Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add LaTeX support (v2) #3744

Closed
wants to merge 10 commits into from
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions src/webview/css/css.js
Original file line number Diff line number Diff line change
@@ -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 = `<style id="katex-mobile-scroll">
.zulip-katex-outer {
display: block;
overflow-x: auto;
text-align: center;
}
.zulip-katex-inner {
display: inline-block;
border: 1px solid hsla(187, 35%, 51%, .5);
border-radius: 3px;
}
/* adjust/override KaTeX-provided CSS */
.zulip-katex-inner .katex-display {
margin: 0.5em 0.25em;
}
</style>`;

/**
* 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 = `<style id="katex-frac-line-hack">
.katex .mfrac .frac-line { border-bottom-width: 1px !important; }
</style>`;

export default (theme: ThemeName) => `
<link rel='stylesheet' type='text/css' href='./base.css'>
<link rel='stylesheet' type='text/css' href='./katex/katex.min.css'>
<style>
${theme === 'night' ? cssNight : ''}
${cssPygments(theme === 'night')}
Expand All @@ -16,5 +81,7 @@ ${cssEmojis}
display: none;
}
</style>
${katexScrollStyle}
${Platform.OS === 'android' ? katexFraclineHackStyle : '<!-- Safari -->'}
<style id="generated-styles"></style>
`;
36 changes: 36 additions & 0 deletions src/webview/js/fixup-katex.js
Original file line number Diff line number Diff line change
@@ -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 `<div>`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 <div> 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;
33 changes: 28 additions & 5 deletions src/webview/js/generatedEs3.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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') {
Expand All @@ -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();
Expand All @@ -420,8 +443,8 @@ var compiledWebviewJs = (function (exports) {
document.addEventListener('message', handleMessageEvent);
}

processIncomingHtml(auth, documentBody);
scrollToMessage(scrollMessageId);
rewriteImageUrls(auth);
sendScrollMessageIfListShort();
scrollEventsDisabled = false;
};
Expand Down
26 changes: 22 additions & 4 deletions src/webview/js/js.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import type { MessageListEvent } from '../webViewEventHandlers';

import rewriteImageUrls from './rewriteImageUrls';
import fixupKatex from './fixup-katex';

/*
* Supported platforms:
Expand Down Expand Up @@ -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' };
Expand All @@ -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();
Expand Down Expand Up @@ -522,8 +540,8 @@ export const handleInitialLoad = (
document.addEventListener('message', handleMessageEvent);
}

processIncomingHtml(auth, documentBody);
scrollToMessage(scrollMessageId);
rewriteImageUrls(auth);
sendScrollMessageIfListShort();
scrollEventsDisabled = false;
};
Expand Down
5 changes: 1 addition & 4 deletions src/webview/js/rewriteImageUrls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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.
Expand Down
Loading