// Copyright (C) 2024 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 #include "markdownbrowser.h" #include "algorithm.h" #include "async.h" #include "mimeutils.h" #include "movie.h" #include "networkaccessmanager.h" #include "stringutils.h" #include "stylehelper.h" #include "textutils.h" #include "theme/theme.h" #include "utilsicons.h" #include "utilstr.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace Utils { using namespace StyleHelper; using TextFormat = std::pair; static constexpr TextFormat contentTF{Theme::Token_Text_Default, UiElement::UiElementBody2}; static constexpr std::array markdownHeadingFormats{ TextFormat{contentTF.first, UiElement::UiElementH4}, TextFormat{contentTF.first, UiElement::UiElementH5}, TextFormat{contentTF.first, UiElement::UiElementH6Capital}, TextFormat{contentTF.first, UiElement::UiElementH6Capital}, TextFormat{contentTF.first, UiElement::UiElementH6Capital}, TextFormat{contentTF.first, UiElement::UiElementH6Capital}, }; static constexpr int MinimumSizeBlocks = 5; static QFont font(TextFormat format, bool underlined = false) { QFont result = Utils::StyleHelper::uiFont(format.second); result.setUnderline(underlined); return result; } static int lineHeight(TextFormat format) { return Utils::StyleHelper::uiFontLineHeight(format.second); } static QColor color(TextFormat format) { return Utils::creatorColor(format.first); } static QTextDocument *highlightText(const QString &code, const QString &language) { auto mimeTypes = mimeTypesForFileName("file." + language); QString mimeType = mimeTypes.isEmpty() ? "text/" + language : mimeTypes.first().name(); auto document = Utils::Text::highlightCode(code, mimeType); if (document.isFinished()) return document.result(); document.cancel(); QTextDocument *doc = new QTextDocument; doc->setPlainText(code); return doc; } static QStringList defaultCodeFontFamilies() { return {"Menlo", "Source Code Pro", "Monospace", "Courier"}; } static QTextFragment copyButtonFragment(const QTextBlock &block); class CopyButtonHandler : public QObject, public QTextObjectInterface { Q_OBJECT Q_INTERFACES(QTextObjectInterface) public: explicit CopyButtonHandler(QObject *parent = nullptr) : QObject(parent) {} static constexpr int objectId() { return QTextFormat::UserObject + 1; } static constexpr int codePropertyId() { return QTextFormat::UserProperty + 1; } static constexpr int isCopiedPropertyId() { return QTextFormat::UserProperty + 2; } static QString text(bool isCopied) { return " " + (isCopied ? Tr::tr("Copied") : Tr::tr("Copy")); } static QIcon icon(bool isCopied) { static QIcon clickedIcon = Utils::Icons::OK.icon(); static QIcon unclickedIcon = Utils::Icons::COPY.icon(); if (isCopied) return clickedIcon; return unclickedIcon; } static void resetOtherCopyButtons(QTextDocument *doc, const QTextFragment &clickedFragment) { for (QTextBlock block = doc->begin(); block != doc->end(); block = block.next()) { const QTextFragment otherFragment = copyButtonFragment(block); if (otherFragment.isValid() && otherFragment != clickedFragment) { QTextCursor resetCursor(block); resetCursor.setPosition(otherFragment.position()); resetCursor.setPosition( otherFragment.position() + otherFragment.length(), QTextCursor::KeepAnchor); QTextCharFormat resetFormat = otherFragment.charFormat(); resetFormat.setProperty(isCopiedPropertyId(), false); resetCursor.setCharFormat(resetFormat); } } } static void copyCodeAndUpdateButton(QTextCursor &cursor, const QTextCharFormat &format) { const QString code = format.property(codePropertyId()).value(); Utils::setClipboardAndSelection(code); QTextCharFormat newFormat = format; newFormat.setProperty(isCopiedPropertyId(), true); cursor.setCharFormat(newFormat); } QSizeF intrinsicSize(QTextDocument *doc, int pos, const QTextFormat &format) override { Q_UNUSED(pos) if (!doc || !format.hasProperty(isCopiedPropertyId())) return QSizeF(0, 0); const QFontMetricsF metrics(getFont(doc)); const bool isCopied = format.property(isCopiedPropertyId()).value(); return QSizeF(metrics.horizontalAdvance(text(isCopied)) + 30, metrics.height() + 10); } void drawObject( QPainter *painter, const QRectF &rect, QTextDocument *doc, int pos, const QTextFormat &format) override { Q_UNUSED(pos) if (!doc || !format.hasProperty(isCopiedPropertyId())) return; painter->setPen(Qt::NoPen); painter->setBrush(Qt::transparent); painter->drawRect(rect); const bool isCopied = format.property(isCopiedPropertyId()).value(); constexpr int iconSize = 16; QRectF iconRect(rect.left(), rect.top() + (rect.height() - iconSize) / 2, iconSize, iconSize); icon(isCopied).paint(painter, iconRect.toRect()); painter->setPen(QColor("#888")); painter->setFont(getFont(doc)); painter ->drawText(rect.adjusted(20, 0, -5, 0), Qt::AlignLeft | Qt::AlignVCenter, text(isCopied)); } private: QFont getFont(QTextDocument *doc) const { QFont font = doc->defaultFont(); font.setPointSize(10); return font; } }; static QTextFragment copyButtonFragment(const QTextBlock &block) { for (auto it = block.begin(); !it.atEnd(); ++it) { QTextFragment fragment = it.fragment(); if (fragment.charFormat().objectType() == CopyButtonHandler::objectId()) return fragment; } return QTextFragment(); } static QPointF blockBBoxTopLeftPosition(const QTextBlock &block) { const QAbstractTextDocumentLayout *docLayout = block.document()->documentLayout(); const QRectF blockRect = docLayout->blockBoundingRect(block); return blockRect.topLeft(); } static QRectF calculateFragmentBounds( const QTextBlock &block, const QTextFragment &fragment, const QPointF &documentOffset) { QRectF bounds(0, 0, 0, 0); if (!block.isValid() || !fragment.isValid()) return bounds; QTextLayout *layout = block.layout(); if (!layout) return bounds; int fragmentStart = fragment.position() - block.position(); QTextLine line = layout->lineForTextPosition(fragmentStart); if (!line.isValid()) return bounds; qreal x = line.cursorToX(fragmentStart); qreal y = line.y(); qreal width = line.cursorToX(fragmentStart + fragment.length()) - x; qreal height = line.height(); bounds = QRectF(x, y, width, height); bounds.translate(documentOffset); return bounds; } class AnimatedImageHandler : public QObject, public QTextObjectInterface { Q_OBJECT Q_INTERFACES(QTextObjectInterface) public: class Entry { public: using Pointer = std::shared_ptr; Entry(const QByteArray &data) { if (data.isEmpty()) return; buffer.setData(data); movie.setDevice(&buffer); if (movie.isValid()) { if (!movie.frameRect().isValid()) movie.jumpToFrame(0); } moveToThread(nullptr); } void moveToThread(QThread *thread) { buffer.moveToThread(thread); movie.moveToThread(thread); } QBuffer buffer; QtcMovie movie; }; public: AnimatedImageHandler( QObject *parent, std::function redraw, std::function scheduleLoad) : QObject(parent) , m_redraw(redraw) , m_scheduleLoad(scheduleLoad) , m_entries(1024 * 1024 * 10) // 10 MB max image cache size {} static Entry::Pointer makeEntry(const QByteArray &data, qsizetype maxSize) { // If the image is larger than what we allow in our cache, // we still want to create an entry, but one with an empty image. // So we clear it here, but still create the entry, so the painter can // correctly show the "broken image" placeholder instead. if (data.size() > maxSize) return std::make_shared(QByteArray()); return std::make_shared(data); } virtual QSizeF intrinsicSize( QTextDocument *doc, int posInDocument, const QTextFormat &format) override { Q_UNUSED(doc) Q_UNUSED(posInDocument) QSize result = Utils::Icons::UNKNOWN_FILE.icon().actualSize(QSize(16, 16)); QString name = format.toImageFormat().name(); Entry::Pointer *entryPtr = m_entries.object(name); if (!entryPtr) { m_scheduleLoad(name); return result; } Entry::Pointer entry = *entryPtr; if (entry->movie.isValid()) { if (!entry->movie.frameRect().isValid()) entry->movie.jumpToFrame(0); result = entry->movie.frameRect().size(); } return result; } void drawObject( QPainter *painter, const QRectF &rect, QTextDocument *document, int posInDocument, const QTextFormat &format) override { Q_UNUSED(document) Q_UNUSED(posInDocument) const QString name = format.toImageFormat().name(); Entry::Pointer *entryPtr = m_entries.object(name); if (!entryPtr) { constexpr QStringView themeScheme(u"theme://"); constexpr QStringView iconScheme(u"icon://"); QVariant resource = document->resource(QTextDocument::ImageResource, name); if (resource.isValid()) { const QImage img = qvariant_cast(resource); if (!img.isNull()) { painter->drawImage(rect, img); return; } } else if (name.startsWith(themeScheme)) { const QIcon icon = QIcon::fromTheme(name.mid(themeScheme.length())); if (!icon.isNull()) { painter->drawPixmap( rect.toRect(), icon.pixmap(rect.size().toSize(), painter->device()->devicePixelRatioF())); return; } } else if (name.startsWith(iconScheme)) { std::optional icon = Icons::fromString(name.mid(iconScheme.length())); if (icon) { painter->drawPixmap(rect.toRect(), icon->pixmap()); return; } } painter->drawPixmap( rect.toRect(), Utils::Icons::UNKNOWN_FILE.icon().pixmap(rect.size().toSize())); } else if (!(*entryPtr)->movie.isValid()) painter->drawPixmap(rect.toRect(), m_brokenImage.pixmap(rect.size().toSize())); else painter->drawImage(rect, (*entryPtr)->movie.currentImage()); } void set(const QString &name, const QByteArray &data) { set(name, makeEntry(data, m_entries.maxCost())); } void set(const QString &name, const Entry::Pointer &entry) { entry->moveToThread(thread()); if (entry->movie.frameCount() > 1) { connect(&entry->movie, &QtcMovie::frameChanged, this, [this]() { m_redraw(); }); entry->movie.start(); } const qint64 size = qMax(1, entry->buffer.size()); if (size > m_entries.maxCost()) { return; } Entry::Pointer *entryPtr = new Entry::Pointer(entry); if (m_entries.insert(name, entryPtr, size)) m_redraw(); } void setMaximumCacheSize(qsizetype maxSize) { m_entries.setMaxCost(maxSize); } qsizetype maximumCacheSize() const { return m_entries.maxCost(); } private: std::function m_redraw; std::function m_scheduleLoad; QCache m_entries; const Icon ErrorCloseIcon = Utils::Icon({{":/utils/images/close.png", Theme::IconsErrorColor}}); const QIcon m_brokenImage = Icon::combinedIcon( {Utils::Icons::UNKNOWN_FILE.icon(), ErrorCloseIcon.icon()}); }; class AnimatedDocument : public QTextDocument { public: AnimatedDocument(QObject *parent = nullptr) : QTextDocument(parent) , m_imageHandler( this, [this]() { documentLayout()->update(); }, [this](const QString &name) { scheduleLoad(QUrl(name)); }) { connect(this, &QTextDocument::documentLayoutChanged, this, [this]() { documentLayout()->registerHandler(QTextFormat::ImageObject, &m_imageHandler); }); connect(this, &QTextDocument::contentsChanged, this, [this]() { if (m_urlsToLoad.isEmpty()) return; if (m_imageLoaderTree.isRunning()) { if (!m_needsToRestartLoading) return; m_imageLoaderTree.cancel(); } m_needsToRestartLoading = false; using namespace Tasking; const bool isBaseHttp = m_basePath.scheme() == QStringLiteral("http") || m_basePath.scheme() == QStringLiteral("https"); const auto isRemoteUrl = [isBaseHttp](const QUrl &url) { return url.scheme() == "http" || url.scheme() == "https" || (url.isRelative() && isBaseHttp); }; const auto isLocalUrl = [this, isRemoteUrl](const QUrl &url) { QVariant res = this->resource(QTextDocument::ImageResource, url); if (res.isValid()) return false; if (url.scheme() == "qrc") return true; if (!url.scheme().isEmpty()) return false; if (!m_basePath.isEmpty() && !isRemoteUrl(url)) return true; return false; }; QSet remoteUrls = Utils::filtered(m_urlsToLoad, isRemoteUrl); QSet localUrls = Utils::filtered(m_urlsToLoad, isLocalUrl); if (!m_loadRemoteImages) remoteUrls.clear(); struct RemoteData { QUrl url; QByteArray data; }; Storage remoteData; const LoopList remoteIterator(Utils::toList(remoteUrls)); const LoopList localIterator(Utils::toList(localUrls)); auto onQuerySetup = [remoteData, this, remoteIterator, base = m_basePath.toUrl()](NetworkQuery &query) { QUrl url = *remoteIterator; if (url.isRelative()) url = base.resolved(url); QNetworkRequest request(url); if (m_requestHook) m_requestHook(&request); query.setRequest(request); query.setNetworkAccessManager(m_networkAccessManager); remoteData->url = *remoteIterator; }; auto onQueryDone = [this, remoteData](const NetworkQuery &query, DoneWith result) { if (result == DoneWith::Cancel) return; m_urlsToLoad.remove(query.reply()->url()); if (result == DoneWith::Success) { remoteData->data = query.reply()->readAll(); } else { m_imageHandler.set(remoteData->url.toString(), QByteArray{}); markContentsDirty(0, this->characterCount()); } }; using EntryPointer = AnimatedImageHandler::Entry::Pointer; auto onMakeEntrySetup = [remoteData, maxSize = m_imageHandler.maximumCacheSize()]( Async &async) { async.setConcurrentCallData( [](const QByteArray &data, qsizetype maxSize) { return AnimatedImageHandler::makeEntry(data, maxSize); }, remoteData->data, maxSize); }; auto onMakeEntryDone = [this, remoteIterator, remoteData](const Async &async) { EntryPointer result = async.result(); if (result) { m_imageHandler.set(remoteData->url.toString(), result); markContentsDirty(0, this->characterCount()); } }; auto onLocalSetup = [localIterator, basePath = m_basePath, maxSize = m_imageHandler.maximumCacheSize()]( Async &async) { const QUrl url = *localIterator; async.setConcurrentCallData( [](QPromise &promise, const FilePath &basePath, const QUrl &url, qsizetype maxSize) { if (url.scheme() == "qrc") { QFile f(":" + url.path()); if (!f.open(QIODevice::ReadOnly)) return; promise.addResult( AnimatedImageHandler::makeEntry(f.readAll(), maxSize)); return; } const FilePath path = basePath.resolvePath(url.path()); auto data = path.fileContents(); if (!data || promise.isCanceled()) return; promise.addResult(AnimatedImageHandler::makeEntry(*data, maxSize)); }, basePath, url, maxSize); }; auto onLocalDone = [localIterator, this](const Async &async) { EntryPointer result = async.result(); if (result) m_imageHandler.set(localIterator->toString(), std::move(result)); }; // clang-format off Group group { parallelLimit(2), For(remoteIterator) >> Do { remoteData, Group { NetworkQueryTask{onQuerySetup, onQueryDone}, AsyncTask(onMakeEntrySetup, onMakeEntryDone), } || successItem, }, For(localIterator) >> Do { AsyncTask(onLocalSetup, onLocalDone) || successItem, }, }; // clang-format on m_imageLoaderTree.start(group); }); } void scheduleLoad(const QUrl &url) { m_urlsToLoad.insert(url); m_needsToRestartLoading = true; } void setBasePath(const FilePath &filePath) { m_basePath = filePath; } void setAllowRemoteImages(bool allow) { m_loadRemoteImages = allow; } void setNetworkAccessManager(QNetworkAccessManager *nam) { m_networkAccessManager = nam; } void setRequestHook(const MarkdownBrowser::RequestHook &hook) { m_requestHook = hook; } void setMaximumCacheSize(qsizetype maxSize) { m_imageHandler.setMaximumCacheSize(maxSize); } private: AnimatedImageHandler m_imageHandler; QSet m_urlsToLoad; bool m_needsToRestartLoading = false; bool m_loadRemoteImages = false; Tasking::TaskTreeRunner m_imageLoaderTree; FilePath m_basePath; std::function m_requestHook; QNetworkAccessManager *m_networkAccessManager = NetworkAccessManager::instance(); }; MarkdownBrowser::MarkdownBrowser(QWidget *parent) : QTextBrowser(parent) , m_enableCodeCopyButton(false) { setOpenLinks(false); connect(this, &QTextBrowser::anchorClicked, this, &MarkdownBrowser::handleAnchorClicked); setDocument(new AnimatedDocument(this)); document() ->documentLayout() ->registerHandler(CopyButtonHandler::objectId(), new CopyButtonHandler(document())); } void MarkdownBrowser::highlightCodeBlock(const QString &language, QTextBlock &block) { const int startPos = block.position(); // Find the end of the code block ... for (block = block.next(); block.isValid(); block = block.next()) { if (!block.blockFormat().hasProperty(QTextFormat::BlockCodeLanguage)) break; if (language != block.blockFormat().stringProperty(QTextFormat::BlockCodeLanguage)) break; } const int endPos = (block.isValid() ? block.position() : document()->characterCount()) - 1; // Get the text of the code block and erase it QTextCursor eraseCursor(document()); eraseCursor.setPosition(startPos); eraseCursor.setPosition(endPos, QTextCursor::KeepAnchor); const QString code = eraseCursor.selectedText(); eraseCursor.removeSelectedText(); // Reposition the main cursor to startPos, to insert new content block = document()->findBlock(startPos); QTextCursor cursor(block); QTextFrameFormat frameFormat; frameFormat.setBorderStyle(QTextFrameFormat::BorderStyle_Solid); frameFormat.setBackground(creatorColor(Theme::Token_Background_Muted)); frameFormat.setPadding(SpacingTokens::ExPaddingGapM); frameFormat.setLeftMargin(SpacingTokens::VGapM); frameFormat.setRightMargin(SpacingTokens::VGapM); QTextFrame *frame = cursor.insertFrame(frameFormat); QTextCursor frameCursor(frame); if (m_enableCodeCopyButton) { QTextBlockFormat rightAlignedCopyButton; rightAlignedCopyButton.setAlignment(Qt::AlignRight); frameCursor.insertBlock(rightAlignedCopyButton); QString copiableCode = code; copiableCode.replace(QChar::ParagraphSeparator, '\n'); QTextCharFormat buttonFormat; buttonFormat.setObjectType(CopyButtonHandler::objectId()); buttonFormat.setProperty(CopyButtonHandler::codePropertyId(), copiableCode); buttonFormat.setProperty(CopyButtonHandler::isCopiedPropertyId(), false); frameCursor.insertText(QString(QChar::ObjectReplacementCharacter), buttonFormat); QTextBlockFormat leftAlignedCode; leftAlignedCode.setAlignment(Qt::AlignLeft); frameCursor.insertBlock(leftAlignedCode); } std::unique_ptr codeDoc(highlightText(code, language)); // Iterate each line in codeDoc and copy it out bool firstLine = true; for (auto tempBlock = codeDoc->begin(); tempBlock != codeDoc->end(); tempBlock = tempBlock.next()) { // For each subsequent line, insert another block if (!firstLine) { QTextBlockFormat codeBlockFmt; codeBlockFmt.setAlignment(Qt::AlignLeft); frameCursor.insertBlock(codeBlockFmt); } firstLine = false; QTextCharFormat lineFormat = tempBlock.charFormat(); lineFormat.setFontFamilies(defaultCodeFontFamilies()); frameCursor.setCharFormat(lineFormat); auto formats = tempBlock.layout()->formats(); frameCursor.insertText(tempBlock.text()); frameCursor.block().layout()->setFormats(formats); } // Leave the frame QTextCursor next = frame->lastCursorPosition(); block = next.block(); } QSize MarkdownBrowser::sizeHint() const { return document()->size().toSize(); } QSize MarkdownBrowser::minimumSizeHint() const { //Lets use the size of the first few blocks as minimum size hint QTextBlock block = document()->begin(); QRectF boundingRect; for (int i = 0; i < MinimumSizeBlocks && block.isValid(); ++i, block = block.next()) { QTextLayout *layout = block.layout(); QRectF blockRect = layout->boundingRect(); boundingRect.adjust(0, 0, 0, blockRect.height()); boundingRect.setWidth(qMax(boundingRect.width(), blockRect.width())); } return boundingRect.size().toSize() + QTextBrowser::minimumSizeHint(); } void MarkdownBrowser::setMargins(const QMargins &margins) { setViewportMargins(margins); } void MarkdownBrowser::setEnableCodeCopyButton(bool enable) { m_enableCodeCopyButton = enable; } void MarkdownBrowser::setAllowRemoteImages(bool allow) { static_cast(document())->setAllowRemoteImages(allow); } void MarkdownBrowser::setNetworkAccessManager(QNetworkAccessManager *nam) { static_cast(document())->setNetworkAccessManager(nam); } void MarkdownBrowser::setRequestHook(const RequestHook &hook) { static_cast(document())->setRequestHook(hook); } void MarkdownBrowser::setMaximumCacheSize(qsizetype maxSize) { static_cast(document())->setMaximumCacheSize(maxSize); } void MarkdownBrowser::handleAnchorClicked(const QUrl &link) { if (link.scheme() == "http" || link.scheme() == "https") QDesktopServices::openUrl(link); if (link.hasFragment() && link.path().isEmpty() && link.scheme().isEmpty()) { // local anchor scrollToAnchor(link.fragment(QUrl::FullyEncoded)); } } void MarkdownBrowser::setBasePath(const FilePath &filePath) { static_cast(document())->setBasePath(filePath); } void MarkdownBrowser::setMarkdown(const QString &markdown) { QScrollBar *sb = verticalScrollBar(); const int scrollValue = sb->value(); document()->setMarkdown(markdown); postProcessDocument(true); QTimer::singleShot(0, this, [sb, scrollValue] { sb->setValue(scrollValue); }); // Reset cursor to start of the document, so that "show" does not // scroll to the end of the document. setTextCursor(QTextCursor(document())); } QString MarkdownBrowser::toMarkdown() const { return document()->toMarkdown(); } void MarkdownBrowser::postProcessDocument(bool firstTime) { const QFont contentFont = Utils::font(contentTF); const float fontScale = font().pointSizeF() / qGuiApp->font().pointSizeF(); const auto scaledFont = [fontScale](QFont f) { f.setPointSizeF(f.pointSizeF() * fontScale); return f; }; document()->setDefaultFont(scaledFont(contentFont)); for (QTextBlock block = document()->begin(); block != document()->end(); block = block.next()) { if (firstTime) { const QTextBlockFormat blockFormat = block.blockFormat(); // Leave images as they are. if (block.text().contains(QChar::ObjectReplacementCharacter)) continue; // Convert code blocks to highlighted frames if (blockFormat.hasProperty(QTextFormat::BlockCodeLanguage)) { const QString language = blockFormat.stringProperty(QTextFormat::BlockCodeLanguage); highlightCodeBlock(language, block); continue; } // Add anchors to headings. This should actually be done by Qt QTBUG-120518 if (blockFormat.hasProperty(QTextFormat::HeadingLevel)) { QTextCharFormat cFormat = block.charFormat(); QString anchor; const QString text = block.text(); for (const QChar &c : text) { if (c == ' ') anchor.append('-'); else if (c == '_' || c == '-' || c.isDigit() || c.isLetter()) anchor.append(c.toLower()); } cFormat.setAnchor(true); cFormat.setAnchorNames({anchor}); QTextCursor cursor(block); cursor.setBlockCharFormat(cFormat); } } // Update fonts QTextCursor cursor(block); auto blockFormat = block.blockFormat(); const auto scaledFont = [fontScale](QFont f) { f.setPointSizeF(f.pointSizeF() * fontScale); return f; }; if (blockFormat.hasProperty(QTextFormat::HeadingLevel)) { blockFormat.setTopMargin(SpacingTokens::ExVPaddingGapXl * fontScale); blockFormat.setBottomMargin(SpacingTokens::VGapL * fontScale); } else { blockFormat .setLineHeight(lineHeight(contentTF) * fontScale, QTextBlockFormat::FixedHeight); } cursor.mergeBlockFormat(blockFormat); const TextFormat &headingTf = markdownHeadingFormats[qBound(0, blockFormat.headingLevel() - 1, 5)]; const QFont headingFont = scaledFont(Utils::font(headingTf)); for (auto it = block.begin(); !(it.atEnd()); ++it) { const QTextFragment fragment = it.fragment(); if (fragment.isValid()) { QTextCharFormat charFormat = fragment.charFormat(); cursor.setPosition(fragment.position()); cursor.setPosition(fragment.position() + fragment.length(), QTextCursor::KeepAnchor); if (blockFormat.hasProperty(QTextFormat::HeadingLevel)) { // We don't use font size adjustment for headings charFormat.clearProperty(QTextFormat::FontSizeAdjustment); charFormat.setFontPointSize(headingFont.pointSizeF()); charFormat.setFontCapitalization(headingFont.capitalization()); charFormat.setFontFamilies(headingFont.families()); charFormat.setFontWeight(headingFont.weight()); charFormat.setForeground(color(headingTf)); } else if (charFormat.isAnchor()) { charFormat.setForeground(creatorColor(Theme::Token_Text_Accent)); } else { charFormat.setForeground(color(contentTF)); } cursor.setCharFormat(charFormat); } } } } void MarkdownBrowser::changeEvent(QEvent *event) { if (event->type() == QEvent::FontChange) postProcessDocument(false); QTextBrowser::changeEvent(event); } std::optional> MarkdownBrowser::findCopyButtonFragmentAt(const QPoint& viewportPos) { QTextCursor cursor = cursorForPosition(viewportPos); if (cursor.isNull()) return std::nullopt; const QTextCharFormat format = cursor.charFormat(); if (format.objectType() != CopyButtonHandler::objectId()) return std::nullopt; const QTextBlock block = cursor.block(); QTextFragment fragment = copyButtonFragment(block); if (!fragment.isValid()) return std::nullopt; QPointF blockPos = blockBBoxTopLeftPosition(block); QRectF fragmentRect = calculateFragmentBounds(block, fragment, blockPos); fragmentRect.translate(-horizontalScrollBar()->value(), -verticalScrollBar()->value()); if (fragmentRect.contains(viewportPos)) return std::make_pair(fragment, fragmentRect); else return std::nullopt; } void MarkdownBrowser::mousePressEvent(QMouseEvent *event) { auto result = findCopyButtonFragmentAt(event->pos()); if (result) { QTextFragment fragment = result->first; QTextCursor cursor(document()); cursor.setPosition(fragment.position()); cursor.setPosition(fragment.position() + fragment.length(), QTextCursor::KeepAnchor); CopyButtonHandler::resetOtherCopyButtons(document(), fragment); CopyButtonHandler::copyCodeAndUpdateButton(cursor, fragment.charFormat()); document()->documentLayout()->update(); event->accept(); } else QTextBrowser::mousePressEvent(event); } void MarkdownBrowser::mouseMoveEvent(QMouseEvent *event) { const QPoint mousePos = event->pos(); if (m_cachedCopyRect && m_cachedCopyRect->contains(mousePos)) { QTextBrowser::mouseMoveEvent(event); return; } m_cachedCopyRect.reset(); viewport()->unsetCursor(); auto result = findCopyButtonFragmentAt(mousePos); if (result) { m_cachedCopyRect = result->second; viewport()->setCursor(Qt::PointingHandCursor); } QTextBrowser::mouseMoveEvent(event); } QMimeData *MarkdownBrowser::createMimeDataFromSelection() const { // Basically a copy of QTextEditMimeData::setup, just replacing the object markers. QMimeData *mimeData = new QMimeData; QTextDocumentFragment fragment(textCursor()); static const auto removeObjectChar = [](QString &&text) { return text.replace(QChar::ObjectReplacementCharacter, ""); }; mimeData->setData("text/html", removeObjectChar(fragment.toHtml()).toUtf8()); mimeData->setData("text/markdown", removeObjectChar(fragment.toMarkdown()).toUtf8()); { QBuffer buffer; QTextDocumentWriter writer(&buffer, "ODF"); if (writer.write(fragment)) { buffer.close(); mimeData->setData("application/vnd.oasis.opendocument.text", buffer.data()); } } mimeData->setText(removeObjectChar(fragment.toPlainText())); return mimeData; } } // namespace Utils #include "markdownbrowser.moc"