配方浏览器

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

配方浏览器是一个小巧的混合网页浏览器应用程序。它演示了如何以下方式使用Qt WebEngine C++类来组合C++和JavaScript逻辑:

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

Markdown是一种轻量级的标记语言,具有纯文本格式化语法。一些服务,如github,认可这种格式,并在浏览器中查看时将其内容渲染为富文本。

配方浏览器的主窗口分为左侧的导航和右侧的预览区域。当用户单击主窗口左上角的编辑按钮时,右侧的预览区域将切换到编辑器。编辑器支持Markdown语法,并由使用QPlainTextEdit实现。用户单击查看按钮后,文档将在预览区域中以富文本形式渲染,此时编辑按钮将转换为查看按钮。此渲染通过使用QWebEngineView实现。要渲染文本,位于网页引擎中的JavaScript库将Markdown文本转换为HTML。预览通过QWebChannel从编辑器更新。

运行示例

要从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,要在C++端设置它,请使用setText()方法,并作为runtime时的一个text属性公开,同时提供一个textChanged信号。我们将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 是一个 QMap<QString, QString>,它包含键/值对 {page, text},因此页面的文本内容更改在导航之间持续存在。尽管如此,我们并没有将修改后的文本内容写入驱动器,而是通过 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_contentDocument 类的一个实例。

不同对象的实际设置在 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 以根据 UI 文件构建小部件和菜单操作。文本编辑器字体设置为具有固定字符宽度的字体,并将 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 信号连接到一个 lambda,以更新 m_content 中的文本。然后,通过 QWebChannel 将此对象在名称 content 之下暴露给 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));

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

处理样式表

我们使用JavaScript来创建和附加CSS元素到文档中。在声明脚本源之后,可以通过QWebEnginePage::runJavaScript() 立即运行它并应用到当前WebView内容的新创建样式。将脚本封装到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的markdown.css,以及进行一些额外样式的自定义.css,最重要的是隐藏具有id content<div>,因为这个 <div> 仅包含未修改的初始内容文本。还包括三个JS脚本。负责解析markdown并将其转换为HTML的marked.js。负责对marked.js进行配置的自定义.js,以及暴露QWebChannel JavaScript API的qwebchannel.js

在主体中,有两个 <div> 元素。具有id placeholder<div> 获取需要渲染并可见的markdown文本注入。id content<div> 被custom.css隐藏,并且只包含原始的、未修改的文本内容。

最后,在每个配方HTML文件的底部是一个脚本,负责通过QWebChannel在C++和JavaScript之间进行通信。原始的、未修改的文本内容被传递到C++侧,并设置了一个回调,当m_contenttextChanged信号发出时,调用该回调。然后回调以解析的markdown更新<div> placeholder的内容。

文件和归属

示例将以下代码与第三方许可证捆绑在一起。

MarkedMIT许可证
Markdown.cssApache许可证2.0

示例项目 @ code.qt.io

© 2024 The Qt Company Ltd。此处包含的文档贡献是各自所有者的版权。提供的文档是根据自由软件基金会发布的GNU自由文档许可证版本1.3的条款许可的。Qt 及相关标志是 The Qt Company Ltd 在芬兰以及/或世界其他地区的商标。所有其他商标均为各自所有者的财产。