diff --git a/.gitignore b/.gitignore index 836122edbef..d5ccc97537f 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ npm-debug.log # Intermediate directories for webview assets /ios/webview # /android/app/build/... is already covered above +/staging # fastlane # See: https://docs.fastlane.tools/best-practices/source-control/ diff --git a/package.json b/package.json index c81020039f8..ca4c9d0e690 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "blueimp-md5": "^2.10.0", "color": "^3.0.0", "date-fns": "^1.29.0", + "katex": "^0.10.2", "lodash.escape": "^4.0.1", "lodash.isequal": "^4.4.0", "lodash.omit": "^4.5.0", diff --git a/src/webview/css/css.js b/src/webview/css/css.js index 2bed42de1f2..16d1bcc34d1 100644 --- a/src/webview/css/css.js +++ b/src/webview/css/css.js @@ -1,11 +1,76 @@ /* @flow strict-local */ +import { Platform } from 'react-native'; import type { ThemeName } from '../../types'; import cssPygments from './cssPygments'; import cssEmojis from './cssEmojis'; import cssNight from './cssNight'; +/** CSS fragment to support scrolling of KaTeX formulae. */ +/* + By default, KaTeX renders (non-inline) math into a div of fixed precomputed + width. This will be cut off by the edge of the screen when the formula is too + long -- and on a mobile device, that's very nearly always. + + The naïve solution of simply giving some part of the KaTeX fragment itself an + `overflow-x: auto` style breaks terribly: + * Margin collapsing no longer works, causing rendering artifacts. (This is + particularly visible on integral signs, which are truncated into near- + illegibility.) + * `overflow-y: hidden` isn't respected. If KaTeX has used negative-position + struts in its rendering (which it does frequently), there will always be + vertical scrollability. (This may be a Chrome bug.) + + Instead, we modify the provided DOM to wrap each `.katex-display` div with two + additional elements: the outer element is scrollable and `display: block`, + while the inner element is fixed and `display: inline-block`. This suffices to + insulate the KaTeX elements from the deleterious effects of scrollability. + + The inner of these elements also receives a border, to act as a UI hint + indicating that scrolling is necessary: the right border will be cut off when + the rendered element is too large. (The KaTeX itself will also be truncated, + of course, but this may not be apparent if the cutoff falls between two + symbols.) + + We also cut the KaTeX-provided margin somewhat. (Since the KaTeX fragment is + isolated in the new divs, margin-collapsing can no longer occur.) +*/ +const katexScrollStyle = ``; + +/** + * Fix KaTeX frac-line elements disappearing. + * + * This is a hack, but it's probably better than not having fraction lines on + * low-resolution phones. It's only known to be useful under Chrome and Android, + * so we only include it there. + * + * See, among others: + * https://github.com/KaTeX/KaTeX/issues/824 + * https://github.com/KaTeX/KaTeX/issues/916 + * https://github.com/KaTeX/KaTeX/pull/1249 + * https://github.com/KaTeX/KaTeX/issues/1775 + */ +const katexFraclineHackStyle = ``; + export default (theme: ThemeName) => ` + +${katexScrollStyle} +${Platform.OS === 'android' ? katexFraclineHackStyle : ''} `; diff --git a/src/webview/js/fixup-katex.js b/src/webview/js/fixup-katex.js new file mode 100644 index 00000000000..c40567918aa --- /dev/null +++ b/src/webview/js/fixup-katex.js @@ -0,0 +1,36 @@ +/* @flow strict */ + +// Auxiliary function, for brevity. +const makeDiv = (className: string): Element => { + const element = document.createElement('div'); + element.classList.add(className); + return element; +}; + +/** + * Adjust non-inline KaTeX equation element structure. + * + * This surrounds existing `.katex-display` elements (the KaTeX wrapper for + * non-inline equations) with shim `
`s to allow horizontal scrolling. + * + * (The actual functionality of these elements is provided by CSS rules.) + */ +const fixupKatex = (root: Element) => { + Array.from(root.querySelectorAll('.katex-display')).forEach(kd => { + // nodes returned by `querySelectorAll` will always have a valid parent + // node, since the root node itself is excluded + const parent: Node = ((kd.parentNode: ?Node): $FlowFixMe); + + // Nest each `.katex-display` element within two new
elements. + // Notionally: + // s/ (.katex-display) / div.z-k-outer > div.z-k-inner > $1 / + const outer = makeDiv('zulip-katex-outer'); + const inner = makeDiv('zulip-katex-inner'); + + outer.appendChild(inner); + parent.replaceChild(outer, kd); + inner.appendChild(kd); + }); +}; + +export default fixupKatex; diff --git a/src/webview/js/generatedEs3.js b/src/webview/js/generatedEs3.js index 0860c5315a7..74d4f4fed5f 100644 --- a/src/webview/js/generatedEs3.js +++ b/src/webview/js/generatedEs3.js @@ -18,8 +18,7 @@ var compiledWebviewJs = (function (exports) { return new RegExp(r); }); - var rewriteImageUrls = function rewriteImageUrls(auth) { - var element = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : document; + var rewriteImageUrls = function rewriteImageUrls(auth, element) { var realm = new URL(auth.realm); var imageTags = [].concat(element instanceof HTMLImageElement ? [element] : [], Array.from(element.getElementsByTagName('img'))); imageTags.forEach(function (img) { @@ -46,6 +45,23 @@ var compiledWebviewJs = (function (exports) { }); }; + var makeDiv = function makeDiv(className) { + var element = document.createElement('div'); + element.classList.add(className); + return element; + }; + + var fixupKatex = function fixupKatex(root) { + Array.from(root.querySelectorAll('.katex-display')).forEach(function (kd) { + var parent = kd.parentNode; + var outer = makeDiv('zulip-katex-outer'); + var inner = makeDiv('zulip-katex-inner'); + outer.appendChild(inner); + parent.replaceChild(outer, kd); + inner.appendChild(kd); + }); + }; + if (!Array.from) { Array.from = function from(arr) { return Array.prototype.slice.call(arr); @@ -379,7 +395,15 @@ var compiledWebviewJs = (function (exports) { window.scrollBy(0, newBoundRect.top - prevBoundTop); }; + var processIncomingHtml = function processIncomingHtml(auth, root) { + rewriteImageUrls(auth, root); + fixupKatex(root); + }; + var handleUpdateEventContent = function handleUpdateEventContent(uevent) { + var contentNode = document.createElement('div'); + contentNode.innerHTML = uevent.content; + processIncomingHtml(uevent.auth, contentNode); var target; if (uevent.updateStrategy === 'replace') { @@ -399,8 +423,7 @@ var compiledWebviewJs = (function (exports) { target = findPreserveTarget(); } - documentBody.innerHTML = uevent.content; - rewriteImageUrls(uevent.auth); + documentBody.innerHTML = contentNode.innerHTML; if (target.type === 'bottom') { scrollToBottom(); @@ -420,8 +443,8 @@ var compiledWebviewJs = (function (exports) { document.addEventListener('message', handleMessageEvent); } + processIncomingHtml(auth, documentBody); scrollToMessage(scrollMessageId); - rewriteImageUrls(auth); sendScrollMessageIfListShort(); scrollEventsDisabled = false; }; diff --git a/src/webview/js/js.js b/src/webview/js/js.js index f05de58ea59..18844f34a13 100644 --- a/src/webview/js/js.js +++ b/src/webview/js/js.js @@ -12,6 +12,7 @@ import type { import type { MessageListEvent } from '../webViewEventHandlers'; import rewriteImageUrls from './rewriteImageUrls'; +import fixupKatex from './fixup-katex'; /* * Supported platforms: @@ -475,7 +476,22 @@ const scrollToPreserve = (msgId: number, prevBoundTop: number) => { window.scrollBy(0, newBoundRect.top - prevBoundTop); }; +/** + * Fix up supplied HTML as needed for display. + * + * The root itself must not need fixups. + */ +const processIncomingHtml = (auth: Auth, root: Element) => { + rewriteImageUrls(auth, root); + fixupKatex(root); +}; + const handleUpdateEventContent = (uevent: WebViewUpdateEventContent) => { + // Perform preprocessing on the webview content. + const contentNode: HTMLDivElement = document.createElement('div'); + contentNode.innerHTML = uevent.content; + processIncomingHtml(uevent.auth, contentNode); + let target: ScrollTarget; if (uevent.updateStrategy === 'replace') { target = { type: 'none' }; @@ -491,9 +507,11 @@ const handleUpdateEventContent = (uevent: WebViewUpdateEventContent) => { target = findPreserveTarget(); } - documentBody.innerHTML = uevent.content; - - rewriteImageUrls(uevent.auth); + // TODO: eliminate the needless reserialization and deserialization + // step here, via something along the lines of + // documentBody.replaceChild(oldContentNode, contentNode) + // (which currently breaks our touch event handling). + documentBody.innerHTML = contentNode.innerHTML; if (target.type === 'bottom') { scrollToBottom(); @@ -522,8 +540,8 @@ export const handleInitialLoad = ( document.addEventListener('message', handleMessageEvent); } + processIncomingHtml(auth, documentBody); scrollToMessage(scrollMessageId); - rewriteImageUrls(auth); sendScrollMessageIfListShort(); scrollEventsDisabled = false; }; diff --git a/src/webview/js/rewriteImageUrls.js b/src/webview/js/rewriteImageUrls.js index a22bc60b058..4885f69d647 100644 --- a/src/webview/js/rewriteImageUrls.js +++ b/src/webview/js/rewriteImageUrls.js @@ -14,11 +14,8 @@ const inlineApiRoutes: RegExp[] = ['^/user_uploads/', '^/thumbnail$', '^/avatar/ * than the document location. * 2. If the source URL names an endpoint known to require authentication, * inject an API key into its query parameters. - * - * DEPRECATED: If no root element is specified, transform every in the - * entire document. */ -const rewriteImageUrls = (auth: Auth, element: Element | Document = document) => { +const rewriteImageUrls = (auth: Auth, element: Element) => { const realm = new URL(auth.realm); // Find the image elements to act on. diff --git a/tools/build-webview b/tools/build-webview index ad12e794064..9b5633a1f06 100755 --- a/tools/build-webview +++ b/tools/build-webview @@ -31,6 +31,25 @@ err() { exit 1; } +# Common rsync invocation. Exported for use by auxiliary scripts. +# +# Filter rules are expected to be passed in via stdin; this is most easily +# accomplished with a heredoc, or `