From f14d3b5c83665ac4176262a72f270885d81b314a Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Thu, 23 Jun 2022 19:03:06 -0700 Subject: [PATCH 01/86] Mobile: Syntax highlighting for TeX, code blocks --- .../components/NoteEditor/CodeMirror.ts | 140 ++-------- .../NoteEditor/CodeMirrorDecorator.ts | 125 +++++++++ .../NoteEditor/CodeMirrorLanguages.ts | 257 ++++++++++++++++++ .../components/NoteEditor/CodeMirrorTheme.ts | 196 +++++++++++++ .../NoteEditor/MarkdownTeXParser.ts | 132 +++++++++ .../components/NoteEditor/NoteEditor.tsx | 56 ++-- packages/app-mobile/injectedJS.config.js | 25 ++ packages/app-mobile/package.json | 7 + packages/app-mobile/tools/buildInjectedJs.js | 3 +- yarn.lock | 99 +++++++ 10 files changed, 897 insertions(+), 143 deletions(-) create mode 100644 packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts create mode 100644 packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.ts create mode 100644 packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts create mode 100644 packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts create mode 100644 packages/app-mobile/injectedJS.config.js diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror.ts b/packages/app-mobile/components/NoteEditor/CodeMirror.ts index 60bfedd5cd8..7027781a252 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirror.ts @@ -9,15 +9,19 @@ // wrapper to access CodeMirror functionalities. Anything else should be done // from NoteEditor.tsx. -import { EditorState, Extension } from '@codemirror/state'; +import MarkdownTeXParser from './MarkdownTeXParser'; +import codeMirrorDecorator from './CodeMirrorDecorator'; +import createTheme from './CodeMirrorTheme'; +import syntaxHighlightingLanguages from './CodeMirrorLanguages'; + +import { EditorState } from '@codemirror/state'; import { markdown } from '@codemirror/lang-markdown'; -import { defaultHighlightStyle, syntaxHighlighting, HighlightStyle } from '@codemirror/language'; -import { tags } from '@lezer/highlight'; +import { GFM } from '@lezer/markdown'; +import { indentOnInput, indentUnit } from '@codemirror/language'; import { EditorView, drawSelection, highlightSpecialChars, ViewUpdate } from '@codemirror/view'; import { undo, redo, history, undoDepth, redoDepth } from '@codemirror/commands'; import { keymap } from '@codemirror/view'; -import { indentOnInput } from '@codemirror/language'; import { historyKeymap, defaultKeymap } from '@codemirror/commands'; interface CodeMirrorResult { @@ -39,120 +43,6 @@ function logMessage(...msg: any[]) { postMessage('onLog', { value: msg }); } -// For an example on how to customize the theme, see: -// -// https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts -// -// For a tutorial, see: -// -// https://codemirror.net/6/examples/styling/#themes -// -// Use Safari developer tools to view the content of the CodeMirror iframe while -// the app is running. It seems that what appears as ".ͼ1" in the CSS is the -// equivalent of "&" in the theme object. So to target ".ͼ1.cm-focused", you'd -// use '&.cm-focused' in the theme. -const createTheme = (theme: any): Extension[] => { - const isDarkTheme = theme.appearance === 'dark'; - - const baseGlobalStyle: Record = { - color: theme.color, - backgroundColor: theme.backgroundColor, - fontFamily: theme.fontFamily, - fontSize: `${theme.fontSize}px`, - }; - const baseCursorStyle: Record = { }; - const baseContentStyle: Record = { }; - const baseSelectionStyle: Record = { }; - - // If we're in dark mode, the caret and selection are difficult to see. - // Adjust them appropriately - if (isDarkTheme) { - // Styling the caret requires styling both the caret itself - // and the CodeMirror caret. - // See https://codemirror.net/6/examples/styling/#themes - baseContentStyle.caretColor = 'white'; - baseCursorStyle.borderLeftColor = 'white'; - - baseSelectionStyle.backgroundColor = '#6b6b6b'; - } - - const baseTheme = EditorView.baseTheme({ - '&': baseGlobalStyle, - - // These must be !important or more specific than CodeMirror's built-ins - '.cm-content': baseContentStyle, - '&.cm-focused .cm-cursor': baseCursorStyle, - '&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle, - - '&.cm-focused': { - outline: 'none', - }, - }); - - const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme }); - - const baseHeadingStyle = { - fontWeight: 'bold', - fontFamily: theme.fontFamily, - }; - - const highlightingStyle = HighlightStyle.define([ - { - tag: tags.strong, - fontWeight: 'bold', - }, - { - tag: tags.emphasis, - fontStyle: 'italic', - }, - { - ...baseHeadingStyle, - tag: tags.heading1, - fontSize: '1.6em', - borderBottom: `1px solid ${theme.dividerColor}`, - }, - { - ...baseHeadingStyle, - tag: tags.heading2, - fontSize: '1.4em', - }, - { - ...baseHeadingStyle, - tag: tags.heading3, - fontSize: '1.3em', - }, - { - ...baseHeadingStyle, - tag: tags.heading4, - fontSize: '1.2em', - }, - { - ...baseHeadingStyle, - tag: tags.heading5, - fontSize: '1.1em', - }, - { - ...baseHeadingStyle, - tag: tags.heading6, - fontSize: '1.0em', - }, - { - tag: tags.list, - fontFamily: theme.fontFamily, - }, - ]); - - return [ - baseTheme, - appearanceTheme, - syntaxHighlighting(highlightingStyle), - - // If we haven't defined highlighting for tags, fall back - // to the default. - syntaxHighlighting(defaultHighlightStyle, { fallback: true }), - ]; -}; - export function initCodeMirror(parentElement: any, initialText: string, theme: any): CodeMirrorResult { logMessage('Initializing CodeMirror...'); @@ -180,13 +70,25 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a // See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts // for a sample configuration. extensions: [ - markdown(), + markdown({ + extensions: [ + GFM, + MarkdownTeXParser, + ], + codeLanguages: syntaxHighlightingLanguages, + }), ...createTheme(theme), history(), drawSelection(), highlightSpecialChars(), indentOnInput(), + // By default, indent with a tab + indentUnit.of('\t'), + + // Full-line styling + codeMirrorDecorator, + EditorView.lineWrapping, EditorView.contentAttributes.of({ autocapitalize: 'sentence' }), EditorView.updateListener.of((viewUpdate: ViewUpdate) => { diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts new file mode 100644 index 00000000000..9106f6bf462 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts @@ -0,0 +1,125 @@ +// Handles multi-line decorations. +// Exports a CodeMirror6 plugin. +// +// Ref: +// • https://codemirror.net/examples/zebra/ + +import { Decoration, EditorView } from '@codemirror/view'; +import { ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view'; +import { ensureSyntaxTree } from '@codemirror/language'; +import { RangeSetBuilder } from '@codemirror/state'; + +const regionStartDecoration = Decoration.line({ + attributes: { class: 'cm-regionFirstLine' }, +}); + +const regionStopDecoration = Decoration.line({ + attributes: { class: 'cm-regionLastLine' }, +}); + +const codeBlockDecoration = Decoration.line({ + attributes: { class: 'cm-codeBlock' }, +}); + +const mathBlockDecoration = Decoration.line({ + attributes: { class: 'cm-mathBlock' }, +}); + +const blockQuoteDecoration = Decoration.line({ + attributes: { class: 'cm-blockQuote' }, +}); + +// Returns a set of [Decoration]s, associated with block syntax groups that require +// full-line styling. +function lineDecoration(view: EditorView) { + const decorations: { pos: number; decoration: Decoration }[] = []; + + // Add a decoration to all lines between the document position [from] up to + // and includeing the position [to]. + const addDecorationToLines = (from: number, to: number, decoration: Decoration) => { + let pos = from; + while (pos <= to) { + const line = view.state.doc.lineAt(pos); + decorations.push({ + pos: line.from, + decoration, + }); + + // Move to the next line + pos = line.to + 1; + } + }; + + for (const { from, to } of view.visibleRanges) { + ensureSyntaxTree( + view.state, + to + )?.iterate({ + from, to, + enter: node => { + let decorated = false; + + // Compute the visible region of the node. + const viewFrom = Math.max(from, node.from); + const viewTo = Math.min(to, node.to); + + switch (node.name) { + case 'FencedCode': + case 'CodeBlock': + addDecorationToLines(viewFrom, viewTo, codeBlockDecoration); + decorated = true; + break; + case 'BlockMath': + addDecorationToLines(viewFrom, viewTo, mathBlockDecoration); + decorated = true; + break; + case 'Blockquote': + addDecorationToLines(viewFrom, viewTo, blockQuoteDecoration); + decorated = true; + break; + } + + if (decorated) { + // Allow different styles for the first, last lines in a block. + if (viewFrom == node.from) { + addDecorationToLines(viewFrom, viewFrom, regionStartDecoration); + } + + if (viewTo == node.to) { + addDecorationToLines(viewTo, viewTo, regionStopDecoration); + } + } + }, + }); + } + + decorations.sort((a, b) => a.pos - b.pos); + + // Items need to be added to a RangeSetBuilder in ascending order + const decorationBuilder = new RangeSetBuilder(); + for (const { pos, decoration } of decorations) { + decorationBuilder.add(pos, pos, decoration); + } + return decorationBuilder.finish(); +} + +const decoratorPlugin = ViewPlugin.fromClass(class { + public decorations: DecorationSet; + + public constructor(view: EditorView) { + this.decorations = lineDecoration(view); + + } + + public update(viewUpdate: ViewUpdate) { + // TODO: If decorations that are invisible when the focus is near, this + // may need to be updated more often: + if (viewUpdate.docChanged || viewUpdate.viewportChanged) { + this.decorations = lineDecoration(viewUpdate.view); + } + } +}, { + decorations: pluginVal => pluginVal.decorations, +}); + +export default decoratorPlugin; diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.ts new file mode 100644 index 00000000000..b2505d5e068 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.ts @@ -0,0 +1,257 @@ +// Languages supported by code regions + + +import { LanguageDescription, LanguageSupport, StreamParser } from '@codemirror/language'; +import { StreamLanguage } from '@codemirror/language'; + +import { python } from '@codemirror/legacy-modes/mode/python'; +import { c, dart } from '@codemirror/legacy-modes/mode/clike'; +import { lua } from '@codemirror/legacy-modes/mode/lua'; +import { r } from '@codemirror/legacy-modes/mode/r'; +import { ruby } from '@codemirror/legacy-modes/mode/ruby'; +import { swift } from '@codemirror/legacy-modes/mode/swift'; +import { go } from '@codemirror/legacy-modes/mode/go'; +import { vb } from '@codemirror/legacy-modes/mode/vb'; +import { vbScript } from '@codemirror/legacy-modes/mode/vbscript'; +import { css } from '@codemirror/legacy-modes/mode/css'; +import { stex } from '@codemirror/legacy-modes/mode/stex'; +import { groovy } from '@codemirror/legacy-modes/mode/groovy'; +import { perl } from '@codemirror/legacy-modes/mode/perl'; +import { cobol } from '@codemirror/legacy-modes/mode/cobol'; +import { julia } from '@codemirror/legacy-modes/mode/julia'; +import { haskell } from '@codemirror/legacy-modes/mode/haskell'; +import { pascal } from '@codemirror/legacy-modes/mode/pascal'; +import { yaml } from '@codemirror/legacy-modes/mode/yaml'; +import { shell } from '@codemirror/legacy-modes/mode/shell'; +import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'; +import { diff } from '@codemirror/legacy-modes/mode/diff'; +import { erlang } from '@codemirror/legacy-modes/mode/erlang'; +import { sqlite, standardSQL, mySQL } from '@codemirror/legacy-modes/mode/sql'; + +import { javascript } from '@codemirror/lang-javascript'; +import { markdown } from '@codemirror/lang-markdown'; +import { html } from '@codemirror/lang-html'; +import { cpp } from '@codemirror/lang-cpp'; +import { php } from '@codemirror/lang-php'; +import { java } from '@codemirror/lang-java'; +import { rust } from '@codemirror/lang-rust'; + +const supportedLanguages: { + name: string; + aliases?: string[]; + + // Either support or parser must be given + parser?: StreamParser; + support?: LanguageSupport; +}[] = [ + // Based on @joplin/desktop/CodeMirror/Editor.tsx + { + name: 'LaTeX', + aliases: ['tex', 'latex', 'luatex'], + parser: stex, + }, + // 'python': [ 'py', ], + { + name: 'python', + aliases: ['py'], + parser: python, + }, + // 'clike': [ 'c', 'h', ], + { + name: 'clike', + aliases: ['c', 'h'], + parser: c, + }, + { + name: 'C++', + aliases: ['cpp', 'hpp', 'cxx', 'hxx', 'c++'], + support: cpp(), + }, + { + name: 'java', + support: java(), + }, + // 'javascript': [ 'js', ], + { + name: 'javascript', + aliases: ['js', 'mjs'], + support: javascript(), + }, + { + name: 'typescript', + aliases: ['ts'], + support: javascript({ jsx: false, typescript: true }), + }, + // 'jsx': [], + { + name: 'react javascript', + aliases: ['jsx'], + support: javascript({ jsx: true, typescript: false }), + }, + { + name: 'react typescript', + aliases: ['tsx'], + support: javascript({ jsx: true, typescript: true }), + }, + // 'lua': [], + { + name: 'lua', + parser: lua, + }, + // 'php': [], + { + name: 'php', + support: php(), + }, + // 'r': [], + { + name: 'r', + parser: r, + }, + // 'swift': [], + { + name: 'swift', + parser: swift, + }, + // 'go': [], + { + name: 'go', + parser: go, + }, + // 'vb': [ 'visualbasic' ], + { + name: 'visualbasic', + aliases: ['vb'], + parser: vb, + }, + // 'vbscript': [ 'vbs' ], + { + name: 'visualbasicscript', + aliases: ['vbscript', 'vbs'], + parser: vbScript, + }, + // 'ruby': [], + { + name: 'ruby', + aliases: ['rb'], + parser: ruby, + }, + // 'rust': [], + { + name: 'rust', + aliases: ['rs'], + support: rust(), + }, + // 'dart': [], + { + name: 'dart', + parser: dart, + }, + // 'groovy': [], + { + name: 'groovy', + parser: groovy, + }, + // 'perl': [], + { + name: 'perl', + aliases: ['pl'], + parser: perl, + }, + // 'cobol': [], + { + name: 'cobol', + aliases: ['cbl', 'cob'], + parser: cobol, + }, + // 'julia': [], + { + name: 'julia', + aliases: ['jl'], + parser: julia, + }, + // 'haskell': [], + { + name: 'haskell', + aliases: ['hs'], + parser: haskell, + }, + // 'pascal': [], + { + name: 'pascal', + parser: pascal, + }, + // 'css': [], + { + name: 'css', + parser: css, + }, + // 'xml': [ 'html', 'xhtml' ], + { + name: 'html', + aliases: ['html', 'htm'], + support: html(), + }, + // 'markdown': [ 'md' ], + { + name: 'markdown', + aliases: ['md'], + support: markdown(), + }, + // 'yaml': [], + { + name: 'yaml', + parser: yaml, + }, + // 'shell': [ 'bash', 'sh', 'zsh', ], + { + name: 'shell', + aliases: ['bash', 'sh', 'zsh', 'dash'], + parser: shell, + }, + // 'dockerfile': [], + { + name: 'dockerfile', + parser: dockerFile, + }, + // 'diff': [], + { + name: 'diff', + parser: diff, + }, + // 'erlang': [], + { + name: 'erlang', + parser: erlang, + }, + // 'sql': [], + { + name: 'sql', + parser: standardSQL, + }, + { + name: 'sqlite', + parser: sqlite, + }, + { + name: 'mysql', + parser: mySQL, + }, +]; + + +const syntaxHighlightingLanguages: LanguageDescription[] = []; +for (const language of supportedLanguages) { + // Convert from parsers to LanguageSupport objects as necessary + const support = language.support ?? new LanguageSupport(StreamLanguage.define(language.parser)); + + syntaxHighlightingLanguages.push( + LanguageDescription.of({ + name: language.name, + alias: language.aliases, + support, + }) + ); +} + +export default syntaxHighlightingLanguages; diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts new file mode 100644 index 00000000000..ddfe5209f44 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts @@ -0,0 +1,196 @@ +/** + * Create a set of Extensions that provide syntax highlighting. + */ + + +import { defaultHighlightStyle, syntaxHighlighting, HighlightStyle } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; +import { EditorView } from '@codemirror/view'; +import { Extension } from '@codemirror/state'; + +// For an example on how to customize the theme, see: +// +// https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts +// +// For a tutorial, see: +// +// https://codemirror.net/6/examples/styling/#themes +// +// Use Safari developer tools to view the content of the CodeMirror iframe while +// the app is running. It seems that what appears as ".ͼ1" in the CSS is the +// equivalent of "&" in the theme object. So to target ".ͼ1.cm-focused", you'd +// use '&.cm-focused' in the theme. +// +// [theme] should be a joplin theme (see @joplin/lib/theme) +const createTheme = (theme: any): Extension[] => { + const isDarkTheme = theme.appearance === 'dark'; + + const baseGlobalStyle: Record = { + color: theme.color, + backgroundColor: theme.backgroundColor, + fontFamily: theme.fontFamily, + fontSize: `${theme.fontSize}px`, + }; + const baseCursorStyle: Record = { }; + const baseContentStyle: Record = { }; + const baseSelectionStyle: Record = { }; + + // If we're in dark mode, the caret and selection are difficult to see. + // Adjust them appropriately + if (isDarkTheme) { + // Styling the caret requires styling both the caret itself + // and the CodeMirror caret. + // See https://codemirror.net/6/examples/styling/#themes + baseContentStyle.caretColor = 'white'; + baseCursorStyle.borderLeftColor = 'white'; + + baseSelectionStyle.backgroundColor = '#6b6b6b'; + } + + const baseTheme = EditorView.baseTheme({ + '&': baseGlobalStyle, + + // These must be !important or more specific than CodeMirror's built-ins + '.cm-content': baseContentStyle, + '&.cm-focused .cm-cursor': baseCursorStyle, + '&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle, + + '&.cm-focused': { + outline: 'none', + }, + + '& .cm-blockQuote': { + borderLeft: `4px solid ${theme.colorFaded}`, + opacity: theme.blockQuoteOpacity, + paddingLeft: '4px', + }, + + '& .cm-codeBlock': { + '&.cm-regionFirstLine, &.cm-regionLastLine': { + borderRadius: '3px', + }, + '&:not(.cm-regionFirstLine)': { + borderTop: 'none', + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, + '&:not(.cm-regionLastLine)': { + borderBottom: 'none', + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + + borderWidth: '1px', + borderStyle: 'solid', + borderColor: theme.colorFaded, + backgroundColor: 'rgba(155, 155, 155, 0.1)', + }, + }); + + const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme }); + + const baseHeadingStyle = { + fontWeight: 'bold', + fontFamily: theme.fontFamily, + }; + + const highlightingStyle = HighlightStyle.define([ + { + tag: tags.strong, + fontWeight: 'bold', + }, + { + tag: tags.emphasis, + fontStyle: 'italic', + }, + { + ...baseHeadingStyle, + tag: tags.heading1, + fontSize: '1.6em', + borderBottom: `1px solid ${theme.dividerColor}`, + }, + { + ...baseHeadingStyle, + tag: tags.heading2, + fontSize: '1.4em', + }, + { + ...baseHeadingStyle, + tag: tags.heading3, + fontSize: '1.3em', + }, + { + ...baseHeadingStyle, + tag: tags.heading4, + fontSize: '1.2em', + }, + { + ...baseHeadingStyle, + tag: tags.heading5, + fontSize: '1.1em', + }, + { + ...baseHeadingStyle, + tag: tags.heading6, + fontSize: '1.0em', + }, + { + tag: tags.list, + fontFamily: theme.fontFamily, + }, + { + tag: tags.comment, + opacity: 0.9, + fontStyle: 'italic', + }, + { + tag: tags.link, + color: theme.urlColor, + textDecoration: 'underline', + }, + + // Code blocks + { + tag: tags.monospace, + border: '1px solid rgba(100, 100, 100, 0.2)', + borderRadius: '4px', + }, + + // Content of code blocks + { + tag: tags.keyword, + color: isDarkTheme ? '#ff7' : '#740', + }, + { + tag: tags.operator, + color: isDarkTheme ? '#f7f' : '#805', + }, + { + tag: tags.literal, + color: isDarkTheme ? '#aaf' : '#037', + }, + { + tag: tags.operator, + color: isDarkTheme ? '#fa9' : '#490', + }, + { + tag: tags.typeName, + color: isDarkTheme ? '#7ff' : '#a00', + }, + ]); + + return [ + baseTheme, + appearanceTheme, + syntaxHighlighting(highlightingStyle), + + // If we haven't defined highlighting for tags, fall back + // to the default. + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + ]; +}; + + + + +export default createTheme; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts b/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts new file mode 100644 index 00000000000..fff508b025d --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts @@ -0,0 +1,132 @@ +// Extends the lezer markdown parser to identify math regions (display and inline) +// See also https://github.com/lezer-parser/markdown/blob/main/src/extension.ts +// for the built-in extensions. +import { tags } from '@lezer/highlight'; +import { parseMixed, SyntaxNodeRef, Input, NestedParse } from '@lezer/common'; +import { + MarkdownConfig, InlineContext, + BlockContext, Line, LeafBlock, +} from '@lezer/markdown'; +import { stexMath } from '@codemirror/legacy-modes/mode/stex'; +import { StreamLanguage } from '@codemirror/language'; + +const DOLLAR_SIGN_CHAR_CODE = 36; +const MATH_BLOCK_START_REGEX = /^\$\$/; +const MATH_BLOCK_STOP_REGEX = /^.*\$\$\s*$/; + +const TEX_LANGUAGE = StreamLanguage.define(stexMath); +const BLOCK_MATH_TAG = 'BlockMath'; +const INLINE_MATH_TAG = 'InlineMath'; + +const InlineMathDelim = { resolve: INLINE_MATH_TAG, mark: 'InlineMathDelim' }; + +// Wraps a TeX math-mode parser +const wrappedTeXParser = (nodeTag: string) => parseMixed( + (node: SyntaxNodeRef, _input: Input): NestedParse => { + if (node.name != nodeTag) { + return null; + } + + return { + parser: TEX_LANGUAGE.parser, + }; + }); + +const InlineMathConfig: MarkdownConfig = { + defineNodes: [ + { + name: INLINE_MATH_TAG, + style: tags.comment, + }, + { + name: 'InlineMathDelim', + style: tags.processingInstruction, + }, + ], + parseInline: [{ + name: INLINE_MATH_TAG, + after: 'InlineCode', + + parse(cx: InlineContext, next: number, pos: number): number { + const prevCharCode = pos - 1 >= 0 ? cx.char(pos - 1) : -1; + const nextCharCode = cx.char(pos + 1); + if (next != DOLLAR_SIGN_CHAR_CODE + || prevCharCode == DOLLAR_SIGN_CHAR_CODE + || nextCharCode == DOLLAR_SIGN_CHAR_CODE) { + return -1; + } + + // $ delimiters are both opening and closing delimiters + const isOpen = true; + const isClose = true; + cx.addDelimiter(InlineMathDelim, pos, pos + 1, isOpen, isClose); + return pos + 1; + }, + }], + wrap: wrappedTeXParser(INLINE_MATH_TAG), +}; + +const BlockMathConfig: MarkdownConfig = { + defineNodes: [ + { + name: BLOCK_MATH_TAG, + style: tags.comment, + }, + ], + parseBlock: [{ + name: BLOCK_MATH_TAG, + before: 'FencedCode', + parse(cx: BlockContext, line: Line): boolean { + const delimLength = 2; + const start = cx.lineStart; + + // $$ delimiter? Start math! + if (MATH_BLOCK_START_REGEX.exec(line.text)) { + // If the math region ends immediately (on the same line), + if (MATH_BLOCK_STOP_REGEX.exec(line.text.substring(delimLength))) { + const elem = cx.elt( + BLOCK_MATH_TAG, cx.lineStart, cx.lineStart + line.text.length); + cx.addElement(elem); + } else { + let hadNextLine = false; + // Otherwise, it's a multi-line block display. + // Consume lines until we reach the end. + do { + hadNextLine = cx.nextLine(); + } + while (hadNextLine && !MATH_BLOCK_STOP_REGEX.exec(line.text)); + + let stop; + + // Only include the ending delimiter if it exists + if (hadNextLine) { + stop = cx.lineStart + delimLength; + } else { + stop = cx.lineStart; + } + + // Mark all lines in the block as math. + const elem = cx.elt(BLOCK_MATH_TAG, start, stop); + cx.addElement(elem); + } + + // Don't re-process the ending delimiter (it may look the same + // as the starting delimiter). + cx.nextLine(); + + return true; + } + + return false; + }, + // End paragraph-like blocks + endLeaf(_cx: BlockContext, line: Line, _leaf: LeafBlock): boolean { + // Leaf blocks (e.g. block quotes) end early if math starts. + return MATH_BLOCK_START_REGEX.exec(line.text) != null; + }, + }], + wrap: wrappedTeXParser(BLOCK_MATH_TAG), +}; + + +export default [InlineMathConfig, BlockMathConfig]; diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index d12f8d1028a..823e044ca3a 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -190,6 +190,20 @@ function useCss(themeId: number): string { :root { background-color: ${theme.backgroundColor}; } + + body { + margin: 0; + height: 100vh; + width: 100vh; + width: 100vw; + min-width: 100vw; + box-sizing: border-box; + + padding-left: 1px; + padding-right: 1px; + padding-bottom: 1px; + padding-top: 10px; + } `; }, [themeId]); } @@ -197,28 +211,26 @@ function useCss(themeId: number): string { function useHtml(css: string): string { const [html, setHtml] = useState(''); - useEffect(() => { - setHtml( - ` - - - - - - - - -
- - - ` - ); + useMemo(() => { + setHtml(` + + + + + + + + +
+ + + `); }, [css]); return html; diff --git a/packages/app-mobile/injectedJS.config.js b/packages/app-mobile/injectedJS.config.js new file mode 100644 index 00000000000..d5b1232c350 --- /dev/null +++ b/packages/app-mobile/injectedJS.config.js @@ -0,0 +1,25 @@ +// Configuration file for rollup + +const path = require('path'); +import typescript from '@rollup/plugin-typescript'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; + + +const rootDir = path.dirname(path.dirname(path.dirname(__dirname))); +const mobileDir = `${rootDir}/packages/app-mobile`; +const codeMirrorDir = `${mobileDir}/components/NoteEditor`; +const outputFile = `${codeMirrorDir}/CodeMirror.bundle.js`; + +export default { + output: outputFile, + plugins: [ + typescript({ + // Exclude all .js files. Rollup will attempt to import a .js + // file if both a .ts and .js file are present, conflicting + // with our build setup. See + // https://discourse.joplinapp.org/t/importing-a-ts-file-from-a-rollup-bundled-ts-file/ + exclude: `${codeMirrorDir}/*.js`, + }), + nodeResolve(), + ], +}; diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json index 63937e7a1e4..91517c0c018 100644 --- a/packages/app-mobile/package.json +++ b/packages/app-mobile/package.json @@ -71,8 +71,15 @@ "@babel/core": "^7.12.9", "@babel/runtime": "^7.12.5", "@codemirror/commands": "^6.0.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-php": "^6.0.0", + "@codemirror/lang-rust": "^6.0.0", "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.1.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@joplin/tools": "~2.8", diff --git a/packages/app-mobile/tools/buildInjectedJs.js b/packages/app-mobile/tools/buildInjectedJs.js index cfe050d2852..0cb22cba592 100644 --- a/packages/app-mobile/tools/buildInjectedJs.js +++ b/packages/app-mobile/tools/buildInjectedJs.js @@ -30,10 +30,9 @@ async function buildCodeMirrorBundle() { 'run', 'rollup', sourceFile, '--name', 'codeMirrorBundle', + '--config', `${mobileDir}/injectedJS.config.js`, '-f', 'iife', '-o', fullBundleFile, - '-p', '@rollup/plugin-node-resolve', - '-p', '@rollup/plugin-typescript', ]); // await execa('./node_modules/uglify-js/bin/uglifyjs', [ diff --git a/yarn.lock b/yarn.lock index 5b3b26e34dc..abd8a48671a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2108,6 +2108,16 @@ __metadata: languageName: node linkType: hard +"@codemirror/lang-cpp@npm:^6.0.0": + version: 6.0.1 + resolution: "@codemirror/lang-cpp@npm:6.0.1" + dependencies: + "@codemirror/language": ^6.0.0 + "@lezer/cpp": ^1.0.0 + checksum: 082e8570db58cf2ffa1f509c72f0df72a9fea7174e46a858ec8b11e7a01ac6ecb5479f18140f2e4131abce51aeae5cacbc79dc8e71840ffdd404ede67e61cb4c + languageName: node + linkType: hard + "@codemirror/lang-css@npm:^6.0.0": version: 6.0.0 resolution: "@codemirror/lang-css@npm:6.0.0" @@ -2135,6 +2145,16 @@ __metadata: languageName: node linkType: hard +"@codemirror/lang-java@npm:^6.0.0": + version: 6.0.0 + resolution: "@codemirror/lang-java@npm:6.0.0" + dependencies: + "@codemirror/language": ^6.0.0 + "@lezer/java": ^1.0.0 + checksum: 27750c463490c71fd0781d9c729621edda2e73d1be316d49146b5876121557dabe58e069c8dfb6ee6fc902ab82ae46647f798a10f66cd4992a312e0b3b5775f2 + languageName: node + linkType: hard + "@codemirror/lang-javascript@npm:^6.0.0": version: 6.0.0 resolution: "@codemirror/lang-javascript@npm:6.0.0" @@ -2164,6 +2184,29 @@ __metadata: languageName: node linkType: hard +"@codemirror/lang-php@npm:^6.0.0": + version: 6.0.0 + resolution: "@codemirror/lang-php@npm:6.0.0" + dependencies: + "@codemirror/lang-html": ^6.0.0 + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@lezer/common": ^1.0.0 + "@lezer/php": ^1.0.0 + checksum: c602530ef766e94b2824791348702dc67d08ca35b1366f5e5f5e2a6c015a040a12508d39e9a907aab9f915caba06b087fd26ec2a614b5acc92b7661583490334 + languageName: node + linkType: hard + +"@codemirror/lang-rust@npm:^6.0.0": + version: 6.0.0 + resolution: "@codemirror/lang-rust@npm:6.0.0" + dependencies: + "@codemirror/language": ^6.0.0 + "@lezer/rust": ^1.0.0 + checksum: 77f19ea1d9d956a54061864b26ce251f344cf38b822fa455759262f861b1d29fce9aa7dd9c5ff4399532d144e15eb53e9a5d947e3172ccf27e7a5c84c578c58e + languageName: node + linkType: hard + "@codemirror/language@npm:^6.0.0": version: 6.0.0 resolution: "@codemirror/language@npm:6.0.0" @@ -2178,6 +2221,15 @@ __metadata: languageName: node linkType: hard +"@codemirror/legacy-modes@npm:^6.1.0": + version: 6.1.0 + resolution: "@codemirror/legacy-modes@npm:6.1.0" + dependencies: + "@codemirror/language": ^6.0.0 + checksum: bc0e3b771360de435735e0203c47feef8336749738da364c744da50e838d1ad5b36402da00131323238b579f657868210659029d44a1935baf3de8cb964903a6 + languageName: node + linkType: hard + "@codemirror/lint@npm:^6.0.0": version: 6.0.0 resolution: "@codemirror/lint@npm:6.0.0" @@ -3259,8 +3311,15 @@ __metadata: "@babel/core": ^7.12.9 "@babel/runtime": ^7.12.5 "@codemirror/commands": ^6.0.0 + "@codemirror/lang-cpp": ^6.0.0 + "@codemirror/lang-html": ^6.0.0 + "@codemirror/lang-java": ^6.0.0 + "@codemirror/lang-javascript": ^6.0.0 "@codemirror/lang-markdown": ^6.0.0 + "@codemirror/lang-php": ^6.0.0 + "@codemirror/lang-rust": ^6.0.0 "@codemirror/language": ^6.0.0 + "@codemirror/legacy-modes": ^6.1.0 "@codemirror/state": ^6.0.0 "@codemirror/view": ^6.0.0 "@joplin/lib": ~2.8 @@ -4480,6 +4539,16 @@ __metadata: languageName: node linkType: hard +"@lezer/cpp@npm:^1.0.0": + version: 1.0.0 + resolution: "@lezer/cpp@npm:1.0.0" + dependencies: + "@lezer/highlight": ^1.0.0 + "@lezer/lr": ^1.0.0 + checksum: 6829550db06ea9ce149fbcd50db3c8988e69bb8e2ce557ecde8f711222b902c5c64453fa77c502c6ab13381d041f5f8e8d3cb80049b2e9e963d76294533e5e83 + languageName: node + linkType: hard + "@lezer/css@npm:^1.0.0": version: 1.0.0 resolution: "@lezer/css@npm:1.0.0" @@ -4509,6 +4578,16 @@ __metadata: languageName: node linkType: hard +"@lezer/java@npm:^1.0.0": + version: 1.0.0 + resolution: "@lezer/java@npm:1.0.0" + dependencies: + "@lezer/highlight": ^1.0.0 + "@lezer/lr": ^1.0.0 + checksum: 0dcd3ea2aa431bc352ed1ca1e92c61a1d60e10d1c0e730200ef1f1dda4b42421e67d56e7808e2102f16b6ffd534f246b82249922998663e9099bd52c141ef1d9 + languageName: node + linkType: hard + "@lezer/javascript@npm:^1.0.0": version: 1.0.0 resolution: "@lezer/javascript@npm:1.0.0" @@ -4538,6 +4617,26 @@ __metadata: languageName: node linkType: hard +"@lezer/php@npm:^1.0.0": + version: 1.0.0 + resolution: "@lezer/php@npm:1.0.0" + dependencies: + "@lezer/highlight": ^1.0.0 + "@lezer/lr": ^1.0.0 + checksum: 06d20c423011119363ccd4dd30d0bdec56ddbdddda05888a6b5890fc090a6338740a310a77d367d3d69a958925fad73e0c7f9b62953eb3f189ec513bd71d9f59 + languageName: node + linkType: hard + +"@lezer/rust@npm:^1.0.0": + version: 1.0.0 + resolution: "@lezer/rust@npm:1.0.0" + dependencies: + "@lezer/highlight": ^1.0.0 + "@lezer/lr": ^1.0.0 + checksum: 0c42f415674f60ca2ef4274b446577621cdeec8f31168b1c3b90888a4377c513f02a89ee346421c264ec3a77fe2fa3e134996be6463ed506dbbc79b4b4505375 + languageName: node + linkType: hard + "@malept/cross-spawn-promise@npm:^1.1.0": version: 1.1.1 resolution: "@malept/cross-spawn-promise@npm:1.1.1" From 689931605722ca6d57cafe06b15089613e641b8b Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 24 Jun 2022 00:17:16 -0700 Subject: [PATCH 02/86] Fix math missing enclosing tag. Math blocks and regions of inline math were missing their original containing nodes. --- .../components/NoteEditor/CodeMirror.ts | 8 +- .../NoteEditor/CodeMirrorDecorator.ts | 46 ++++++-- .../components/NoteEditor/CodeMirrorTheme.ts | 20 +++- .../NoteEditor/MarkdownTeXParser.ts | 110 ++++++++++++------ 4 files changed, 129 insertions(+), 55 deletions(-) diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror.ts b/packages/app-mobile/components/NoteEditor/CodeMirror.ts index 7027781a252..a0f86bb153a 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirror.ts @@ -9,7 +9,7 @@ // wrapper to access CodeMirror functionalities. Anything else should be done // from NoteEditor.tsx. -import MarkdownTeXParser from './MarkdownTeXParser'; +import { MarkdownMathExtension } from './MarkdownTeXParser'; import codeMirrorDecorator from './CodeMirrorDecorator'; import createTheme from './CodeMirrorTheme'; import syntaxHighlightingLanguages from './CodeMirrorLanguages'; @@ -22,7 +22,7 @@ import { EditorView, drawSelection, highlightSpecialChars, ViewUpdate } from '@c import { undo, redo, history, undoDepth, redoDepth } from '@codemirror/commands'; import { keymap } from '@codemirror/view'; -import { historyKeymap, defaultKeymap } from '@codemirror/commands'; +import { historyKeymap, defaultKeymap, indentWithTab } from '@codemirror/commands'; interface CodeMirrorResult { editor: EditorView; @@ -73,7 +73,7 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a markdown({ extensions: [ GFM, - MarkdownTeXParser, + MarkdownMathExtension, ], codeLanguages: syntaxHighlightingLanguages, }), @@ -105,7 +105,7 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a } }), keymap.of([ - ...defaultKeymap, ...historyKeymap, + ...defaultKeymap, ...historyKeymap, indentWithTab, ]), ], doc: initialText, diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts index 9106f6bf462..267822823fc 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts @@ -21,18 +21,26 @@ const codeBlockDecoration = Decoration.line({ attributes: { class: 'cm-codeBlock' }, }); +const inlineCodeDecoration = Decoration.mark({ + attributes: { class: 'cm-inlineCode' }, +}); + const mathBlockDecoration = Decoration.line({ attributes: { class: 'cm-mathBlock' }, }); +const inlineMathDecoration = Decoration.mark({ + attributes: { class: 'cm-inlineMath' }, +}); + const blockQuoteDecoration = Decoration.line({ attributes: { class: 'cm-blockQuote' }, }); // Returns a set of [Decoration]s, associated with block syntax groups that require // full-line styling. -function lineDecoration(view: EditorView) { - const decorations: { pos: number; decoration: Decoration }[] = []; +function computeDecorations(view: EditorView) { + const decorations: { pos: number; length?: number; decoration: Decoration }[] = []; // Add a decoration to all lines between the document position [from] up to // and includeing the position [to]. @@ -50,6 +58,14 @@ function lineDecoration(view: EditorView) { } }; + const addDecorationToRange = (from: number, to: number, decoration: Decoration) => { + decorations.push({ + pos: from, + length: to - from, + decoration, + }); + }; + for (const { from, to } of view.visibleRanges) { ensureSyntaxTree( view.state, @@ -57,7 +73,7 @@ function lineDecoration(view: EditorView) { )?.iterate({ from, to, enter: node => { - let decorated = false; + let blockDecorated = false; // Compute the visible region of the node. const viewFrom = Math.max(from, node.from); @@ -67,19 +83,25 @@ function lineDecoration(view: EditorView) { case 'FencedCode': case 'CodeBlock': addDecorationToLines(viewFrom, viewTo, codeBlockDecoration); - decorated = true; + blockDecorated = true; break; case 'BlockMath': addDecorationToLines(viewFrom, viewTo, mathBlockDecoration); - decorated = true; + blockDecorated = true; break; case 'Blockquote': addDecorationToLines(viewFrom, viewTo, blockQuoteDecoration); - decorated = true; + blockDecorated = true; + break; + case 'InlineMath': + addDecorationToRange(viewFrom, viewTo, inlineMathDecoration); + break; + case 'InlineCode': + addDecorationToRange(viewFrom, viewTo, inlineCodeDecoration); break; } - if (decorated) { + if (blockDecorated) { // Allow different styles for the first, last lines in a block. if (viewFrom == node.from) { addDecorationToLines(viewFrom, viewFrom, regionStartDecoration); @@ -97,8 +119,9 @@ function lineDecoration(view: EditorView) { // Items need to be added to a RangeSetBuilder in ascending order const decorationBuilder = new RangeSetBuilder(); - for (const { pos, decoration } of decorations) { - decorationBuilder.add(pos, pos, decoration); + for (const { pos, length, decoration } of decorations) { + // Null length => entire line + decorationBuilder.add(pos, pos + (length ?? 0), decoration); } return decorationBuilder.finish(); } @@ -107,15 +130,14 @@ const decoratorPlugin = ViewPlugin.fromClass(class { public decorations: DecorationSet; public constructor(view: EditorView) { - this.decorations = lineDecoration(view); - + this.decorations = computeDecorations(view); } public update(viewUpdate: ViewUpdate) { // TODO: If decorations that are invisible when the focus is near, this // may need to be updated more often: if (viewUpdate.docChanged || viewUpdate.viewportChanged) { - this.decorations = lineDecoration(viewUpdate.view); + this.decorations = computeDecorations(viewUpdate.view); } } }, { diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts index ddfe5209f44..f1c68a79c59 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts @@ -8,6 +8,8 @@ import { tags } from '@lezer/highlight'; import { EditorView } from '@codemirror/view'; import { Extension } from '@codemirror/state'; +import { inlineMathTag, mathTag } from './MarkdownTeXParser'; + // For an example on how to customize the theme, see: // // https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts @@ -85,6 +87,17 @@ const createTheme = (theme: any): Extension[] => { borderColor: theme.colorFaded, backgroundColor: 'rgba(155, 155, 155, 0.1)', }, + + // CodeMirror wraps the existing inline span in an additional element. + // Style the contianed element (work around a rendering bug). + '& .cm-inlineCode > *': { + border: '1px solid rgba(100, 100, 100, 0.3)', + borderRadius: '4px', + }, + + '& .cm-mathBlock, & .cm-inlineMath': { + color: isDarkTheme ? '#afe' : '#276', + }, }); const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme }); @@ -148,12 +161,9 @@ const createTheme = (theme: any): Extension[] => { color: theme.urlColor, textDecoration: 'underline', }, - - // Code blocks { - tag: tags.monospace, - border: '1px solid rgba(100, 100, 100, 0.2)', - borderRadius: '4px', + tag: [mathTag, inlineMathTag], + fontStyle: 'italic', }, // Content of code blocks diff --git a/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts b/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts index fff508b025d..2db78264f7c 100644 --- a/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts +++ b/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts @@ -1,7 +1,7 @@ // Extends the lezer markdown parser to identify math regions (display and inline) // See also https://github.com/lezer-parser/markdown/blob/main/src/extension.ts // for the built-in extensions. -import { tags } from '@lezer/highlight'; +import { tags, Tag } from '@lezer/highlight'; import { parseMixed, SyntaxNodeRef, Input, NestedParse } from '@lezer/common'; import { MarkdownConfig, InlineContext, @@ -11,16 +11,23 @@ import { stexMath } from '@codemirror/legacy-modes/mode/stex'; import { StreamLanguage } from '@codemirror/language'; const DOLLAR_SIGN_CHAR_CODE = 36; -const MATH_BLOCK_START_REGEX = /^\$\$/; -const MATH_BLOCK_STOP_REGEX = /^.*\$\$\s*$/; +const BACKSLASH_CHAR_CODE = 92; + +// (?:[>]\s*)?: Optionally allow block math lines to start with '> ' +const MATH_BLOCK_START_REGEX = /^(?:\s*[>]\s*)?\$\$/; +const MATH_BLOCK_STOP_REGEX = /\$\$\s*$/; const TEX_LANGUAGE = StreamLanguage.define(stexMath); const BLOCK_MATH_TAG = 'BlockMath'; +const BLOCK_MATH_CONTENT_TAG = 'BlockMathContent'; const INLINE_MATH_TAG = 'InlineMath'; +const INLINE_MATH_CONTENT_TAG = 'InlineMathContent'; -const InlineMathDelim = { resolve: INLINE_MATH_TAG, mark: 'InlineMathDelim' }; +export const mathTag = Tag.define(tags.monospace); +export const inlineMathTag = Tag.define(mathTag); -// Wraps a TeX math-mode parser +// Wraps a TeX math-mode parser. This removes [nodeTag] from the syntax tree +// and replaces it with a region handled by the sTeXMath parser. const wrappedTeXParser = (nodeTag: string) => parseMixed( (node: SyntaxNodeRef, _input: Input): NestedParse => { if (node.name != nodeTag) { @@ -36,11 +43,10 @@ const InlineMathConfig: MarkdownConfig = { defineNodes: [ { name: INLINE_MATH_TAG, - style: tags.comment, + style: inlineMathTag, }, { - name: 'InlineMathDelim', - style: tags.processingInstruction, + name: INLINE_MATH_CONTENT_TAG, }, ], parseInline: [{ @@ -56,60 +62,91 @@ const InlineMathConfig: MarkdownConfig = { return -1; } - // $ delimiters are both opening and closing delimiters - const isOpen = true; - const isClose = true; - cx.addDelimiter(InlineMathDelim, pos, pos + 1, isOpen, isClose); + let escaped = false; + const start = pos; + const end = cx.end; + + pos ++; + + // Scan ahead for the next '$' symbol + for (; pos < end && (escaped || cx.char(pos) != DOLLAR_SIGN_CHAR_CODE); pos++) { + if (!escaped && cx.char(pos) == BACKSLASH_CHAR_CODE) { + escaped = true; + } else { + escaped = false; + } + } + + // Advance to just after the ending '$' + pos ++; + + // Add a wraping INLINE_MATH_TAG node that contains an INLINE_MATH_CONTENT_TAG. + // The INLINE_MATH_CONTENT_TAG node can thus be safely removed and the region + // will still be marked as a math region. + const contentElem = cx.elt(INLINE_MATH_CONTENT_TAG, start + 1, pos - 1); + cx.addElement(cx.elt(INLINE_MATH_TAG, start, pos + 1, [contentElem])); + return pos + 1; }, }], - wrap: wrappedTeXParser(INLINE_MATH_TAG), + wrap: wrappedTeXParser(INLINE_MATH_CONTENT_TAG), }; const BlockMathConfig: MarkdownConfig = { defineNodes: [ { name: BLOCK_MATH_TAG, - style: tags.comment, + style: mathTag, + }, + { + name: BLOCK_MATH_CONTENT_TAG, }, ], parseBlock: [{ name: BLOCK_MATH_TAG, - before: 'FencedCode', + before: 'Blockquote', parse(cx: BlockContext, line: Line): boolean { - const delimLength = 2; - const start = cx.lineStart; + const lineLength = line.text.length; + const delimLen = 2; // $$ delimiter? Start math! - if (MATH_BLOCK_START_REGEX.exec(line.text)) { + const mathStartMatch = MATH_BLOCK_START_REGEX.exec(line.text); + if (mathStartMatch) { + const start = cx.lineStart + mathStartMatch[0].length; + let stop; + + let endMatch = MATH_BLOCK_STOP_REGEX.exec( + line.text.substring(mathStartMatch[0].length) + ); + // If the math region ends immediately (on the same line), - if (MATH_BLOCK_STOP_REGEX.exec(line.text.substring(delimLength))) { - const elem = cx.elt( - BLOCK_MATH_TAG, cx.lineStart, cx.lineStart + line.text.length); - cx.addElement(elem); + if (endMatch) { + stop = cx.lineStart + lineLength - endMatch[0].length; } else { let hadNextLine = false; + // Otherwise, it's a multi-line block display. // Consume lines until we reach the end. do { hadNextLine = cx.nextLine(); + endMatch = hadNextLine ? MATH_BLOCK_STOP_REGEX.exec(line.text) : null; } - while (hadNextLine && !MATH_BLOCK_STOP_REGEX.exec(line.text)); - - let stop; + while (hadNextLine && endMatch == null); - // Only include the ending delimiter if it exists - if (hadNextLine) { - stop = cx.lineStart + delimLength; + if (hadNextLine && endMatch) { + // Remove the ending delimiter + stop = cx.lineStart + lineLength - endMatch[0].length; } else { stop = cx.lineStart; } - - // Mark all lines in the block as math. - const elem = cx.elt(BLOCK_MATH_TAG, start, stop); - cx.addElement(elem); } + // Label the region. Add two labels so that one can be removed. + const contentElem = cx.elt(BLOCK_MATH_CONTENT_TAG, start, stop); + cx.addElement( + cx.elt(BLOCK_MATH_TAG, start - delimLen, stop + delimLen, [contentElem]) + ); + // Don't re-process the ending delimiter (it may look the same // as the starting delimiter). cx.nextLine(); @@ -125,8 +162,13 @@ const BlockMathConfig: MarkdownConfig = { return MATH_BLOCK_START_REGEX.exec(line.text) != null; }, }], - wrap: wrappedTeXParser(BLOCK_MATH_TAG), + wrap: wrappedTeXParser(BLOCK_MATH_CONTENT_TAG), }; +// Markdown configuration for block and inline math support. +const MarkdownMathExtension: MarkdownConfig[] = [ + InlineMathConfig, + BlockMathConfig, +]; -export default [InlineMathConfig, BlockMathConfig]; +export { MarkdownMathExtension }; From 04f7cd47b0c81197c7f6d9835bd3d028ae3010b8 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 24 Jun 2022 00:31:06 -0700 Subject: [PATCH 03/86] Minor code cleanup --- .../NoteEditor/CodeMirrorDecorator.ts | 14 +++---- .../NoteEditor/CodeMirrorLanguages.ts | 42 +++++-------------- 2 files changed, 17 insertions(+), 39 deletions(-) diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts index 267822823fc..0982b51b8c9 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.ts @@ -1,8 +1,9 @@ -// Handles multi-line decorations. -// Exports a CodeMirror6 plugin. -// -// Ref: -// • https://codemirror.net/examples/zebra/ +/** + * Exports an editor plugin that creates multi-line decorations based on the + * editor's syntax tree (assumes markdown). + * + * For more about creating decorations, see https://codemirror.net/examples/zebra/ + */ import { Decoration, EditorView } from '@codemirror/view'; import { ViewPlugin, DecorationSet, ViewUpdate } from '@codemirror/view'; @@ -101,6 +102,7 @@ function computeDecorations(view: EditorView) { break; } + // Only block decorations will have differing first and last lines if (blockDecorated) { // Allow different styles for the first, last lines in a block. if (viewFrom == node.from) { @@ -134,8 +136,6 @@ const decoratorPlugin = ViewPlugin.fromClass(class { } public update(viewUpdate: ViewUpdate) { - // TODO: If decorations that are invisible when the focus is near, this - // may need to be updated more often: if (viewUpdate.docChanged || viewUpdate.viewportChanged) { this.decorations = computeDecorations(viewUpdate.view); } diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.ts index b2505d5e068..36e89c4c5b9 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.ts @@ -1,5 +1,6 @@ -// Languages supported by code regions - +/** + * Exports a list of languages that can be used in fenced code blocks. + */ import { LanguageDescription, LanguageSupport, StreamParser } from '@codemirror/language'; import { StreamLanguage } from '@codemirror/language'; @@ -22,6 +23,7 @@ import { julia } from '@codemirror/legacy-modes/mode/julia'; import { haskell } from '@codemirror/legacy-modes/mode/haskell'; import { pascal } from '@codemirror/legacy-modes/mode/pascal'; import { yaml } from '@codemirror/legacy-modes/mode/yaml'; +import { xml } from '@codemirror/legacy-modes/mode/xml'; import { shell } from '@codemirror/legacy-modes/mode/shell'; import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'; import { diff } from '@codemirror/legacy-modes/mode/diff'; @@ -45,18 +47,17 @@ const supportedLanguages: { support?: LanguageSupport; }[] = [ // Based on @joplin/desktop/CodeMirror/Editor.tsx + { name: 'LaTeX', aliases: ['tex', 'latex', 'luatex'], parser: stex, }, - // 'python': [ 'py', ], { name: 'python', aliases: ['py'], parser: python, }, - // 'clike': [ 'c', 'h', ], { name: 'clike', aliases: ['c', 'h'], @@ -71,7 +72,6 @@ const supportedLanguages: { name: 'java', support: java(), }, - // 'javascript': [ 'js', ], { name: 'javascript', aliases: ['js', 'mjs'], @@ -82,7 +82,6 @@ const supportedLanguages: { aliases: ['ts'], support: javascript({ jsx: false, typescript: true }), }, - // 'jsx': [], { name: 'react javascript', aliases: ['jsx'], @@ -93,138 +92,117 @@ const supportedLanguages: { aliases: ['tsx'], support: javascript({ jsx: true, typescript: true }), }, - // 'lua': [], { name: 'lua', parser: lua, }, - // 'php': [], { name: 'php', support: php(), }, - // 'r': [], { name: 'r', parser: r, }, - // 'swift': [], { name: 'swift', parser: swift, }, - // 'go': [], { name: 'go', parser: go, }, - // 'vb': [ 'visualbasic' ], { name: 'visualbasic', aliases: ['vb'], parser: vb, }, - // 'vbscript': [ 'vbs' ], { name: 'visualbasicscript', aliases: ['vbscript', 'vbs'], parser: vbScript, }, - // 'ruby': [], { name: 'ruby', aliases: ['rb'], parser: ruby, }, - // 'rust': [], { name: 'rust', aliases: ['rs'], support: rust(), }, - // 'dart': [], { name: 'dart', parser: dart, }, - // 'groovy': [], { name: 'groovy', parser: groovy, }, - // 'perl': [], { name: 'perl', aliases: ['pl'], parser: perl, }, - // 'cobol': [], { name: 'cobol', aliases: ['cbl', 'cob'], parser: cobol, }, - // 'julia': [], { name: 'julia', aliases: ['jl'], parser: julia, }, - // 'haskell': [], { name: 'haskell', aliases: ['hs'], parser: haskell, }, - // 'pascal': [], { name: 'pascal', parser: pascal, }, - // 'css': [], { name: 'css', parser: css, }, - // 'xml': [ 'html', 'xhtml' ], + { + name: 'xml', + aliases: ['xhtml'], + parser: xml, + }, { name: 'html', aliases: ['html', 'htm'], support: html(), }, - // 'markdown': [ 'md' ], { name: 'markdown', - aliases: ['md'], support: markdown(), }, - // 'yaml': [], { name: 'yaml', parser: yaml, }, - // 'shell': [ 'bash', 'sh', 'zsh', ], { name: 'shell', aliases: ['bash', 'sh', 'zsh', 'dash'], parser: shell, }, - // 'dockerfile': [], { name: 'dockerfile', parser: dockerFile, }, - // 'diff': [], { name: 'diff', parser: diff, }, - // 'erlang': [], { name: 'erlang', parser: erlang, }, - // 'sql': [], { name: 'sql', parser: standardSQL, From f993bce7101b8ad25931bc63e8983db306359a6c Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 24 Jun 2022 04:41:45 -0700 Subject: [PATCH 04/86] Adjust dark mode TeX color to increase contrast with keywords --- packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts index f1c68a79c59..e77a8bbc4fa 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts @@ -96,7 +96,7 @@ const createTheme = (theme: any): Extension[] => { }, '& .cm-mathBlock, & .cm-inlineMath': { - color: isDarkTheme ? '#afe' : '#276', + color: isDarkTheme ? '#9fa' : '#276', }, }); From 28da39e959ac9e3054b0ad8ad2480a375148d6e0 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 24 Jun 2022 12:02:49 -0700 Subject: [PATCH 05/86] Make TeX matching more consistent with rendered markdown --- .../components/NoteEditor/MarkdownTeXParser.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts b/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts index bc7ddc102bf..2835356a686 100644 --- a/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts +++ b/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts @@ -62,15 +62,20 @@ const InlineMathConfig: MarkdownConfig = { name: INLINE_MATH_TAG, after: 'InlineCode', - parse(cx: InlineContext, next: number, pos: number): number { + parse(cx: InlineContext, current: number, pos: number): number { const prevCharCode = pos - 1 >= 0 ? cx.char(pos - 1) : -1; const nextCharCode = cx.char(pos + 1); - if (next != DOLLAR_SIGN_CHAR_CODE + if (current != DOLLAR_SIGN_CHAR_CODE || prevCharCode == DOLLAR_SIGN_CHAR_CODE || nextCharCode == DOLLAR_SIGN_CHAR_CODE) { return -1; } + // Don't match if there's a space directly after the '$' + if (/\s/.exec(String.fromCharCode(nextCharCode))) { + return -1; + } + let escaped = false; const start = pos; const end = cx.end; @@ -86,6 +91,12 @@ const InlineMathConfig: MarkdownConfig = { } } + // Don't match if the ending '$' is preceded by a space. + const prevChar = String.fromCharCode(cx.char(pos - 1)); + if (/\s/.exec(prevChar)) { + return -1; + } + // Advance to just after the ending '$' pos ++; From 94d457ebec7ec160afe09420c302e4460d75a280 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 24 Jun 2022 12:33:00 -0700 Subject: [PATCH 06/86] Rename MarkdownTeXParser to MarkdownMathParser --- packages/app-mobile/components/NoteEditor/CodeMirror.ts | 2 +- packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts | 2 +- .../NoteEditor/{MarkdownTeXParser.ts => MarkdownMathParser.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/app-mobile/components/NoteEditor/{MarkdownTeXParser.ts => MarkdownMathParser.ts} (100%) diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror.ts b/packages/app-mobile/components/NoteEditor/CodeMirror.ts index 0154285ddb2..8ceadbb567d 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirror.ts @@ -9,7 +9,7 @@ // wrapper to access CodeMirror functionalities. Anything else should be done // from NoteEditor.tsx. -import { MarkdownMathExtension } from './MarkdownTeXParser'; +import { MarkdownMathExtension } from './MarkdownMathParser'; import codeMirrorDecorator from './CodeMirrorDecorator'; import createTheme from './CodeMirrorTheme'; import syntaxHighlightingLanguages from './CodeMirrorLanguages'; diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts index e77a8bbc4fa..f260250f69d 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts @@ -8,7 +8,7 @@ import { tags } from '@lezer/highlight'; import { EditorView } from '@codemirror/view'; import { Extension } from '@codemirror/state'; -import { inlineMathTag, mathTag } from './MarkdownTeXParser'; +import { inlineMathTag, mathTag } from './MarkdownMathParser'; // For an example on how to customize the theme, see: // diff --git a/packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts b/packages/app-mobile/components/NoteEditor/MarkdownMathParser.ts similarity index 100% rename from packages/app-mobile/components/NoteEditor/MarkdownTeXParser.ts rename to packages/app-mobile/components/NoteEditor/MarkdownMathParser.ts From 37753cc4c03f97061bee7fa6eef7749221acf8f4 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 24 Jun 2022 19:21:19 -0700 Subject: [PATCH 07/86] Don't render math if the user has disabled it. --- .../components/NoteEditor/CodeMirror.ts | 58 ++++++++++++------- .../components/NoteEditor/EditorType.ts | 36 ++++++++++++ .../components/NoteEditor/NoteEditor.tsx | 30 ++++------ .../app-mobile/components/screens/Note.tsx | 3 +- 4 files changed, 87 insertions(+), 40 deletions(-) create mode 100644 packages/app-mobile/components/NoteEditor/EditorType.ts diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror.ts b/packages/app-mobile/components/NoteEditor/CodeMirror.ts index 8ceadbb567d..6be92203ec7 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirror.ts @@ -26,13 +26,9 @@ import { keymap } from '@codemirror/view'; import { searchKeymap } from '@codemirror/search'; import { historyKeymap, defaultKeymap, indentWithTab } from '@codemirror/commands'; -interface CodeMirrorResult { - editor: EditorView; - undo: Function; - redo: Function; - select: (anchor: number, head: number)=> void; - insertText: (text: string)=> void; -} +import { EditorControl, EditorSettings } from './EditorType'; +import { ChangeEvent, SelectionChangeEvent, Selection } from './EditorType'; + function postMessage(name: string, data: any) { (window as any).ReactNativeWebView.postMessage(JSON.stringify({ @@ -45,8 +41,11 @@ function logMessage(...msg: any[]) { postMessage('onLog', { value: msg }); } -export function initCodeMirror(parentElement: any, initialText: string, theme: any): CodeMirrorResult { +export function initCodeMirror( + parentElement: any, initialText: string, settings: EditorSettings +): EditorControl { logMessage('Initializing CodeMirror...'); + const theme = settings.themeData; let schedulePostUndoRedoDepthChangeId_: any = 0; function schedulePostUndoRedoDepthChange(editor: EditorView, doItNow: boolean = false) { @@ -67,6 +66,31 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a }, doItNow ? 0 : 1000); } + function notifyDocChanged(viewUpdate: ViewUpdate) { + if (viewUpdate.docChanged) { + const event: ChangeEvent = { + value: editor.state.doc.toString(), + }; + + postMessage('onChange', event); + schedulePostUndoRedoDepthChange(editor); + } + } + + function notifySelectionChange(viewUpdate: ViewUpdate) { + if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { + const mainRange = viewUpdate.state.selection.main; + const selection: Selection = { + start: mainRange.from, + end: mainRange.to, + }; + const event: SelectionChangeEvent = { + selection, + }; + postMessage('onSelectionChange', event); + } + } + const editor = new EditorView({ state: EditorState.create({ // See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts @@ -75,7 +99,9 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a markdown({ extensions: [ GFM, - MarkdownMathExtension, + + // Don't highlight KaTeX if the user disabled it + settings.katexEnabled ? MarkdownMathExtension : [], ], codeLanguages: syntaxHighlightingLanguages, }), @@ -96,17 +122,8 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a EditorView.lineWrapping, EditorView.contentAttributes.of({ autocapitalize: 'sentence' }), EditorView.updateListener.of((viewUpdate: ViewUpdate) => { - if (viewUpdate.docChanged) { - postMessage('onChange', { value: editor.state.doc.toString() }); - schedulePostUndoRedoDepthChange(editor); - } - - if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { - const mainRange = viewUpdate.state.selection.main; - const selStart = mainRange.from; - const selEnd = mainRange.to; - postMessage('onSelectionChange', { selection: { start: selStart, end: selEnd } }); - } + notifyDocChanged(viewUpdate); + notifySelectionChange(viewUpdate); }), keymap.of([ ...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap, @@ -119,6 +136,7 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a return { editor, + undo: () => { undo(editor); schedulePostUndoRedoDepthChange(editor, true); diff --git a/packages/app-mobile/components/NoteEditor/EditorType.ts b/packages/app-mobile/components/NoteEditor/EditorType.ts new file mode 100644 index 00000000000..5399da16e57 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/EditorType.ts @@ -0,0 +1,36 @@ +// Types related to the NoteEditor + +export interface ChangeEvent { + // New editor content + value: string; +} + +export interface UndoRedoDepthChangeEvent { + undoDepth: number; + redoDepth: number; +} + +export interface Selection { + start: number; + end: number; +} + +export interface SelectionChangeEvent { + selection: Selection; +} + +export interface EditorControl { + // A reference to the CodeMirror EditorView. + // Use for debugging. + editor: any; + + undo(): void; + redo(): void; + select(anchor: number, head: number): void; + insertText(text: string): void; +} + +export interface EditorSettings { + themeData: any; + katexEnabled: boolean; +} diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index 823e044ca3a..608e8ebf057 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -5,24 +5,10 @@ const React = require('react'); const { forwardRef, useImperativeHandle, useEffect, useMemo, useState, useCallback, useRef } = require('react'); const { WebView } = require('react-native-webview'); const { editorFont } = require('../global-style'); - -export interface ChangeEvent { - value: string; -} - -export interface UndoRedoDepthChangeEvent { - undoDepth: number; - redoDepth: number; -} - -export interface Selection { - start: number; - end: number; -} - -export interface SelectionChangeEvent { - selection: Selection; -} +import { + ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent, + EditorSettings, +} from './EditorType'; type ChangeEventHandler = (event: ChangeEvent)=> void; type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void; @@ -252,6 +238,11 @@ function NoteEditor(props: Props, ref: any) { cm.select(${props.initialSelection.start}, ${props.initialSelection.end}); ` : ''; + const editorSettings: EditorSettings = { + themeData: JSON.stringify(editorTheme(props.themeId)), + katexEnabled: Setting.value('markdown.plugin.katex') as boolean, + }; + const injectedJavaScript = ` function postMessage(name, data) { window.ReactNativeWebView.postMessage(JSON.stringify({ @@ -275,8 +266,9 @@ function NoteEditor(props: Props, ref: any) { const parentElement = document.getElementsByClassName('CodeMirror')[0]; const theme = ${JSON.stringify(editorTheme(props.themeId))}; const initialText = ${JSON.stringify(props.initialText)}; + const settings = ${JSON.stringify(editorSettings)}; - cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, theme); + cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, settings); ${setInitialSelectionJS} } catch (e) { window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e)) diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index d591a7b91d0..17864da567f 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -5,7 +5,8 @@ import shim from '@joplin/lib/shim'; import UndoRedoService from '@joplin/lib/services/UndoRedoService'; import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer'; import checkPermissions from '../../utils/checkPermissions'; -import NoteEditor, { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/NoteEditor'; +import NoteEditor from '../NoteEditor/NoteEditor'; +import { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/EditorType'; const FileViewer = require('react-native-file-viewer').default; const React = require('react'); From 3b08dc86cd25461d4150c5894e13ae3be4ba10ca Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 24 Jun 2022 19:29:39 -0700 Subject: [PATCH 08/86] Update .gitignore, .eslintignore with auto-generated .js files --- .eslintignore | 15 +++++++++++++++ .gitignore | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.eslintignore b/.eslintignore index 964f1c4e61b..f36ee881944 100644 --- a/.eslintignore +++ b/.eslintignore @@ -856,6 +856,21 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map packages/app-mobile/components/NoteEditor/CodeMirror.d.ts packages/app-mobile/components/NoteEditor/CodeMirror.js packages/app-mobile/components/NoteEditor/CodeMirror.js.map +packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.d.ts +packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.js +packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.js.map +packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.d.ts +packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.js +packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.js.map +packages/app-mobile/components/NoteEditor/CodeMirrorTheme.d.ts +packages/app-mobile/components/NoteEditor/CodeMirrorTheme.js +packages/app-mobile/components/NoteEditor/CodeMirrorTheme.js.map +packages/app-mobile/components/NoteEditor/EditorType.d.ts +packages/app-mobile/components/NoteEditor/EditorType.js +packages/app-mobile/components/NoteEditor/EditorType.js.map +packages/app-mobile/components/NoteEditor/MarkdownMathParser.d.ts +packages/app-mobile/components/NoteEditor/MarkdownMathParser.js +packages/app-mobile/components/NoteEditor/MarkdownMathParser.js.map packages/app-mobile/components/NoteEditor/NoteEditor.d.ts packages/app-mobile/components/NoteEditor/NoteEditor.js packages/app-mobile/components/NoteEditor/NoteEditor.js.map diff --git a/.gitignore b/.gitignore index 55e212247d8..d45265438f1 100644 --- a/.gitignore +++ b/.gitignore @@ -846,6 +846,21 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map packages/app-mobile/components/NoteEditor/CodeMirror.d.ts packages/app-mobile/components/NoteEditor/CodeMirror.js packages/app-mobile/components/NoteEditor/CodeMirror.js.map +packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.d.ts +packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.js +packages/app-mobile/components/NoteEditor/CodeMirrorDecorator.js.map +packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.d.ts +packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.js +packages/app-mobile/components/NoteEditor/CodeMirrorLanguages.js.map +packages/app-mobile/components/NoteEditor/CodeMirrorTheme.d.ts +packages/app-mobile/components/NoteEditor/CodeMirrorTheme.js +packages/app-mobile/components/NoteEditor/CodeMirrorTheme.js.map +packages/app-mobile/components/NoteEditor/EditorType.d.ts +packages/app-mobile/components/NoteEditor/EditorType.js +packages/app-mobile/components/NoteEditor/EditorType.js.map +packages/app-mobile/components/NoteEditor/MarkdownMathParser.d.ts +packages/app-mobile/components/NoteEditor/MarkdownMathParser.js +packages/app-mobile/components/NoteEditor/MarkdownMathParser.js.map packages/app-mobile/components/NoteEditor/NoteEditor.d.ts packages/app-mobile/components/NoteEditor/NoteEditor.js packages/app-mobile/components/NoteEditor/NoteEditor.js.map From 45425c91f192826d58f0fe813f891aff109c61d1 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 24 Jun 2022 21:19:08 -0700 Subject: [PATCH 09/86] Fix settings serialization --- packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts | 4 +++- packages/app-mobile/components/NoteEditor/NoteEditor.tsx | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts index f260250f69d..96286f4185c 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirrorTheme.ts @@ -91,7 +91,9 @@ const createTheme = (theme: any): Extension[] => { // CodeMirror wraps the existing inline span in an additional element. // Style the contianed element (work around a rendering bug). '& .cm-inlineCode > *': { - border: '1px solid rgba(100, 100, 100, 0.3)', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: isDarkTheme ? 'rgba(200, 200, 200, 0.5)' : 'rgba(100, 100, 100, 0.5)', borderRadius: '4px', }, diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index 608e8ebf057..6b050eba091 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -239,7 +239,7 @@ function NoteEditor(props: Props, ref: any) { ` : ''; const editorSettings: EditorSettings = { - themeData: JSON.stringify(editorTheme(props.themeId)), + themeData: editorTheme(props.themeId), katexEnabled: Setting.value('markdown.plugin.katex') as boolean, }; @@ -264,7 +264,6 @@ function NoteEditor(props: Props, ref: any) { ${shim.injectedJs('codeMirrorBundle')}; const parentElement = document.getElementsByClassName('CodeMirror')[0]; - const theme = ${JSON.stringify(editorTheme(props.themeId))}; const initialText = ${JSON.stringify(props.initialText)}; const settings = ${JSON.stringify(editorSettings)}; From 2a17e9045d85446a5eca491029bf80e32ad8f19f Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Tue, 14 Jun 2022 08:56:55 -0700 Subject: [PATCH 10/86] Markdown toolbar with bold and italicize buttons --- .../components/NoteEditor/CodeMirror.ts | 128 ++++++++++- .../components/NoteEditor/MarkdownToolbar.tsx | 34 +++ .../components/NoteEditor/NoteEditor.tsx | 212 +++++------------- 3 files changed, 209 insertions(+), 165 deletions(-) create mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar.tsx diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror.ts b/packages/app-mobile/components/NoteEditor/CodeMirror.ts index 4c2ed5e71cd..6f9f0f0410f 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirror.ts @@ -14,14 +14,17 @@ import { markdown } from '@codemirror/lang-markdown'; import { highlightSelectionMatches, search } from '@codemirror/search'; import { defaultHighlightStyle, syntaxHighlighting, HighlightStyle } from '@codemirror/language'; import { tags } from '@lezer/highlight'; +import { GFM } from '@lezer/markdown'; import { EditorView, drawSelection, highlightSpecialChars, ViewUpdate } from '@codemirror/view'; import { undo, redo, history, undoDepth, redoDepth } from '@codemirror/commands'; import { keymap } from '@codemirror/view'; -import { indentOnInput } from '@codemirror/language'; +import { indentOnInput, syntaxTree } from '@codemirror/language'; import { searchKeymap } from '@codemirror/search'; import { historyKeymap, defaultKeymap } from '@codemirror/commands'; +import { SelectionRange, EditorSelection } from '@codemirror/state'; + interface CodeMirrorResult { editor: EditorView; undo: Function; @@ -182,7 +185,9 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a // See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts // for a sample configuration. extensions: [ - markdown(), + markdown({ + extensions: [GFM], + }), ...createTheme(theme), history(), search(), @@ -215,24 +220,133 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a parent: parentElement, }); - return { + const editorControls = { editor, - undo: () => { + undo() { undo(editor); schedulePostUndoRedoDepthChange(editor, true); }, - redo: () => { + redo() { redo(editor); schedulePostUndoRedoDepthChange(editor, true); }, - select: (anchor: number, head: number) => { + select(anchor: number, head: number) { editor.dispatch(editor.state.update({ selection: { anchor, head }, scrollIntoView: true, })); }, - insertText: (text: string) => { + insertText(text: string) { editor.dispatch(editor.state.replaceSelection(text)); }, + selectionCommands: { + // / Expands selections to the smallest container node + // / with name [nodeName]. + growSelectionToNode(nodeName: string) { + const selectionRanges = editor.state.selection.ranges.map((range: SelectionRange) => { + let newFrom = null; + let newTo = null; + let smallestLen = Infinity; + + // Find the smallest range. + syntaxTree(editor.state).iterate({ + from: range.from, to: range.to, + enter: node => { + if (node.name == nodeName) { + if (node.to - node.from < smallestLen) { + newFrom = node.from; + newTo = node.to; + smallestLen = newTo - newFrom; + } + } + }, + }); + + // If it's in such a node, + if (newFrom != null && newTo != null) { + return EditorSelection.range(newFrom, newTo); + } else { + return range; + } + }); + + editor.dispatch({ + selection: EditorSelection.create(selectionRanges), + }); + }, + + // / Adds/removes [before] before the current selection and [after] + // / after it. + // / For example, surroundSelecton('**', '**') surrounds every selection + // / range with asterisks (including the caret). + // / If the selection is already surrounded by these characters, they are + // / removed. + surroundSelection(before: string, after: string) { + // Ref: https://codemirror.net/examples/decoration/ + const changes = editor.state.changeByRange((sel: SelectionRange) => { + let content = editor.state.doc.sliceString(sel.from, sel.to); + const startsWithBefore = content.indexOf(before) == 0; + const endsWithAfter = content.lastIndexOf(after) == content.length - after.length; + + const changes = []; + let finalSelStart = sel.from; + let finalSelStop = sel.to; + + if (startsWithBefore && endsWithAfter) { + // Remove the before and after. + content = content.substring(before.length); + content = content.substring(0, content.length - after.length); + + finalSelStop -= before.length + after.length; + + changes.push({ + from: sel.from, + to: sel.to, + insert: content, + }); + } else { + changes.push({ + from: sel.from, + insert: before, + }); + + changes.push({ + from: sel.to, + insert: after, + }); + + // If not a caret, + if (!sel.empty) { + // Select the surrounding chars. + finalSelStop += before.length + after.length; + } else { + // Position the caret within the added content. + finalSelStart = sel.from + before.length; + finalSelStop = finalSelStart; + } + } + + return { + changes, + range: EditorSelection.range(finalSelStart, finalSelStop), + }; + }); + + editor.dispatch(changes); + }, + // / Bolds/unbolds the current selection. + bold() { + editorControls.selectionCommands.growSelectionToNode('StrongEmphasis'); + editorControls.selectionCommands.surroundSelection('**', '**'); + }, + + // / Italicizes/deitalicizes the current selection. + italicize() { + editorControls.selectionCommands.growSelectionToNode('Emphasis'); + editorControls.selectionCommands.surroundSelection('_', '_'); + }, + }, }; + + return editorControls; } diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar.tsx new file mode 100644 index 00000000000..b6945342f1c --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/MarkdownToolbar.tsx @@ -0,0 +1,34 @@ +// / +// / A toolbar for the markdown editor. +// / + +const React = require('react'); +const { View, Button } = require('react-native'); + +interface Props { + doBold: ()=> void; + doItalicize: ()=> void; +} + +const MarkdownToolbar = (props: Props) => { + return ( + +