From 34ca1ce213ea612c3a220c3dfb6df534c21f1ff8 Mon Sep 17 00:00:00 2001 From: Mikolaj Trzcinski Date: Fri, 21 Feb 2025 10:04:34 +0100 Subject: [PATCH] [#71231] Simplify text management and Markdown rendering --- src/MystEditor.jsx | 28 +- src/components/CodeMirror.jsx | 40 +-- src/components/Diff.jsx | 6 +- src/components/TemplateManager.jsx | 7 +- src/components/Topbar.jsx | 4 +- src/extensions/cursorIndicator.js | 16 +- src/extensions/index.js | 8 +- src/extensions/syncDualPane.js | 18 +- src/hooks/useText.js | 306 ------------------ src/{hooks => markdown}/markdownCheckboxes.js | 4 +- src/{hooks => markdown}/markdownDirectives.js | 0 src/{hooks => markdown}/markdownFence.js | 0 src/{hooks => markdown}/markdownLineBreak.js | 0 src/markdown/markdownLinks.js | 20 ++ src/{hooks => markdown}/markdownMermaid.js | 2 +- src/{hooks => markdown}/markdownReplacer.js | 0 src/{hooks => markdown}/markdownSourceMap.js | 8 +- src/{hooks => markdown}/markdownUrlMapping.js | 8 +- src/mystState.js | 7 +- src/text.js | 201 ++++++++++++ 20 files changed, 293 insertions(+), 390 deletions(-) delete mode 100644 src/hooks/useText.js rename src/{hooks => markdown}/markdownCheckboxes.js (85%) rename src/{hooks => markdown}/markdownDirectives.js (100%) rename src/{hooks => markdown}/markdownFence.js (100%) rename src/{hooks => markdown}/markdownLineBreak.js (100%) create mode 100644 src/markdown/markdownLinks.js rename src/{hooks => markdown}/markdownMermaid.js (96%) rename src/{hooks => markdown}/markdownReplacer.js (100%) rename src/{hooks => markdown}/markdownSourceMap.js (97%) rename src/{hooks => markdown}/markdownUrlMapping.js (62%) create mode 100644 src/text.js diff --git a/src/MystEditor.jsx b/src/MystEditor.jsx index 692f778..f4b42a1 100644 --- a/src/MystEditor.jsx +++ b/src/MystEditor.jsx @@ -4,14 +4,13 @@ import { StyleSheetManager, styled } from "styled-components"; import CodeMirror from "./components/CodeMirror"; import Preview, { PreviewFocusHighlight } from "./components/Preview"; import Diff from "./components/Diff"; -import { useText } from "./hooks/useText"; import { EditorTopbar } from "./components/Topbar"; import ResolvedComments from "./components/Resolved"; import { handlePreviewClickToScroll } from "./extensions/syncDualPane"; import { createMystState, MystState, predefinedButtons, defaultButtons } from "./mystState"; import { batch, computed, signal, effect } from "@preact/signals"; -import { syncCheckboxes } from "./hooks/markdownCheckboxes"; import { MystContainer } from "./styles/MystStyles"; +import { syncCheckboxes } from "./markdown/markdownCheckboxes"; const EditorParent = styled.div` font-family: "Lato"; @@ -95,11 +94,13 @@ const createExtraScopePlugin = (scope) => { const hideBodyScrollIf = (val) => (document.documentElement.style.overflow = val ? "hidden" : "visible"); const MystEditor = () => { - const { editorView, cache, options, collab } = useContext(MystState); + const { editorView, cache, options, collab, text } = useContext(MystState); const [fullscreen, setFullscreen] = useState(false); const preview = useRef(null); - const text = useText({ preview }); + useEffect(() => { + text.preview.value = preview.current; + }, [preview.current]); const [alert, setAlert] = useState(null); const [users, setUsers] = useReducer( @@ -121,11 +122,11 @@ const MystEditor = () => { fullscreen: () => setFullscreen((f) => !f), refresh: () => { cache.transform.clear(); + text.renderText(false); alertFor("Rich links refreshed!", 1); - text.refresh(); }, }), - [text], + [], ); const buttons = useMemo( @@ -143,23 +144,14 @@ const MystEditor = () => { - {options.topbar.value && ( - - )} + {options.topbar.value && } {options.collaboration.value.enabled && !collab.value.ready.value && ( Connecting to the collaboration server ... )} {options.collaboration.value.enabled && collab.value.lockMsg.value && {collab.value.lockMsg}} - + { {options.mode.value === "Diff" && ( - + )} {options.collaboration.value.commentsEnabled && options.collaboration.value.resolvingCommentsEnabled && collab.value.ready.value && ( diff --git a/src/components/CodeMirror.jsx b/src/components/CodeMirror.jsx index 247aeea..ea81fa3 100644 --- a/src/components/CodeMirror.jsx +++ b/src/components/CodeMirror.jsx @@ -205,31 +205,21 @@ const CodeEditor = styled.div` } `; -const setEditorText = (editor, text) => { - editor.dispatch({ - changes: { - from: 0, - to: editor.state.doc.length, - insert: text, - }, - }); -}; - -const CodeMirror = ({ text, setUsers, preview }) => { - const { editorView, options, collab, userSettings, linter } = useContext(MystState); +const CodeMirror = ({ setUsers }) => { + const { editorView, options, collab, userSettings, linter, text } = useContext(MystState); const editorMountpoint = useRef(null); const focusScroll = useRef(null); const lastTyped = useRef(null); + const renderTimer = useRef(null); useSignalEffect(() => { if (!options.collaboration.value.enabled || (collab.value.ready.value && !collab.value.lockMsg.value)) return; - text.readyToRender(); editorView.value?.destroy(); const view = new EditorView({ root: options.parent, state: EditorState.create({ - doc: text.get(), + doc: text.text.peek(), extensions: ExtensionBuilder.basicSetup().useHighlighter(options.transforms.value).readonly().create(), }), parent: editorMountpoint.current, @@ -256,7 +246,7 @@ const CodeMirror = ({ text, setUsers, preview }) => { if (collab.value.ytext.toString().length === 0 && options.initialText.length > 0) { console.warn("[Collaboration] Remote state is empty, overriding with local state"); - text.set(options.initialText); + text.text.value = options.initialText; collab.value.ydoc.transact(() => { collab.value.ytext.insert(0, options.initialText); const metaMap = collab.value.ydoc.getMap("meta"); @@ -264,18 +254,16 @@ const CodeMirror = ({ text, setUsers, preview }) => { }); } - text.set(collab.value.ytext.toString()); + text.text.value = collab.value.ytext.toString(); collab.value.ytext.observe((ev, tr) => { if (!tr.local) return; lastTyped.current = performance.now(); }); } - text.readyToRender(); - const startState = EditorState.create({ root: options.parent, - doc: options.collaboration.value.enabled ? collab.value.ytext.toString() : text.get(), + doc: options.collaboration.value.enabled ? collab.value.ytext.toString() : text.text.peek(), extensions: ExtensionBuilder.basicSetup() .useHighlighter(options.transforms.value) .useCompartment(suggestionCompartment, customHighlighter([])) @@ -291,11 +279,18 @@ const CodeMirror = ({ text, setUsers, preview }) => { editorMountpoint, }), ) - .addUpdateListener((update) => update.docChanged && text.set(view.state.doc.toString(), update)) + .addUpdateListener((update) => { + if (!update.docChanged) return; + clearTimeout(renderTimer.current); + renderTimer.current = setTimeout(() => { + text.shiftLineMap(update); + text.text.value = view.state.doc.toString(); + }); + }) .useFixFoldingScroll(focusScroll) .useMoveCursorAfterFold() - .useCursorIndicator({ lineMap: text.lineMap, preview }) - .if(options.syncScroll.value, (b) => b.useSyncPreviewWithCursor({ lineMap: text.lineMap, preview, lastTyped })) + .useCursorIndicator({ text, preview: text.preview.value }) + .if(options.syncScroll.value, (b) => b.useSyncPreviewWithCursor({ text, preview: text.preview.value, lastTyped })) .if(options.yamlSchema.value, (b) => b.useYamlSchema(options.yamlSchema.value, editorView, linter)) .create(), }); @@ -313,7 +308,6 @@ const CodeMirror = ({ text, setUsers, preview }) => { collab.value?.ycomments?.registerCodeMirror(view); collab.value?.provider?.watchCollabolators(setUsers); - text.onSync((currentText) => setEditorText(view, currentText)); return () => { view.destroy(); diff --git a/src/components/Diff.jsx b/src/components/Diff.jsx index 4f0a443..950f92c 100644 --- a/src/components/Diff.jsx +++ b/src/components/Diff.jsx @@ -32,8 +32,8 @@ const initMergeView = ({ old, current, root }) => { }); }; -const Diff = ({ text }) => { - const { options } = useContext(MystState); +const Diff = () => { + const { options, text } = useContext(MystState); let leftRef = useRef(null); let rightRef = useRef(null); let mergeView = useRef(null); @@ -44,7 +44,7 @@ const Diff = ({ text }) => { } mergeView.current = initMergeView({ old: options.initialText, - current: text.get(), + current: text.text.peek(), root: options.parent, }); diff --git a/src/components/TemplateManager.jsx b/src/components/TemplateManager.jsx index e972e7b..9ce365f 100644 --- a/src/components/TemplateManager.jsx +++ b/src/components/TemplateManager.jsx @@ -80,8 +80,8 @@ const validateTemplConfig = (templConfig) => { return templConfig; }; -const TemplateManager = ({ text }) => { - const { options } = useContext(MystState); +const TemplateManager = () => { + const { options, editorView } = useContext(MystState); const [template, setTemplate] = useState(""); const [readyTemplates, setReadyTemplates] = useState({}); const [selectedTemplate, setSelectedTemplate] = useState(null); @@ -97,8 +97,7 @@ const TemplateManager = ({ text }) => { const changeDocumentTemplate = (template) => { setTemplate(readyTemplates[template].templatetext); - text.set(readyTemplates[template].templatetext); - text.sync(); + editorView.value.dispatch({ changes: { from: 0, to: editorView.value.state.doc.length, insert: readyTemplates[template].templatetext } }); setShowModal(false); }; diff --git a/src/components/Topbar.jsx b/src/components/Topbar.jsx index 35be697..020bb67 100644 --- a/src/components/Topbar.jsx +++ b/src/components/Topbar.jsx @@ -228,7 +228,7 @@ const icons = { settings: SettingsIcon, }; -export const EditorTopbar = ({ alert, users, text, buttons }) => { +export const EditorTopbar = ({ alert, users, buttons }) => { const { options, editorView } = useContext(MystState); const titleHtml = useComputed(() => purify.sanitize(renderMdLinks(options.title.value))); const emptyDiff = useSignal(false); @@ -268,7 +268,7 @@ export const EditorTopbar = ({ alert, users, text, buttons }) => {
{button.dropdown?.()}
))} - {buttons.find((b) => b.id === "template-manager") && options.templatelist.value && } + {buttons.find((b) => b.id === "template-manager") && options.templatelist.value && } {alert && {alert} } diff --git a/src/extensions/cursorIndicator.js b/src/extensions/cursorIndicator.js index 2c6867d..a7762eb 100644 --- a/src/extensions/cursorIndicator.js +++ b/src/extensions/cursorIndicator.js @@ -1,25 +1,25 @@ import { EditorView } from "codemirror"; -import { markdownUpdatedStateEffect } from "../hooks/useText"; -import { findNearestElementForLine } from "../hooks/markdownSourceMap"; +import { markdownUpdatedEffect } from "../text"; +import { findNearestElementForLine } from "../markdown/markdownSourceMap"; -export const cursorIndicator = (lineMap, preview) => +export const cursorIndicator = (text, preview) => EditorView.updateListener.of((update) => { const cursorLineBefore = update.startState.doc.lineAt(update.startState.selection.main.head).number; const cursorLineAfter = update.state.doc.lineAt(update.state.selection.main.head).number; const selectionChanged = update.selectionSet && (cursorLineBefore !== cursorLineAfter || cursorLineBefore === 1); - const markdownUpdated = update.transactions.some((t) => t.effects.some((e) => e.is(markdownUpdatedStateEffect))); + const markdownUpdated = update.transactions.some((t) => t.effects.some((e) => e.is(markdownUpdatedEffect))); const resized = update.geometryChanged && !update.viewportChanged; if (update.docChanged || (!selectionChanged && !markdownUpdated && !resized)) { return; } - const [matchingElem] = findNearestElementForLine(cursorLineAfter, lineMap, preview.current); - const previewElement = preview.current.querySelector(".cm-previewFocus"); + const [matchingElem] = findNearestElementForLine(cursorLineAfter, text.lineMap, preview); + const previewElement = preview.querySelector(".cm-previewFocus"); if (matchingElem) { - const previewRect = preview.current.getBoundingClientRect(); + const previewRect = preview.getBoundingClientRect(); let matchingRect = matchingElem.getBoundingClientRect(); - previewElement.style.top = `${matchingRect.top - previewRect.top + preview.current.scrollTop}px`; + previewElement.style.top = `${matchingRect.top - previewRect.top + preview.scrollTop}px`; // the decoration of a list item is not contained within it's clientRect const leftPos = matchingRect.left - previewRect.left - 12.5 - (matchingElem.tagName === "LI" || matchingElem.parentElement.tagName === "LI" ? 17 : 0); diff --git a/src/extensions/index.js b/src/extensions/index.js index cf69be5..b6c719e 100644 --- a/src/extensions/index.js +++ b/src/extensions/index.js @@ -177,13 +177,13 @@ export class ExtensionBuilder { return this; } - useSyncPreviewWithCursor({ lineMap, preview, lastTyped }) { - this.extensions.push(syncPreviewWithCursor(lineMap, preview, lastTyped)); + useSyncPreviewWithCursor({ text, preview, lastTyped }) { + this.extensions.push(syncPreviewWithCursor(text, preview, lastTyped)); return this; } - useCursorIndicator({ lineMap, preview }) { - this.extensions.push(cursorIndicator(lineMap, preview)); + useCursorIndicator({ text, preview }) { + this.extensions.push(cursorIndicator(text, preview)); return this; } diff --git a/src/extensions/syncDualPane.js b/src/extensions/syncDualPane.js index 2e3db4c..d91388d 100644 --- a/src/extensions/syncDualPane.js +++ b/src/extensions/syncDualPane.js @@ -1,6 +1,6 @@ import { EditorView } from "codemirror"; -import { markdownUpdatedStateEffect } from "../hooks/useText"; -import { findNearestElementForLine, getLineById } from "../hooks/markdownSourceMap"; +import { markdownUpdatedEffect } from "../text"; +import { findNearestElementForLine, getLineById } from "../markdown/markdownSourceMap"; import { EditorSelection } from "@codemirror/state"; const previewTopPadding = 20; @@ -10,7 +10,7 @@ const debounceTimeout = 100; */ const typedToUpdateThreshold = 500; -export const syncPreviewWithCursor = (lineMap, preview, lastTyped) => { +export const syncPreviewWithCursor = (text, preview, lastTyped) => { let timeout; return EditorView.updateListener.of((update) => { @@ -19,21 +19,21 @@ export const syncPreviewWithCursor = (lineMap, preview, lastTyped) => { const selectionChanged = update.selectionSet && (cursorLineBefore !== cursorLineAfter || cursorLineBefore === 1); const timeSinceLastTyped = lastTyped.current === null ? typedToUpdateThreshold : performance.now() - lastTyped.current; const markdownUpdated = - update.transactions.some((t) => t.effects.some((e) => e.is(markdownUpdatedStateEffect))) && timeSinceLastTyped < typedToUpdateThreshold; + update.transactions.some((t) => t.effects.some((e) => e.is(markdownUpdatedEffect))) && timeSinceLastTyped < typedToUpdateThreshold; const resized = update.geometryChanged && !update.viewportChanged; if (update.docChanged || (!selectionChanged && !markdownUpdated && !resized)) { return; } function sync() { - const [matchingElem, matchingLine] = findNearestElementForLine(cursorLineAfter, lineMap, preview.current); + const [matchingElem, matchingLine] = findNearestElementForLine(cursorLineAfter, text.lineMap, preview); if (matchingElem) { scrollPreviewElemIntoView({ view: update.view, matchingLine, matchingElem, behavior: "smooth", - preview: preview.current, + preview, }); } } @@ -60,8 +60,8 @@ function scrollPreviewElemIntoView({ view, matchingLine, matchingElem, behavior /** * @param {{ target: HTMLElement }} ev - * @param {{ current: Map<number, string> }} lineMap - * @param {{ current: HTMLElement }} preview + * @param {Map<number, string>} lineMap + * @param {HTMLElement} preview * @param { EditorView } editor */ export function handlePreviewClickToScroll(ev, lineMap, preview, editor) { @@ -78,7 +78,7 @@ export function handlePreviewClickToScroll(ev, lineMap, preview, editor) { } if (!id) return; - const lineNumber = getLineById(lineMap.current, id); + const lineNumber = getLineById(lineMap, id); const line = editor.state.doc.line(lineNumber); const visible = editor.visibleRanges[0]; function setCursor() { diff --git a/src/hooks/useText.js b/src/hooks/useText.js deleted file mode 100644 index f7728e3..0000000 --- a/src/hooks/useText.js +++ /dev/null @@ -1,306 +0,0 @@ -import markdownitDocutils, { directivesDefault } from "markdown-it-docutils"; -import purify from "dompurify"; -import markdownIt from "markdown-it"; -import { markdownReplacer, useCustomDirectives, useCustomRoles } from "./markdownReplacer"; -import { useCallback, useContext, useEffect, useReducer, useRef, useState } from "preact/hooks"; -import IMurMurHash from "imurmurhash"; -import { backslashLineBreakPlugin } from "./markdownLineBreak"; -import markdownSourceMap from "./markdownSourceMap"; -import { StateEffect } from "@codemirror/state"; -import markdownMermaid from "./markdownMermaid"; -import { EditorView, ViewUpdate } from "@codemirror/view"; -import { ensureSyntaxTree } from "@codemirror/language"; -import { MystState } from "../mystState"; -import { useComputed, useSignalEffect } from "@preact/signals"; -import markdownCheckboxes from "markdown-it-checkbox"; -import { colonFencedBlocks } from "./markdownFence"; -import { markdownItMapUrls } from "./markdownUrlMapping"; -import newDirectives from "./markdownDirectives"; -import hljs from "highlight.js/lib/core"; -import yamlHighlight from "highlight.js/lib/languages/yaml"; - -const countOccurences = (str, pattern) => (str?.match(pattern) || []).length; - -/** Extension to markdownIt which invalidates links starting with `(` */ -function checkLinks(/** @type {markdownIt} */ md) { - const defaultRule = md.renderer.rules.link_open; - md.renderer.rules.link_open = (tokens, idx, options, env, self) => { - const render = defaultRule ?? self.renderToken.bind(self); - const href = tokens[idx].attrs?.find((a) => a[0] == "href")?.[1]; - if (href?.startsWith("(")) { - const linkLabel = tokens[idx + 1]; - const linkClose = tokens[idx + 2]; - linkLabel.content = `[${linkLabel.content}]`; - linkClose.type = "text"; - linkClose.content = `(${href})`; - return ""; - } - - return render(tokens, idx, options, env, self); - }; -} - -const exposeText = (text, editorId) => () => { - window.myst_editor[editorId].text = text; -}; - -const copyHtmlAsRichText = async (/** @type {string} */ txt) => { - const parser = new DOMParser(); - const doc = parser.parseFromString(txt, "text/html"); - doc.querySelectorAll("[data-line-id]").forEach((n) => n.removeAttribute("data-line-id")); - // This removes spans added for source mapping purposes. - doc.querySelectorAll("span").forEach((n) => { - if (n.attributes.length === 0) { - n.insertAdjacentHTML("afterend", n.innerHTML); - n.remove(); - } - }); - doc.querySelectorAll("[data-remove]").forEach((n) => n.remove()); - const sanitized = doc.body.innerHTML; - - await navigator.clipboard.write([ - new ClipboardItem({ - "text/plain": new Blob([sanitized], { type: "text/plain" }), - "text/html": new Blob([sanitized], { type: "text/html" }), - }), - ]); -}; - -export const markdownUpdatedStateEffect = StateEffect.define(); - -hljs.registerLanguage("yaml", yamlHighlight); - -/** @param {{preview: { current: Element } }} */ -export const useText = ({ preview }) => { - const { editorView, cache, options, userSettings } = useContext(MystState); - const [text, setText] = useState(options.initialText); - const [readyToRender, setReadyToRender] = useState(false); - const [syncText, setSyncText] = useState(false); - const [onSync, setOnSync] = useState({ action: (text) => {} }); - const lineMap = useRef(new Map()); - /** - * Split the document into chunks and re-render only the chunks which were changed - * - * @type {[{ md: string, html: string }[], Dispatch<{newMarkdown: string, force: boolean }>]} - */ - const [htmlChunks, updateHtmlChunks] = useReducer((oldChunks, { newMarkdown, force = false, view }) => { - let htmlLookup = {}; - if (!force) { - htmlLookup = oldChunks.reduce((lookup, { hash, html }) => { - lookup[hash] = html; - return lookup; - }, {}); - } - - const newChunks = splitIntoChunks(newMarkdown, htmlLookup, view); - - if (newChunks.length !== oldChunks.length || force) { - // We can't infer which chunks were modified, so we update the entire document - const toRemove = [...preview.current.childNodes].filter((c) => !c.classList || !c.classList.contains("cm-previewFocus")); - toRemove.forEach((c) => preview.current.removeChild(c)); - - preview.current.innerHTML += newChunks.map((c) => `<html-chunk id="html-chunk-${c.id}">` + c.html + "</html-chunk>").join(""); - return newChunks; - } - - newChunks // Go through every modified chunk and update its content - .filter((newChunk, idx) => newChunk.hash !== oldChunks[idx].hash) - .forEach((chunk) => (preview.current.querySelector("html-chunk#html-chunk-" + chunk.id).innerHTML = chunk.html)); - - return newChunks; - }, []); - - const markdown = useComputed(() => { - const md = markdownIt({ - breaks: true, - linkify: true, - html: true, - highlight: (str, lang) => { - if (lang && hljs.getLanguage(lang)) { - try { - const v = hljs.highlight(str, { language: lang }).value; - return v; - } catch (err) { - console.error(`Error while highlighting ${lang}: ${err}`); - } - return md.utils.escapeHtml(str); - } - }, - }) - .use(markdownitDocutils, { directives: { ...directivesDefault, ...newDirectives } }) - .use(markdownReplacer(options.transforms.value, options.parent, cache.transform)) - .use(useCustomRoles(options.customRoles.value, options.parent, cache.transform)) - .use(useCustomDirectives(options.customDirectives.value, options.parent, cache.transform)) - .use(markdownMermaid, { lineMap, parent: options.parent }) - .use(markdownSourceMap) - .use(checkLinks) - .use(colonFencedBlocks) - .use(markdownItMapUrls) - .use(markdownCheckboxes); - if (options.backslashLineBreak.value) md.use(backslashLineBreakPlugin); - - userSettings.value.filter((s) => s.enabled && s.markdown).forEach((s) => md.use(s.markdown)); - - return md; - }); - - useSignalEffect(() => { - markdown.value; - updateHtmlChunks({ newMarkdown: text, force: true }); - }); - - const shiftLineMap = useCallback((/** @type {ViewUpdate} */ update) => { - if (update.startState.doc.lines === update.state.doc.lines) return; - let shiftStart = 0; - let shiftAmount = 0; - update.changes.iterChangedRanges((fromA, toA, fromB, toB) => { - const startLine = update.startState.doc.lineAt(fromA).number; - const endLine = update.startState.doc.lineAt(toA).number; - const startLineB = update.state.doc.lineAt(fromB).number; - const endLineB = update.state.doc.lineAt(toB).number; - - shiftStart = endLine; - if (startLine === endLine) { - shiftAmount = endLineB - startLineB; - } else { - shiftAmount = -(endLine - startLine); - } - }); - - const newMap = new Map(lineMap.current); - for (const [line, id] of lineMap.current.entries()) { - if (line < shiftStart) continue; - if (id === newMap.get(line)) { - newMap.delete(line); - } - newMap.set(line + shiftAmount, id); - } - lineMap.current = newMap; - }); - - /** Split and parse markdown into chunks of HTML. If `lookup` is not provided then every chunk will be parsed */ - const splitIntoChunks = useCallback( - (newMarkdown, lookup = {}) => - newMarkdown - .split(/(?=\n#{1,3} )/g) // Perform a split without removing the delimeter - .reduce( - // Check if a chunk has a non-closed code section. If yes - join this and the next chunk - (chunks, newChunk) => { - const lastChunkIdx = chunks.length - 1; - const lastChunk = chunks[lastChunkIdx]; - /** Markdown-it gets passed small chunks of `newMarkdown` so when we get the line number of a token, it is relative to that chunk. - * In order to get the line number relative to the whole document, we need to keep track of which line a chunk begins at.*/ - let startLine = 1; - if (lastChunk) { - if (lastChunkIdx == 0) startLine = lastChunk.startLine + lastChunk.md.split("\n").length; - else startLine = lastChunk.startLine + lastChunk.md.trimLeft().split("\n").length; - } - - const endLine = startLine + newChunk.trimLeft().split("\n").length - 1; - const fenceRegex = /^[`:~]{3}/gm; - if (countOccurences(lastChunk?.md, fenceRegex) % 2 != 0) { - chunks[lastChunkIdx] = { md: lastChunk.md + newChunk, startLine: lastChunk.startLine, endLine }; - } else { - chunks.push({ md: newChunk, startLine, endLine }); - } - return chunks; - }, - [], - ) - .map(({ md, startLine, endLine }, chunkId) => { - const hash = new IMurMurHash(md, 42).result(); - - // Clear source mappings for chunk we are rerendering - if (!lookup[hash]) { - for (let l = startLine; l <= endLine; l++) { - lineMap.current.delete(l); - } - } - - const html = - lookup[hash] || - purify.sanitize(markdown.value.render(md, { chunkId, startLine, lineMap, view: editorView.value, mapUrl: options.mapUrl.value }), { - // Taken from Mermaid JS settings: https://github.com/mermaid-js/mermaid/blob/dd0304387e85fc57a9ebb666f89ef788c012c2c5/packages/mermaid/src/mermaidAPI.ts#L50 - ADD_TAGS: ["foreignobject", "iframe"], - ADD_ATTR: ["dominant-baseline", "target"], - }); - return { md, hash, id: chunkId, html }; - }), - [markdown.value, options.id.value, options.mapUrl.value], - ); - - useEffect(() => readyToRender && updateHtmlChunks({ newMarkdown: text }), [readyToRender]); - useEffect(exposeText(text, options.id.value), [text]); - useEffect(() => { - if (syncText) { - onSync.action(text); - setSyncText(false); - } - }, [syncText]); - - useEffect(() => { - if (preview.current == null) return; - - const resizeObserver = new ResizeObserver(() => { - editorView.value?.dispatch?.({ - effects: markdownUpdatedStateEffect.of(null), - }); - }); - const mutationObserver = new MutationObserver((mutationList) => { - editorView.value?.dispatch?.({ - effects: markdownUpdatedStateEffect.of(null), - }); - for (const mutation of mutationList) { - if (mutation.type !== "childList") continue; - [...mutation.addedNodes].filter((n) => n.nodeName === "IMG").forEach((n) => resizeObserver.observe(n)); - [...mutation.removedNodes].filter((n) => n.nodeName === "IMG").forEach((n) => resizeObserver.unobserve(n)); - } - }); - mutationObserver.observe(preview.current, { childList: true, subtree: true }); - - return () => { - mutationObserver.disconnect(); - resizeObserver.disconnect(); - }; - }, [preview.current]); - - return { - set(newMarkdown, update) { - if (update) { - shiftLineMap(update); - } - setText(newMarkdown); - setTimeout(() => { - try { - updateHtmlChunks({ newMarkdown, view: update?.view }); - } catch (e) { - console.warn(e); - updateHtmlChunks({ newMarkdown, force: true, view: update?.view }); - } - }); - }, - get() { - return text; - }, - sync() { - setSyncText(true); - }, - refresh() { - updateHtmlChunks({ newMarkdown: window.myst_editor[options.id.value].text, force: true }); - }, - onSync(action) { - setOnSync({ action }); - }, - readyToRender() { - setReadyToRender(true); - }, - async copy() { - await copyHtmlAsRichText( - splitIntoChunks(window.myst_editor[options.id.value].text, {}, []) - .map((c) => c.html) - .join("\n"), - ); - }, - lineMap, - }; -}; diff --git a/src/hooks/markdownCheckboxes.js b/src/markdown/markdownCheckboxes.js similarity index 85% rename from src/hooks/markdownCheckboxes.js rename to src/markdown/markdownCheckboxes.js index b27e86f..686e8ad 100644 --- a/src/hooks/markdownCheckboxes.js +++ b/src/markdown/markdownCheckboxes.js @@ -3,13 +3,13 @@ import { EditorView } from "codemirror"; /** * @param {{ target: HTMLElement }} ev - * @param {{ current: Map<number, string> }} lineMap + * @param {Map<number, string>} lineMap * @param { EditorView } editor */ export function syncCheckboxes(ev, lineMap, editor) { if (ev.target.tagName != "INPUT") return; const id = ev.target.getAttribute("data-line-id"); - const lineNumber = getLineById(lineMap.current, id); + const lineNumber = getLineById(lineMap, id); const line = editor.state.doc.line(lineNumber); const openIdx = line.text.indexOf("["); const closeIdx = line.text.indexOf("]"); diff --git a/src/hooks/markdownDirectives.js b/src/markdown/markdownDirectives.js similarity index 100% rename from src/hooks/markdownDirectives.js rename to src/markdown/markdownDirectives.js diff --git a/src/hooks/markdownFence.js b/src/markdown/markdownFence.js similarity index 100% rename from src/hooks/markdownFence.js rename to src/markdown/markdownFence.js diff --git a/src/hooks/markdownLineBreak.js b/src/markdown/markdownLineBreak.js similarity index 100% rename from src/hooks/markdownLineBreak.js rename to src/markdown/markdownLineBreak.js diff --git a/src/markdown/markdownLinks.js b/src/markdown/markdownLinks.js new file mode 100644 index 0000000..7c869fa --- /dev/null +++ b/src/markdown/markdownLinks.js @@ -0,0 +1,20 @@ +import markdownIt from "markdown-it"; + +/** Extension to markdownIt which invalidates links starting with `(` */ +export function checkLinks(/** @type {markdownIt} */ md) { + const defaultRule = md.renderer.rules.link_open; + md.renderer.rules.link_open = (tokens, idx, options, env, self) => { + const render = defaultRule ?? self.renderToken.bind(self); + const href = tokens[idx].attrs?.find((a) => a[0] == "href")?.[1]; + if (href?.startsWith("(")) { + const linkLabel = tokens[idx + 1]; + const linkClose = tokens[idx + 2]; + linkLabel.content = `[${linkLabel.content}]`; + linkClose.type = "text"; + linkClose.content = `(${href})`; + return ""; + } + + return render(tokens, idx, options, env, self); + }; +} diff --git a/src/hooks/markdownMermaid.js b/src/markdown/markdownMermaid.js similarity index 96% rename from src/hooks/markdownMermaid.js rename to src/markdown/markdownMermaid.js index 2d318fa..7637c04 100644 --- a/src/hooks/markdownMermaid.js +++ b/src/markdown/markdownMermaid.js @@ -31,7 +31,7 @@ const markdownItMermaid = (md, { lineMap, parent }) => { } const code = token.content.trim(); - const lineNumber = getLineById(lineMap.current, token.attrGet("data-line-id")); + const lineNumber = getLineById(lineMap, token.attrGet("data-line-id")); let cached = lineCache.get(lineNumber); const hash = new IMurMurHash(code, hashSeed).result(); if (!cached || cached.hash !== hash) { diff --git a/src/hooks/markdownReplacer.js b/src/markdown/markdownReplacer.js similarity index 100% rename from src/hooks/markdownReplacer.js rename to src/markdown/markdownReplacer.js diff --git a/src/hooks/markdownSourceMap.js b/src/markdown/markdownSourceMap.js similarity index 97% rename from src/hooks/markdownSourceMap.js rename to src/markdown/markdownSourceMap.js index 0add0c3..ea49bc5 100644 --- a/src/hooks/markdownSourceMap.js +++ b/src/markdown/markdownSourceMap.js @@ -68,8 +68,8 @@ function addLineNumberToTokens(defaultRule) { } else if (tokens[idx].map) { const line = tokens[idx].map[0] + env.startLine - (env.chunkId !== 0); const id = randomLineId(); - if (!env.lineMap.current.has(line)) { - env.lineMap.current.set(line, id); + if (!env.lineMap.has(line)) { + env.lineMap.set(line, id); tokens[idx].attrSet(SRC_LINE_ID, id); } } @@ -153,7 +153,7 @@ function wrapFencedLinesInSpan(/** @type {markdownIt} */ md) { .filter((_, i, lines) => i !== lines.length - 1) .map((l, i) => { const id = randomLineId(); - env.lineMap.current.set(startLine + i + 1, id); + env.lineMap.set(startLine + i + 1, id); return `<span ${SRC_LINE_ID}="${id}">${l}</span>`; }) .join("\n"); @@ -167,7 +167,7 @@ export function findNearestElementForLine(lineNumber, lineMap, preview) { let match = null; let num = lineNumber; for (; num >= 1; num--) { - id = lineMap.current.get(num); + id = lineMap.get(num); if (id) { match = preview.querySelector(`[data-line-id="${id}"]`); if (match) break; diff --git a/src/hooks/markdownUrlMapping.js b/src/markdown/markdownUrlMapping.js similarity index 62% rename from src/hooks/markdownUrlMapping.js rename to src/markdown/markdownUrlMapping.js index e5a0715..6799727 100644 --- a/src/hooks/markdownUrlMapping.js +++ b/src/markdown/markdownUrlMapping.js @@ -9,13 +9,11 @@ const mapToken = (token, mapFunc) => { } }; -export function markdownItMapUrls(/** @type {markdownIt} */ md) { +export function markdownItMapUrls(/** @type {markdownIt} */ md, mapUrl) { md.core.ruler.after("inline", "map_urls", (state) => { - if (state.env.mapUrl == undefined) return; - for (const token of state.tokens) { - mapToken(token, state.env.mapUrl); - token.children?.forEach?.((c) => mapToken(c, state.env.mapUrl)); + mapToken(token, mapUrl); + token.children?.forEach?.((c) => mapToken(c, mapUrl)); } }); } diff --git a/src/mystState.js b/src/mystState.js index 0857ef9..66e5788 100644 --- a/src/mystState.js +++ b/src/mystState.js @@ -9,6 +9,7 @@ import Settings from "./components/Settings"; import { CollaborationClient } from "./collaboration"; import { CodeMirror as VimCM, vim } from "@replit/codemirror-vim"; import { collabClientFacet } from "./extensions"; +import { TextManager } from "./text"; export const predefinedButtons = { printToPdf: { @@ -172,7 +173,7 @@ export function createMystState(/** @type {typeof defaults} */ opts) { return () => client.destroy(); }); - return { + const state = { options: signalOptions, /** @type {Signal<EditorView?>} */ editorView: signal(null), @@ -183,7 +184,11 @@ export function createMystState(/** @type {typeof defaults} */ opts) { collab, cleanups: [collabCleanup], linter: signal({ status: "disabled", diagnostics: [] }), + /** @type {TextManager} */ + text: null, }; + state.text = new TextManager({ ...signalOptions, ...state }); + return state; } /** @type {import("preact").Context<ReturnType<createMystState>>} */ diff --git a/src/text.js b/src/text.js new file mode 100644 index 0000000..6986ade --- /dev/null +++ b/src/text.js @@ -0,0 +1,201 @@ +import { computed, effect, signal } from "@preact/signals"; +import markdownIt from "markdown-it"; +import markdownitDocutils, { directivesDefault } from "markdown-it-docutils"; +import newDirectives from "./markdown/markdownDirectives"; +import { markdownReplacer, useCustomDirectives, useCustomRoles } from "./markdown/markdownReplacer"; +import markdownMermaid from "./markdown/markdownMermaid"; +import markdownSourceMap from "./markdown/markdownSourceMap"; +import { checkLinks } from "./markdown/markdownLinks"; +import { colonFencedBlocks } from "./markdown/markdownFence"; +import { markdownItMapUrls } from "./markdown/markdownUrlMapping"; +import markdownCheckboxes from "markdown-it-checkbox"; +import { backslashLineBreakPlugin } from "./markdown/markdownLineBreak"; +import IMurMurHash from "imurmurhash"; +import purify from "dompurify"; +import { StateEffect } from "@codemirror/state"; +import hljs from "highlight.js/lib/core"; +import yamlHighlight from "highlight.js/lib/languages/yaml"; + +export const markdownUpdatedEffect = StateEffect.define(); + +hljs.registerLanguage("yaml", yamlHighlight); + +/** This class stores the document text and renders the Markdown in the Preview */ +export class TextManager { + constructor({ initialText = "", editorView, cache, options, userSettings }) { + this.text = signal(initialText); + this.lineMap = new Map(); + this.chunks = []; + this.editorView = editorView; + this.preview = signal(null); + this.md = computed(() => { + const md = markdownIt({ + breaks: true, + linkify: true, + html: true, + highlight: (str, lang) => { + if (lang && hljs.getLanguage(lang)) { + try { + const v = hljs.highlight(str, { language: lang }).value; + return v; + } catch (err) { + console.error(`Error while highlighting ${lang}: ${err}`); + } + return md.utils.escapeHtml(str); + } + }, + }) + .use(markdownitDocutils, { directives: { ...directivesDefault, ...newDirectives } }) + .use(markdownReplacer(options.transforms.value, options.parent, cache.transform)) + .use(useCustomRoles(options.customRoles.value, options.parent, cache.transform)) + .use(useCustomDirectives(options.customDirectives.value, options.parent, cache.transform)) + .use(markdownMermaid, { lineMap: this.lineMap, parent: options.parent }) + .use(markdownSourceMap) + .use(checkLinks) + .use(colonFencedBlocks) + .use(markdownItMapUrls, options.mapUrl.value) + .use(markdownCheckboxes); + if (options.backslashLineBreak.value) md.use(backslashLineBreakPlugin); + userSettings.value.filter((s) => s.enabled && s.markdown).forEach((s) => md.use(s.markdown)); + + return md; + }); + effect(() => this.renderText()); + effect(() => (window.myst_editor[options.id.value].text = this.text.value)); + effect(() => this.observePreview()); + } + + renderText(useCache = true) { + if (!this.preview.value || !this.editorView.value) return; + const cache = !this.lastMd || this.lastMd == this.md.value ? useCache : false; + const newChunks = this.splitTextIntoChunks(cache); + + if (this.chunks.length != newChunks.length || !cache) { + // Render all chunks + const toRemove = [...this.preview.value.childNodes].filter((c) => !c.classList || !c.classList.contains("cm-previewFocus")); + toRemove.forEach((c) => this.preview.value.removeChild(c)); + this.preview.value.innerHTML += newChunks.map((c) => `<html-chunk id="html-chunk-${c.id}">${c.html}</html-chunk>`).join(""); + } else { + newChunks + .filter((newChunk, idx) => newChunk.hash !== this.chunks[idx].hash) + .forEach((chunk) => (this.preview.value.querySelector(`html-chunk#html-chunk-${chunk.id}`).innerHTML = chunk.html)); + } + + this.chunks = newChunks; + this.lastMd = this.md.value; + } + + observePreview() { + if (!this.preview.value) return; + const imageObserver = new ResizeObserver(() => this.editorView.value.dispatch({ effects: markdownUpdatedEffect.of(true) })); + const observer = new MutationObserver(() => { + this.editorView.value.dispatch({ effects: markdownUpdatedEffect.of(true) }); + this.preview.value.querySelectorAll("img").forEach((i) => imageObserver.observe(i)); + }); + observer.observe(this.preview.value, { childList: true, subtree: true }); + + return () => { + imageObserver.disconnect(); + observer.disconnect(); + }; + } + + splitTextIntoChunks(cache = true) { + const chunkLookup = cache ? this.chunks.reduce((lookup, chunk) => ({ ...lookup, [chunk.hash]: chunk.html }), {}) : {}; + + return this.text.value + .split(/(?=\n#{1,3} )/g) + .reduce((chunks, textChunk) => { + const lastChunkIdx = chunks.length - 1; + const lastChunk = chunks[lastChunkIdx]; + + let startLine = 1; + if (lastChunk) { + if (lastChunkIdx == 0) startLine = lastChunk.startLine + lastChunk.text.split("\n").length; + else startLine = lastChunk.startLine + lastChunk.text.trimLeft().split("\n").length; + } + const endLine = startLine + textChunk.trimStart().split("\n").length - 1; + + const fenceRegex = /^[`:~]{3}/gm; + if (countOccurences(lastChunk?.text, fenceRegex) % 2 != 0) { + chunks[lastChunkIdx] = { text: lastChunk.text + textChunk, startLine: lastChunk.startLine, endLine }; + } else { + chunks.push({ text: textChunk, startLine, endLine }); + } + return chunks; + }, []) + .map(({ text, startLine, endLine }, chunkId) => { + const hash = new IMurMurHash(text, 42).result(); + + // Clear source mappings for chunk we are rerendering + if (!(hash in chunkLookup)) { + for (let l = startLine; l <= endLine; l++) { + this.lineMap.delete(l); + } + } + + const html = + chunkLookup[hash] || + purify.sanitize(this.md.value.render(text, { chunkId, startLine, lineMap: this.lineMap, view: this.editorView.value }), { + // Taken from Mermaid JS settings: https://github.com/mermaid-js/mermaid/blob/dd0304387e85fc57a9ebb666f89ef788c012c2c5/packages/mermaid/src/mermaidAPI.ts#L50 + ADD_TAGS: ["foreignobject", "iframe"], + ADD_ATTR: ["dominant-baseline", "target"], + }); + return { text, hash, id: chunkId, html }; + }); + } + + shiftLineMap(update) { + if (update.startState.doc.lines === update.state.doc.lines) return; + let shiftStart = 0; + let shiftAmount = 0; + update.changes.iterChangedRanges((fromA, toA, fromB, toB) => { + const startLine = update.startState.doc.lineAt(fromA).number; + const endLine = update.startState.doc.lineAt(toA).number; + const startLineB = update.state.doc.lineAt(fromB).number; + const endLineB = update.state.doc.lineAt(toB).number; + + shiftStart = endLine; + if (startLine === endLine) { + shiftAmount = endLineB - startLineB; + } else { + shiftAmount = -(endLine - startLine); + } + }); + + const newMap = new Map(this.lineMap); + for (const [line, id] of this.lineMap.entries()) { + if (line < shiftStart) continue; + if (id === newMap.get(line)) { + newMap.delete(line); + } + newMap.set(line + shiftAmount, id); + } + this.lineMap = newMap; + } + + async copy() { + const html = this.chunks.map((c) => c.html).join("\n"); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + doc.querySelectorAll("[data-line-id]").forEach((n) => n.removeAttribute("data-line-id")); + // This removes spans added for source mapping purposes. + doc.querySelectorAll("span").forEach((n) => { + if (n.attributes.length === 0) { + n.insertAdjacentHTML("afterend", n.innerHTML); + n.remove(); + } + }); + doc.querySelectorAll("[data-remove]").forEach((n) => n.remove()); + const sanitized = doc.body.innerHTML; + + await navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": new Blob([sanitized], { type: "text/plain" }), + "text/html": new Blob([sanitized], { type: "text/html" }), + }), + ]); + } +} + +const countOccurences = (str, pattern) => (str?.match(pattern) || []).length;