1 | // Copyright (C) 2016 The Qt Company Ltd. |
2 | // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 |
3 | |
4 | #include "helpviewer.h" |
5 | #include "helpviewerimpl.h" |
6 | |
7 | #include "helpenginewrapper.h" |
8 | #include "tracer.h" |
9 | |
10 | #include <QtCore/QFileInfo> |
11 | #include <QtCore/QStringBuilder> |
12 | #include <QtCore/QTemporaryFile> |
13 | |
14 | #include <QtGui/QDesktopServices> |
15 | #if QT_CONFIG(clipboard) |
16 | #include <QtGui/QClipboard> |
17 | #endif |
18 | #include <QtGui/QGuiApplication> |
19 | #include <QtGui/QWheelEvent> |
20 | |
21 | #include <QtWidgets/QScrollBar> |
22 | #include <QtWidgets/QVBoxLayout> |
23 | |
24 | #include <QtHelp/QHelpEngineCore> |
25 | |
26 | #include <qlitehtmlwidget.h> |
27 | |
28 | QT_BEGIN_NAMESPACE |
29 | |
30 | const int kMaxHistoryItems = 20; |
31 | |
32 | struct ExtensionMap { |
33 | const char *extension; |
34 | const char *mimeType; |
35 | } extensionMap[] = { |
36 | { .extension: ".bmp" , .mimeType: "image/bmp" }, |
37 | { .extension: ".css" , .mimeType: "text/css" }, |
38 | { .extension: ".gif" , .mimeType: "image/gif" }, |
39 | { .extension: ".html" , .mimeType: "text/html" }, |
40 | { .extension: ".htm" , .mimeType: "text/html" }, |
41 | { .extension: ".ico" , .mimeType: "image/x-icon" }, |
42 | { .extension: ".jpeg" , .mimeType: "image/jpeg" }, |
43 | { .extension: ".jpg" , .mimeType: "image/jpeg" }, |
44 | { .extension: ".js" , .mimeType: "application/x-javascript" }, |
45 | { .extension: ".mng" , .mimeType: "video/x-mng" }, |
46 | { .extension: ".pbm" , .mimeType: "image/x-portable-bitmap" }, |
47 | { .extension: ".pgm" , .mimeType: "image/x-portable-graymap" }, |
48 | { .extension: ".pdf" , .mimeType: nullptr }, |
49 | { .extension: ".png" , .mimeType: "image/png" }, |
50 | { .extension: ".ppm" , .mimeType: "image/x-portable-pixmap" }, |
51 | { .extension: ".rss" , .mimeType: "application/rss+xml" }, |
52 | { .extension: ".svg" , .mimeType: "image/svg+xml" }, |
53 | { .extension: ".svgz" , .mimeType: "image/svg+xml" }, |
54 | { .extension: ".text" , .mimeType: "text/plain" }, |
55 | { .extension: ".tif" , .mimeType: "image/tiff" }, |
56 | { .extension: ".tiff" , .mimeType: "image/tiff" }, |
57 | { .extension: ".txt" , .mimeType: "text/plain" }, |
58 | { .extension: ".xbm" , .mimeType: "image/x-xbitmap" }, |
59 | { .extension: ".xml" , .mimeType: "text/xml" }, |
60 | { .extension: ".xpm" , .mimeType: "image/x-xpm" }, |
61 | { .extension: ".xsl" , .mimeType: "text/xsl" }, |
62 | { .extension: ".xhtml" , .mimeType: "application/xhtml+xml" }, |
63 | { .extension: ".wml" , .mimeType: "text/vnd.wap.wml" }, |
64 | { .extension: ".wmlc" , .mimeType: "application/vnd.wap.wmlc" }, |
65 | { .extension: "about:blank" , .mimeType: nullptr }, |
66 | { .extension: nullptr, .mimeType: nullptr } |
67 | }; |
68 | |
69 | static QByteArray getData(const QUrl &url) |
70 | { |
71 | // TODO: this is just a hack for Qt documentation |
72 | // which decides to use a simpler CSS if the viewer does not have JavaScript |
73 | // which was a hack to decide if we are viewing in QTextBrowser or QtWebEngine et al |
74 | QUrl actualUrl = url; |
75 | QString path = url.path(options: QUrl::FullyEncoded); |
76 | static const char simpleCss[] = "/offline-simple.css" ; |
77 | if (path.endsWith(s: simpleCss)) { |
78 | path.replace(before: simpleCss, after: "/offline.css" ); |
79 | actualUrl.setPath(path); |
80 | } |
81 | |
82 | if (actualUrl.isValid()) |
83 | return HelpEngineWrapper::instance().fileData(url: actualUrl); |
84 | |
85 | const bool isAbout = (actualUrl.toString() == QLatin1String("about:blank" )); |
86 | return isAbout ? HelpViewerImpl::AboutBlank.toUtf8() |
87 | : HelpViewerImpl::PageNotFoundMessage.arg(a: url.toString()).toUtf8(); |
88 | } |
89 | |
90 | class HelpViewerPrivate |
91 | { |
92 | public: |
93 | struct HistoryItem |
94 | { |
95 | QUrl url; |
96 | QString title; |
97 | int vscroll; |
98 | }; |
99 | HistoryItem currentHistoryItem() const; |
100 | void setSourceInternal(const QUrl &url, int *vscroll = nullptr, bool reload = false); |
101 | void incrementZoom(int steps); |
102 | void applyZoom(int percentage); |
103 | |
104 | HelpViewer *q = nullptr; |
105 | QLiteHtmlWidget *m_viewer = nullptr; |
106 | std::vector<HistoryItem> m_backItems; |
107 | std::vector<HistoryItem> m_forwardItems; |
108 | int m_fontZoom = 100; // zoom percentage |
109 | }; |
110 | |
111 | HelpViewerPrivate::HistoryItem HelpViewerPrivate::currentHistoryItem() const |
112 | { |
113 | return { .url: m_viewer->url(), .title: m_viewer->title(), .vscroll: m_viewer->verticalScrollBar()->value() }; |
114 | } |
115 | |
116 | void HelpViewerPrivate::setSourceInternal(const QUrl &url, int *vscroll, bool reload) |
117 | { |
118 | QGuiApplication::setOverrideCursor(QCursor(Qt::WaitCursor)); |
119 | |
120 | const bool isHelp = (url.toString() == QLatin1String("help" )); |
121 | const QUrl resolvedUrl = (isHelp ? HelpViewerImpl::LocalHelpFile |
122 | : HelpEngineWrapper::instance().findFile(url)); |
123 | |
124 | QUrl currentUrlWithoutFragment = m_viewer->url(); |
125 | currentUrlWithoutFragment.setFragment(fragment: {}); |
126 | QUrl newUrlWithoutFragment = resolvedUrl; |
127 | newUrlWithoutFragment.setFragment(fragment: {}); |
128 | |
129 | m_viewer->setUrl(resolvedUrl); |
130 | if (currentUrlWithoutFragment != newUrlWithoutFragment || reload) |
131 | m_viewer->setHtml(QString::fromUtf8(ba: getData(url: resolvedUrl))); |
132 | if (vscroll) |
133 | m_viewer->verticalScrollBar()->setValue(*vscroll); |
134 | else |
135 | m_viewer->scrollToAnchor(name: resolvedUrl.fragment(options: QUrl::FullyEncoded)); |
136 | |
137 | QGuiApplication::restoreOverrideCursor(); |
138 | |
139 | emit q->sourceChanged(url: q->source()); |
140 | emit q->loadFinished(); |
141 | emit q->titleChanged(); |
142 | } |
143 | |
144 | void HelpViewerPrivate::incrementZoom(int steps) |
145 | { |
146 | const int incrementPercentage = 10 * steps; // 10 percent increase by single step |
147 | const int previousZoom = m_fontZoom; |
148 | applyZoom(percentage: previousZoom + incrementPercentage); |
149 | } |
150 | |
151 | void HelpViewerPrivate::applyZoom(int percentage) |
152 | { |
153 | const int newZoom = qBound(min: 10, val: percentage, max: 300); |
154 | if (newZoom == m_fontZoom) |
155 | return; |
156 | m_fontZoom = newZoom; |
157 | m_viewer->setZoomFactor(newZoom / 100.0); |
158 | } |
159 | |
160 | HelpViewer::HelpViewer(qreal zoom, QWidget *parent) |
161 | : QWidget(parent) |
162 | , d(new HelpViewerPrivate) |
163 | { |
164 | auto layout = new QVBoxLayout; |
165 | d->q = this; |
166 | d->m_viewer = new QLiteHtmlWidget(this); |
167 | d->m_viewer->setResourceHandler([](const QUrl &url) { return getData(url); }); |
168 | d->m_viewer->viewport()->installEventFilter(filterObj: this); |
169 | const int zoomPercentage = zoom == 0 ? 100 : zoom * 100; |
170 | d->applyZoom(percentage: zoomPercentage); |
171 | connect(sender: d->m_viewer, signal: &QLiteHtmlWidget::linkClicked, context: this, slot: &HelpViewer::setSource); |
172 | connect(sender: d->m_viewer, signal: &QLiteHtmlWidget::linkHighlighted, context: this, slot: &HelpViewer::highlighted); |
173 | #if QT_CONFIG(clipboard) |
174 | connect(sender: d->m_viewer, signal: &QLiteHtmlWidget::copyAvailable, context: this, slot: &HelpViewer::copyAvailable); |
175 | #endif |
176 | setLayout(layout); |
177 | layout->setContentsMargins(left: 0, top: 0, right: 0, bottom: 0); |
178 | layout->addWidget(d->m_viewer, stretch: 10); |
179 | |
180 | // Make docs' contents visible in dark theme |
181 | QPalette p = palette(); |
182 | p.setColor(acg: QPalette::Inactive, acr: QPalette::Highlight, |
183 | acolor: p.color(cg: QPalette::Active, cr: QPalette::Highlight)); |
184 | p.setColor(acg: QPalette::Inactive, acr: QPalette::HighlightedText, |
185 | acolor: p.color(cg: QPalette::Active, cr: QPalette::HighlightedText)); |
186 | p.setColor(acr: QPalette::Base, acolor: Qt::white); |
187 | p.setColor(acr: QPalette::Text, acolor: Qt::black); |
188 | setPalette(p); |
189 | } |
190 | |
191 | HelpViewer::~HelpViewer() |
192 | { |
193 | delete d; |
194 | } |
195 | |
196 | QFont HelpViewer::viewerFont() const |
197 | { |
198 | return d->m_viewer->defaultFont(); |
199 | } |
200 | |
201 | void HelpViewer::setViewerFont(const QFont &font) |
202 | { |
203 | d->m_viewer->setDefaultFont(font); |
204 | } |
205 | |
206 | void HelpViewer::scaleUp() |
207 | { |
208 | d->incrementZoom(steps: 1); |
209 | } |
210 | |
211 | void HelpViewer::scaleDown() |
212 | { |
213 | d->incrementZoom(steps: -1); |
214 | } |
215 | |
216 | void HelpViewer::resetScale() |
217 | { |
218 | d->applyZoom(percentage: 100); |
219 | } |
220 | |
221 | qreal HelpViewer::scale() const |
222 | { |
223 | return d->m_viewer->zoomFactor(); |
224 | } |
225 | |
226 | QString HelpViewer::title() const |
227 | { |
228 | return d->m_viewer->title(); |
229 | } |
230 | |
231 | QUrl HelpViewer::source() const |
232 | { |
233 | return d->m_viewer->url(); |
234 | } |
235 | |
236 | void HelpViewer::reload() |
237 | { |
238 | doSetSource(url: source(), reload: true); |
239 | } |
240 | |
241 | void HelpViewer::setSource(const QUrl &url) |
242 | { |
243 | doSetSource(url, reload: false); |
244 | } |
245 | |
246 | void HelpViewer::doSetSource(const QUrl &url, bool reload) |
247 | { |
248 | if (launchWithExternalApp(url)) |
249 | return; |
250 | |
251 | d->m_forwardItems.clear(); |
252 | emit forwardAvailable(enabled: false); |
253 | if (d->m_viewer->url().isValid()) { |
254 | d->m_backItems.push_back(x: d->currentHistoryItem()); |
255 | while (d->m_backItems.size() > kMaxHistoryItems) // this should trigger only once anyhow |
256 | d->m_backItems.erase(position: d->m_backItems.begin()); |
257 | emit backwardAvailable(enabled: true); |
258 | } |
259 | |
260 | d->setSourceInternal(url, vscroll: nullptr, reload); |
261 | } |
262 | |
263 | void HelpViewer::print(QPagedPaintDevice *printer) |
264 | { |
265 | Q_UNUSED(printer); |
266 | // TODO: implement me |
267 | } |
268 | |
269 | QString HelpViewer::selectedText() const |
270 | { |
271 | return d->m_viewer->selectedText(); |
272 | } |
273 | |
274 | bool HelpViewer::isForwardAvailable() const |
275 | { |
276 | return !d->m_forwardItems.empty(); |
277 | } |
278 | |
279 | bool HelpViewer::isBackwardAvailable() const |
280 | { |
281 | return !d->m_backItems.empty(); |
282 | } |
283 | |
284 | static QTextDocument::FindFlags textDocumentFlagsForFindFlags(HelpViewer::FindFlags flags) |
285 | { |
286 | QTextDocument::FindFlags textDocFlags; |
287 | if (flags & HelpViewer::FindBackward) |
288 | textDocFlags |= QTextDocument::FindBackward; |
289 | if (flags & HelpViewer::FindCaseSensitively) |
290 | textDocFlags |= QTextDocument::FindCaseSensitively; |
291 | return textDocFlags; |
292 | } |
293 | |
294 | bool HelpViewer::findText(const QString &text, FindFlags flags, bool incremental, bool fromSearch) |
295 | { |
296 | Q_UNUSED(fromSearch); |
297 | return d->m_viewer->findText(text, flags: textDocumentFlagsForFindFlags(flags), incremental); |
298 | } |
299 | |
300 | #if QT_CONFIG(clipboard) |
301 | void HelpViewer::copy() |
302 | { |
303 | QGuiApplication::clipboard()->setText(selectedText()); |
304 | } |
305 | #endif |
306 | |
307 | void HelpViewer::home() |
308 | { |
309 | setSource(HelpEngineWrapper::instance().homePage()); |
310 | } |
311 | |
312 | void HelpViewer::forward() |
313 | { |
314 | HelpViewerPrivate::HistoryItem nextItem = d->currentHistoryItem(); |
315 | if (d->m_forwardItems.empty()) |
316 | return; |
317 | d->m_backItems.push_back(x: nextItem); |
318 | nextItem = d->m_forwardItems.front(); |
319 | d->m_forwardItems.erase(position: d->m_forwardItems.begin()); |
320 | |
321 | emit backwardAvailable(enabled: isBackwardAvailable()); |
322 | emit forwardAvailable(enabled: isForwardAvailable()); |
323 | d->setSourceInternal(url: nextItem.url, vscroll: &nextItem.vscroll); |
324 | } |
325 | |
326 | void HelpViewer::backward() |
327 | { |
328 | HelpViewerPrivate::HistoryItem previousItem = d->currentHistoryItem(); |
329 | if (d->m_backItems.empty()) |
330 | return; |
331 | d->m_forwardItems.insert(position: d->m_forwardItems.begin(), x: previousItem); |
332 | previousItem = d->m_backItems.back(); |
333 | d->m_backItems.pop_back(); |
334 | |
335 | emit backwardAvailable(enabled: isBackwardAvailable()); |
336 | emit forwardAvailable(enabled: isForwardAvailable()); |
337 | d->setSourceInternal(url: previousItem.url, vscroll: &previousItem.vscroll); |
338 | } |
339 | |
340 | bool HelpViewer::eventFilter(QObject *src, QEvent *event) |
341 | { |
342 | if (event->type() == QEvent::Wheel) { |
343 | auto we = static_cast<QWheelEvent *>(event); |
344 | if (we->modifiers() == Qt::ControlModifier) { |
345 | we->accept(); |
346 | const int deltaY = we->angleDelta().y(); |
347 | if (deltaY != 0) |
348 | d->incrementZoom(steps: deltaY / 120); |
349 | return true; |
350 | } |
351 | } |
352 | return QWidget::eventFilter(watched: src, event); |
353 | } |
354 | |
355 | bool HelpViewer::isLocalUrl(const QUrl &url) |
356 | { |
357 | TRACE_OBJ |
358 | const QString &scheme = url.scheme(); |
359 | return scheme.isEmpty() |
360 | || scheme == QLatin1String("file" ) |
361 | || scheme == QLatin1String("qrc" ) |
362 | || scheme == QLatin1String("data" ) |
363 | || scheme == QLatin1String("qthelp" ) |
364 | || scheme == QLatin1String("about" ); |
365 | } |
366 | |
367 | bool HelpViewer::canOpenPage(const QString &path) |
368 | { |
369 | TRACE_OBJ |
370 | return !mimeFromUrl(url: QUrl::fromLocalFile(localfile: path)).isEmpty(); |
371 | } |
372 | |
373 | QString HelpViewer::mimeFromUrl(const QUrl &url) |
374 | { |
375 | TRACE_OBJ |
376 | const QString &path = url.path(); |
377 | const int index = path.lastIndexOf(c: QLatin1Char('.')); |
378 | const QByteArray &ext = path.mid(position: index).toUtf8().toLower(); |
379 | |
380 | const ExtensionMap *e = extensionMap; |
381 | while (e->extension) { |
382 | if (ext == e->extension) |
383 | return QLatin1String(e->mimeType); |
384 | ++e; |
385 | } |
386 | return QLatin1String("application/octet-stream" ); |
387 | } |
388 | |
389 | bool HelpViewer::launchWithExternalApp(const QUrl &url) |
390 | { |
391 | TRACE_OBJ |
392 | if (isLocalUrl(url)) { |
393 | const HelpEngineWrapper &helpEngine = HelpEngineWrapper::instance(); |
394 | const QUrl &resolvedUrl = helpEngine.findFile(url); |
395 | if (!resolvedUrl.isValid()) |
396 | return false; |
397 | |
398 | const QString& path = resolvedUrl.toLocalFile(); |
399 | if (!canOpenPage(path)) { |
400 | QTemporaryFile tmpTmpFile; |
401 | if (!tmpTmpFile.open()) |
402 | return false; |
403 | |
404 | const QString &extension = QFileInfo(path).completeSuffix(); |
405 | QFile actualTmpFile(tmpTmpFile.fileName() % QLatin1String("." ) |
406 | % extension); |
407 | if (!actualTmpFile.open(flags: QIODevice::ReadWrite | QIODevice::Truncate)) |
408 | return false; |
409 | |
410 | actualTmpFile.write(data: helpEngine.fileData(url: resolvedUrl)); |
411 | actualTmpFile.close(); |
412 | return QDesktopServices::openUrl(url: QUrl::fromLocalFile(localfile: actualTmpFile.fileName())); |
413 | } |
414 | return false; |
415 | } |
416 | return QDesktopServices::openUrl(url); |
417 | } |
418 | |
419 | QT_END_NAMESPACE |
420 | |