配方浏览器

为网页注入自定义样式表,并为自定义标记语言提供富文本预览工具。

Recipe Browser是一个小型混合网络浏览器应用程序。它演示了如何使用 Qt WebEngine C++ classes将 C++ 和 JavaScript 逻辑结合起来的方法。

  • 通过QWebEnginePage::runJavaScript() 运行任意 JavaScript 代码,以注入自定义 CSS 样式表
  • 使用QWebEngineScriptQWebEngineScriptCollection 来持久化 JavaScript 代码,并将其注入到每个页面中
  • 使用QWebChannel 与自定义标记语言交互并提供富文本预览

Markdown是一种采用纯文本格式语法的轻量级标记语言。一些服务(如github)承认这种格式,并在浏览器中将内容呈现为富文本。

配方浏览器主窗口分为左侧的导航区和右侧的预览区。当用户点击主窗口左上角的编辑按钮时,右侧的预览区域会切换为编辑器。编辑器支持 Markdown 语法,使用QPlainTextEdit 实现。用户点击 "查看 "按钮后,文档会在预览区域以富文本形式呈现,而 "编辑 "按钮会转换为富文本形式。这种渲染是通过QWebEngineView 实现的。为了呈现文本,网络引擎内的 JavaScript 库将 Markdown 文本转换为 HTML。预览通过QWebChannel 从编辑器更新。

运行示例

要从 Qt Creator,打开Welcome 模式,然后从Examples 中选择示例。更多信息,请参阅Qt Creator: 教程:构建并运行

公开文档文本

要呈现当前的 Markdown 文本,需要通过QWebChannel 将其暴露给网络引擎。为此,它必须成为 Qt 元类型系统的一部分。这是通过使用一个专用的Document 类来实现的,该类将文档文本公开为Q_PROPERTY

class Document : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString text MEMBER m_currentText NOTIFY textChanged FINAL)
public:
    explicit Document(QObject *parent = nullptr);

    void setTextEdit(QPlainTextEdit *textEdit);
    void setCurrentPage(const QString &page);

public slots:
    void setInitialText(const QString &text);
    void setText(const QString &text);

signals:
    void textChanged(const QString &text);

private:
    QPlainTextEdit *m_textEdit;

    QString m_currentText;
    QString m_currentPage;
    QMap<QString, QString> m_textCollection;
};

Document 类封装了一个QString m_currentText ,通过setText() 方法在 C++ 端进行设置,并在运行时通过textChanged 信号将其作为text 属性公开。我们对setText 方法的定义如下:

void Document::setText(const QString &text)
{
    if (text == m_currentText)
        return;
    m_currentText = text;
    emit textChanged(m_currentText);

    QSettings settings;
    settings.beginGroup("textCollection");
    settings.setValue(m_currentPage, text);
    m_textCollection.insert(m_currentPage, text);
    settings.endGroup();
}

此外,Document 类通过m_currentPage 跟踪当前食谱。我们在这里称菜谱为页面,因为每个菜谱都有自己独特的 HTML 文档,其中包含初始文本内容。此外,m_textCollection 是一个包含键/值对 {page, text} 的QMap<QString,QString>,因此,对页面文本内容所做的更改会在两次导航之间持续存在。不过,我们不会将修改后的文本内容写入驱动器,而是通过QSettings 在应用程序启动和关闭之间持久保存。

创建主窗口

MainWindow 类继承于QMainWindow 类:

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = nullptr);
    ~MainWindow();

    void insertStyleSheet(const QString &name, const QString &source, bool immediately);
    void removeStyleSheet(const QString &name, bool immediately);
    bool hasStyleSheet(const QString &name);
    void loadDefaultStyleSheets();

private slots:
    void showStyleSheetsDialog();
    void toggleEditView();

private:
    Ui::MainWindow *ui;

    bool m_isEditMode;
    Document m_content;
};

该类声明了与左上角导航列表视图上的两个按钮相匹配的私有槽。此外,还声明了用于自定义 CSS 样式表的辅助方法。

主窗口的实际布局在.ui 文件中指定。窗口部件和操作可在运行时通过ui 成员变量获得。

m_isEditMode m_content 是 类的实例。Document

不同对象的实际设置在MainWindow 构造函数中完成:

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent), ui(new Ui::MainWindow), m_isEditMode(false)
{
    ui->setupUi(this);
    ui->textEdit->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
    ui->textEdit->hide();
    ui->webEngineView->setContextMenuPolicy(Qt::NoContextMenu);

构造函数首先调用setupUi ,根据用户界面文件构造部件和菜单操作。文本编辑器的字体被设置为具有固定字符宽度的字体,而QWebEngineView 部件则被告知不显示上下文菜单。此外,编辑器也被隐藏起来。

    connect(ui->stylesheetsButton, &QPushButton::clicked, this, &MainWindow::showStyleSheetsDialog);
    connect(ui->editViewButton, &QPushButton::clicked, this, &MainWindow::toggleEditView);

在这里,QPushButtonclicked 信号分别连接到显示样式表对话框或在编辑和查看模式之间切换的功能,即分别隐藏和显示编辑器和预览区。

    ui->recipes->insertItem(0, "Burger");
    ui->recipes->insertItem(1, "Cupcakes");
    ui->recipes->insertItem(2, "Pasta");
    ui->recipes->insertItem(3, "Pizza");
    ui->recipes->insertItem(4, "Skewers");
    ui->recipes->insertItem(5, "Soup");
    ui->recipes->insertItem(6, "Steak");
    connect(ui->recipes, &QListWidget::currentItemChanged, this,
            [this](QListWidgetItem *current, QListWidgetItem * /* previous */) {
                const QString page = current->text().toLower();
                const QString url = QStringLiteral("qrc:/pages/") + page + QStringLiteral(".html");
                ui->webEngineView->setUrl(QUrl(url));
                m_content.setCurrentPage(page);
            });

这里,左侧的导航QListWidget 设置了 7 个菜谱。此外,QListWidget 的 currentItemChanged 信号被连接到一个 lambda,该 lambda 会加载新的当前菜谱页面,并更新m_content 中的页面。

    m_content.setTextEdit(ui->textEdit);

接下来,ui 编辑器指针(QPlainTextEdit )被传递到m_content ,以确保对Document::setInitialText() 的调用正常工作。

    connect(ui->textEdit, &QPlainTextEdit::textChanged, this,
            [this]() { m_content.setText(ui->textEdit->toPlainText()); });

    QWebChannel *channel = new QWebChannel(this);
    channel->registerObject(QStringLiteral("content"), &m_content);
    ui->webEngineView->page()->setWebChannel(channel);

在这里,编辑器的textChanged 信号与更新m_content 中文本的 lambda 相连。然后,这个对象会以content 的名义通过QWebChannel 暴露给 JS 端。

    QSettings settings;
    settings.beginGroup("styleSheets");
    QStringList styleSheets = settings.allKeys();
    if (styleSheets.empty()) {
        // Add back default style sheets if the user cleared them out
        loadDefaultStyleSheets();
    } else {
        for (const auto &name : std::as_const(styleSheets)) {
            StyleSheet styleSheet = settings.value(name).value<StyleSheet>();
            if (styleSheet.second)
                insertStyleSheet(name, styleSheet.first, false);
        }
    }
    settings.endGroup();

通过使用QSettings ,我们可以在应用程序运行之间保持样式表。如果没有配置样式表(例如,由于用户在上次运行中删除了所有样式表),我们将加载默认样式表。

    ui->recipes->setCurrentItem(ui->recipes->item(0));

最后,我们将当前选择的列表项目设置为导航列表 widget 中的第一个项目。这将触发之前提到的QListWidget::currentItemChanged 信号,并导航到列表项的页面。

使用样式表

我们使用 JavaScript 创建 CSS 元素并将其添加到文档中。在声明脚本源后,QWebEnginePage::runJavaScript() 可以立即运行脚本,并将新创建的样式应用于网页视图的当前内容。将脚本封装到QWebEngineScript 中,并添加到QWebEnginePage 的脚本集合中,可使其永久生效。

void MainWindow::insertStyleSheet(const QString &name, const QString &source, bool immediately)
{
    QWebEngineScript script;
    QString s = QString::fromLatin1("(function() {"
                                    "    css = document.createElement('style');"
                                    "    css.type = 'text/css';"
                                    "    css.id = '%1';"
                                    "    document.head.appendChild(css);"
                                    "    css.innerText = '%2';"
                                    "})()")
                        .arg(name, source.simplified());
    if (immediately)
        ui->webEngineView->page()->runJavaScript(s, QWebEngineScript::ApplicationWorld);

    script.setName(name);
    script.setSourceCode(s);
    script.setInjectionPoint(QWebEngineScript::DocumentReady);
    script.setRunsOnSubFrames(true);
    script.setWorldId(QWebEngineScript::ApplicationWorld);
    ui->webEngineView->page()->scripts().insert(script);
}

删除样式表的方法与此类似:

void MainWindow::removeStyleSheet(const QString &name, bool immediately)
{
    QString s = QString::fromLatin1("(function() {"
                                    "    var element = document.getElementById('%1');"
                                    "    element.outerHTML = '';"
                                    "    delete element;"
                                    "})()")
                        .arg(name);
    if (immediately)
        ui->webEngineView->page()->runJavaScript(s, QWebEngineScript::ApplicationWorld);

    const QList<QWebEngineScript> scripts = ui->webEngineView->page()->scripts().find(name);
    if (!scripts.isEmpty())
        ui->webEngineView->page()->scripts().remove(scripts.first());
}

创建配方文件

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Insanity Burger</title>
  <link rel="stylesheet" type="text/css" href="../3rdparty/markdown.css">
  <link rel="stylesheet" type="text/css" href="../custom.css">
  <script src="../3rdparty/marked.js"></script>
  <script src="../custom.js"></script>
  <script src="qrc:/qtwebchannel/qwebchannel.js"></script>
</head>
<body>
  <div id="placeholder"></div>
  <div id="content">

<img src="images/burger.jpg" alt="Insanity Burger" title="Insanity Burger" />

Insanity burger
===============

### Ingredients

* 800 g minced chuck steak
* olive oil
* 1 large red onion
* 1 splash of white wine vinegar
* 2 large gherkins
* 4 sesame-topped brioche burger buns
* 4-8 rashers of smoked streaky bacon
* 4 teaspoons American mustard
* Tabasco Chipotle sauce
* 4 thin slices of Red Leicester cheese
* 4 teaspoons tomato ketchup

#### For the burger sauce
* ¼ of an iceberg lettuce
* 2 heaped tablespoons mayonnaise
* 1 heaped tablespoon tomato ketchup
* 1 teaspoon Tabasco Chipotle sauce
* 1 teaspoon Worcestershire sauce
* 1 teaspoon brandy, or bourbon (optional)

### Instructions
For the best burger, go to your butcher’s and ask them to mince 800g of chuck steak for you.
This cut has a really good balance of fat and flavoursome meat. Divide it into 4 and, with wet
hands, roll each piece into a ball, then press into flat patties roughly 12cm wide and about 2cm
wider than your buns. Place on an oiled plate and chill in the fridge. Next, finely slice the red
onion, then dress in a bowl with the vinegar and a pinch of sea salt. Slice the gherkins and halve
the buns. Finely chop the lettuce and mix with the rest of the burger sauce ingredients in a bowl,
then season to taste.

I like to only cook 2 burgers at a time to achieve perfection, so get two pans on the go – a large
non-stick pan on a high heat for your burgers and another on a medium heat for the bacon. Pat your
burgers with oil and season them with salt and pepper. Put 2 burgers into the first pan, pressing
down on them with a fish slice, then put half the bacon into the other pan. After 1 minute, flip
the burgers and brush each cooked side with ½ a teaspoon of mustard and a dash of Tabasco. After
another minute, flip onto the mustard side and brush again with another ½ teaspoon of mustard and
a second dash of Tabasco on the other side. Cook for one more minute, by which point you can place
some crispy bacon on top of each burger with a slice of cheese. Add a tiny splash of water to the
pan and place a heatproof bowl over the burgers to melt the cheese – 30 seconds should do it. At the
same time, toast 2 split buns in the bacon fat in the other pan until lightly golden. Repeat with
the remaining two burgers.

To build each burger, add a quarter of the burger sauce to the bun base, then top with a cheesy
bacon burger, a quarter of the onions and gherkins. Rub the bun top with a teaspoon of ketchup,
then gently press together. As the burger rests, juices will soak into the bun, so serve right
away, which is great, or for an extra filthy experience, wrap each one in greaseproof paper, then
give it a minute to go gorgeous and sloppy.

**Enjoy!**

  </div><!--End of content-->

  <script>
    'use strict';

    var jsContent = document.getElementById('content');
    var placeholder = document.getElementById('placeholder');

    var updateText = function(text) {
      placeholder.innerHTML = marked.parse(text);
    }

    new QWebChannel(qt.webChannelTransport,
      function(channel) {
        var content = channel.objects.content;
        content.setInitialText(jsContent.innerHTML);
        content.textChanged.connect(updateText);
      }
    );
  </script>
</body>
</html>

所有不同食谱页面的设置方法相同。

<head> 部分,它们包含两个 CSS 文件:markdown.cssCSS文件,用于对markdown进行样式化,以及custom.css文件,用于进一步样式化,但最重要的是隐藏带有id内容<div> ,因为该<div> 只包含未修改的初始内容文本。marked.js 负责解析 markdown 并将其转换为 HTML。custom.jsmarked.js 进行一些配置,而qwebchannel.js 则公开QWebChannel JavaScript API。

正文中有两个<div> 元素。带有 id占位符<div> 会注入标记符文本,这些文本会被渲染并显示出来。带有 id 的<div> 内容custom.css 隐藏,只包含菜谱未经修改的原始文本内容。

最后,在每个食谱 HTML 文件的底部都有一个脚本,负责通过QWebChannel 在 C++ 和 JavaScript 端之间进行通信。<div> 中带有 id内容的未经修改的原始文本内容会传递给 C++ 端,并设置一个回调,当m_contenttextChanged 信号发出时,该回调就会被调用。回调会使用解析后的标记更新<div> 占位符的内容。

文件和属性

本示例捆绑了以下代码,并获得了第三方许可:

标记MIT 许可
Markdown.cssApache 许可证 2.0

示例项目 @ code.qt.io

© 2025 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.