From 06b28776809b9265433a0361d7cb8796f83b88ff Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 31 May 2024 15:17:11 -0700 Subject: [PATCH 1/2] no wrapper span --- package.json | 2 +- src/client/main.js | 82 +++++++++++++++---- src/client/preview.js | 63 ++++++++++---- src/html.ts | 46 ++++++++++- src/markdown.ts | 16 ++-- src/preview.ts | 29 +++++-- src/style/inspector.css | 6 +- test/output/block-expression.html | 2 +- test/output/build/archives.posix/tar.html | 14 ++-- test/output/build/archives.posix/zip.html | 10 +-- test/output/build/archives.win32/tar.html | 6 +- test/output/build/archives.win32/zip.html | 4 +- test/output/build/fetches/foo.html | 2 +- test/output/build/fetches/top.html | 2 +- test/output/build/files/files.html | 8 +- .../build/files/subsection/subfiles.html | 4 +- test/output/build/imports/foo/foo.html | 4 +- test/output/build/missing-file/index.html | 2 +- test/output/build/missing-import/index.html | 2 +- test/output/build/multi/index.html | 6 +- test/output/build/simple/simple.html | 4 +- test/output/dollar-expression.html | 2 +- test/output/dot-graphviz.html | 2 +- test/output/double-quote-expression.html | 2 +- test/output/embedded-expression.html | 2 +- test/output/fenced-code-options.html | 8 +- test/output/fenced-code.html | 2 +- test/output/fetch-parent-dir.html | 4 +- test/output/heading-expression.html | 2 +- test/output/inline-expression.html | 2 +- test/output/local-fetch.html | 2 +- test/output/mermaid.html | 2 +- test/output/single-quote-expression.html | 2 +- test/output/template-expression.html | 2 +- test/output/tex-block.html | 2 +- test/output/tex-expression.html | 4 +- 36 files changed, 249 insertions(+), 105 deletions(-) diff --git a/package.json b/package.json index df404282c..3994ac21b 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "d3-hierarchy": "^3.1.2", "esbuild": "^0.20.1", "fast-array-diff": "^1.1.0", + "fast-deep-equal": "^3.1.3", "gray-matter": "^4.0.3", "he": "^1.2.0", "highlight.js": "^11.8.0", @@ -118,7 +119,6 @@ "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.0", - "fast-deep-equal": "^3.1.3", "glob": "^10.3.10", "mocha": "^10.2.0", "prettier": "^3.0.3 <3.1", diff --git a/src/client/main.js b/src/client/main.js index 370e4de02..dd05d4e5c 100644 --- a/src/client/main.js +++ b/src/client/main.js @@ -20,17 +20,19 @@ export const runtime = new Runtime(library); export const main = runtime.module(); const cellsById = new Map(); +const rootsById = findRoots(document.body); export function define(cell) { const {id, inline, inputs = [], outputs = [], body} = cell; const variables = []; - cellsById.get(id)?.variables.forEach((v) => v.delete()); cellsById.set(id, {cell, variables}); - const root = document.querySelector(`#cell-${id}`); - const loading = root.querySelector(".observablehq-loading"); + const root = rootsById.get(id); + const loading = findLoading(root); + root._nodes = []; + if (loading) root._nodes.push(loading); const pending = () => reset(root, loading); const rejected = (error) => reject(root, error); - const v = main.variable({_node: root, pending, rejected}, {shadow: {}}); // _node for visibility promise + const v = main.variable({_node: root.parentNode, pending, rejected}, {shadow: {}}); // _node for visibility promise if (inputs.includes("display") || inputs.includes("view")) { let displayVersion = -1; // the variable._version of currently-displayed values const display = inline ? displayInline : displayBlock; @@ -67,35 +69,57 @@ export function define(cell) { // original loading indicator in this case, which applies to inline expressions // and expression code blocks. function reset(root, loading) { - if (root.classList.contains("observablehq--error")) { - root.classList.remove("observablehq--error"); + if (root._error) { + root._error = false; clear(root); - if (loading) root.append(loading); + if (loading) displayNode(root, loading); } } function reject(root, error) { console.error(error); - root.classList.add("observablehq--error"); // see reset + root._error = true; // see reset clear(root); - root.append(inspectError(error)); + displayNode(root, inspectError(error)); +} + +function displayNode(root, node) { + if (node.nodeType === 11) { + let child; + while ((child = node.firstChild)) { + root._nodes.push(child); + root.parentNode.insertBefore(child, root); + } + } else { + root._nodes.push(node); + root.parentNode.insertBefore(node, root); + } } function clear(root) { - root.textContent = ""; + for (const v of root._nodes) v.remove(); + root._nodes.length = 0; } function displayInline(root, value) { - if (isNode(value) || typeof value === "string" || !value?.[Symbol.iterator]) root.append(value); - else root.append(...value); + if (isNode(value)) { + displayNode(root, value); + } else if (typeof value === "string" || !value?.[Symbol.iterator]) { + displayNode(root, document.createTextNode(value)); + } else { + for (const v of value) { + displayNode(root, isNode(v) ? v : document.createTextNode(v)); + } + } } function displayBlock(root, value) { - root.append(isNode(value) ? value : inspect(value)); + displayNode(root, isNode(value) ? value : inspect(value)); } export function undefine(id) { - cellsById.get(id)?.variables.forEach((v) => v.delete()); + clear(rootsById.get(id)); + cellsById.get(id).variables.forEach((v) => v.delete()); cellsById.delete(id); } @@ -103,3 +127,33 @@ export function undefine(id) { function isNode(value) { return value instanceof Node && value instanceof value.constructor; } + +export function findRoots(root) { + const roots = new Map(); + const iterator = document.createNodeIterator(root, 128, null); + let node; + while ((node = iterator.nextNode())) { + if (isRoot(node)) { + roots.set(node.data.slice(1, -1), node); + } + } + return roots; +} + +function isRoot(node) { + return node.nodeType === 8 && /^:[0-9a-f]{8}(?:-\d+)?:$/.test(node.data); +} + +function isLoading(node) { + return node.nodeType === 1 && node.tagName === "O-LOADING"; +} + +export function findLoading(root) { + const sibling = root.previousSibling; + return sibling && isLoading(sibling) ? sibling : null; +} + +export function registerRoot(id, node) { + if (node == null) rootsById.delete(id); + else rootsById.set(id, node); +} diff --git a/src/client/preview.js b/src/client/preview.js index c689fa408..9e94f90f5 100644 --- a/src/client/preview.js +++ b/src/client/preview.js @@ -1,5 +1,6 @@ import {FileAttachment, registerFile} from "npm:@observablehq/stdlib"; import {main, runtime, undefine} from "./main.js"; +import {findLoading, findRoots, registerRoot} from "./main.js"; import {enableCopyButtons} from "./pre.js"; export * from "./index.js"; @@ -48,12 +49,30 @@ export function open({hash, eval: compile} = {}) { case "add": { for (const item of items) { const pos = oldPos + offset; - if (pos < root.children.length) { - root.children[pos].insertAdjacentHTML("beforebegin", item); + if (pos < root.childNodes.length) { + const child = root.childNodes[pos]; + if (item.type === 1) { + if (child.nodeType === 1) { + child.insertAdjacentHTML("beforebegin", item.value); + } else { + root.insertAdjacentHTML("beforeend", item.value); + root.insertBefore(root.lastChild, child); + } + } else if (item.type === 3) { + root.insertBefore(document.createTextNode(item.value), child); + } else if (item.type === 8) { + root.insertBefore(document.createComment(item.value), child); + } } else { - root.insertAdjacentHTML("beforeend", item); + if (item.type === 1) { + root.insertAdjacentHTML("beforeend", item.value); + } else if (item.type === 3) { + root.appendChild(document.createTextNode(item.value)); + } else if (item.type === 8) { + root.appendChild(document.createComment(item.value)); + } } - indexCells(addedCells, root.children[pos]); + indexCells(addedCells, root.childNodes[pos]); ++offset; } break; @@ -62,13 +81,13 @@ export function open({hash, eval: compile} = {}) { let removes = 0; for (let i = 0; i < items.length; ++i) { const pos = oldPos + offset; - if (pos < root.children.length) { - const child = root.children[pos]; + if (pos < root.childNodes.length) { + const child = root.childNodes[pos]; indexCells(removedCells, child); child.remove(); ++removes; } else { - console.error(`remove out of range: ${pos} ≮ ${root.children.length}`); + console.error(`remove out of range: ${pos} ≮ ${root.childNodes.length}`); } } offset -= removes; @@ -76,12 +95,21 @@ export function open({hash, eval: compile} = {}) { } } } - for (const [id, removed] of removedCells) { - addedCells.get(id)?.replaceWith(removed); - } for (const id of message.code.removed) { undefine(id); } + for (const [id, removed] of removedCells) { + if (!addedCells.has(id)) { + registerRoot(id, null); + } else { + replaceRoot(addedCells.get(id), removed); + } + } + for (const [id, root] of addedCells) { + if (!removedCells.has(id)) { + registerRoot(id, root); + } + } for (const body of message.code.added) { compile(body); } @@ -138,11 +166,8 @@ export function open({hash, eval: compile} = {}) { }; function indexCells(map, node) { - if (node.id.startsWith("cell-")) { - map.set(node.id, node); - } - for (const cell of node.querySelectorAll("[id^=cell-]")) { - map.set(cell.id, cell); + for (const [id, root] of findRoots(node)) { + map.set(id, root); } } @@ -151,3 +176,11 @@ export function open({hash, eval: compile} = {}) { socket.send(JSON.stringify(message)); } } + +export function replaceRoot(added, removed) { + findLoading(added)?.remove(); + added.replaceWith(removed); + for (const n of removed._nodes) { + removed.parentNode.insertBefore(n, removed); + } +} diff --git a/src/html.ts b/src/html.ts index 0a9d7a43d..cf5720ab9 100644 --- a/src/html.ts +++ b/src/html.ts @@ -169,6 +169,8 @@ export function rewriteHtml( ? hljs.highlight(child.textContent!, {language}).value : isElement(child) ? child.outerHTML + : isComment(child) + ? `` : ""; } code.innerHTML = html; @@ -183,6 +185,29 @@ export function rewriteHtml( h.append(a); } + // For incremental update during preview, we need to know the direct children + // of the body statically; therefore we must wrap any top-level cells with a + // span to avoid polluting the direct children with dynamic content. + for (let child = document.body.firstChild; child; child = child.nextSibling) { + if (isRoot(child)) { + const parent = document.createElement("span"); + const loading = findLoading(child); + child.replaceWith(parent); + if (loading) parent.appendChild(loading); + parent.appendChild(child); + child = parent; + } + } + + // In some contexts, such as a table, the element may be + // reparented; enforce the requirement that the element + // immediately precedes its root by removing any violating elements. + for (const l of document.querySelectorAll("o-loading")) { + if (!l.nextSibling || !isRoot(l.nextSibling)) { + l.remove(); + } + } + return document.body.innerHTML; } @@ -208,14 +233,31 @@ function resolveSrcset(srcset: string, resolve: (specifier: string) => string): .join(", "); } -function isText(node: Node): node is Text { +export function isText(node: Node): node is Text { return node.nodeType === 3; } -function isElement(node: Node): node is Element { +export function isComment(node: Node): node is Comment { + return node.nodeType === 8; +} + +export function isElement(node: Node): node is Element { return node.nodeType === 1; } +function isRoot(node: Node): node is Comment { + return isComment(node) && /^:[0-9a-f]{8}(?:-\d+)?:$/.test(node.data); +} + +function isLoading(node: Node): node is Element { + return isElement(node) && node.tagName === "O-LOADING"; +} + +function findLoading(node: Node): Element | null { + const sibling = node.previousSibling; + return sibling && isLoading(sibling) ? sibling : null; +} + /** * Denotes a string that contains HTML source; when interpolated into an html * tagged template literal, it will not be escaped. Use Html.unsafe to denote diff --git a/src/markdown.ts b/src/markdown.ts index 8cd136bc0..e5d992e45 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -106,9 +106,9 @@ function makeFenceRenderer(baseRenderer: RenderRule): RenderRule { // TODO const sourceLine = context.startLine + context.currentLine; const node = parseJavaScript(source, {path}); context.code.push({id, node}); - html += `
${ - node.expression ? '' : "" - }
\n`; + html += `
${ + node.expression ? "" : "" + }
\n`; } } catch (error) { if (!(error instanceof SyntaxError)) throw error; @@ -261,14 +261,12 @@ function makePlaceholderRenderer(): RenderRule { // TODO sourceLine: context.startLine + context.currentLine const node = parseJavaScript(token.content, {path, inline: true}); context.code.push({id, node}); - return ``; + return ``; } catch (error) { if (!(error instanceof SyntaxError)) throw error; - return ` - SyntaxError: ${he.escape( - error.message - )} -`; + return `SyntaxError: ${he.escape( + error.message + )}`; } }; } diff --git a/src/preview.ts b/src/preview.ts index 428869d8f..092454bed 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -8,6 +8,7 @@ import {basename, dirname, join, normalize} from "node:path/posix"; import {difference} from "d3-array"; import type {PatchItem} from "fast-array-diff"; import {getPatch} from "fast-array-diff"; +import deepEqual from "fast-deep-equal"; import mime from "mime"; import openBrowser from "open"; import send from "send"; @@ -19,7 +20,7 @@ import type {LoaderResolver} from "./dataloader.js"; import {HttpError, isEnoent, isHttpError, isSystemError} from "./error.js"; import {getClientPath} from "./files.js"; import type {FileWatchers} from "./fileWatchers.js"; -import {parseHtml, rewriteHtml} from "./html.js"; +import {isComment, isElement, isText, parseHtml, rewriteHtml} from "./html.js"; import {transpileJavaScript, transpileModule} from "./javascript/transpile.js"; import {parseMarkdown} from "./markdown.js"; import type {MarkdownCode, MarkdownPage} from "./markdown.js"; @@ -285,11 +286,16 @@ function getWatchFiles(resolvers: Resolvers): Iterable { return files; } +interface HtmlPart { + type: number; + value: string; +} + function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Promise) { let config: Config | null = null; let path: string | null = null; let hash: string | null = null; - let html: string[] | null = null; + let html: HtmlPart[] | null = null; let code: Map | null = null; let files: Map | null = null; let tables: Map | null = null; @@ -429,8 +435,19 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, configPromise: Pro } } -function getHtml({body}: MarkdownPage, resolvers: Resolvers): string[] { - return Array.from(parseHtml(rewriteHtml(body, resolvers)).document.body.children, (d) => d.outerHTML); +function serializeHtml(node: ChildNode): HtmlPart | undefined { + return isElement(node) + ? {type: 1, value: node.outerHTML} + : isText(node) + ? {type: 3, value: node.nodeValue!} + : isComment(node) + ? {type: 8, value: node.data} + : undefined; +} + +function getHtml({body}: MarkdownPage, resolvers: Resolvers): HtmlPart[] { + const {document} = parseHtml(`\n${rewriteHtml(body, resolvers)}`); + return Array.from(document.body.childNodes, serializeHtml).filter((d): d is HtmlPart => d != null); } function getCode({code}: MarkdownPage, resolvers: Resolvers): Map { @@ -530,8 +547,8 @@ function diffTables( return patch; } -function diffHtml(oldHtml: string[], newHtml: string[]): RedactedPatch { - return getPatch(oldHtml, newHtml).map(redactPatch); +function diffHtml(oldHtml: HtmlPart[], newHtml: HtmlPart[]): RedactedPatch { + return getPatch(oldHtml, newHtml, deepEqual).map(redactPatch); } type RedactedPatch = RedactedPatchItem[]; diff --git a/src/style/inspector.css b/src/style/inspector.css index b28afb0e2..0826bd586 100644 --- a/src/style/inspector.css +++ b/src/style/inspector.css @@ -11,7 +11,7 @@ } } -.observablehq-loading { +o-loading { font: var(--monospace-font); color: var(--theme-foreground-muted); display: inline-block; @@ -22,11 +22,11 @@ animation-iteration-count: infinite; } -.observablehq-loading::before { +o-loading::before { content: "↻"; } -.observablehq--block .observablehq-loading { +.observablehq--block o-loading { display: block; } diff --git a/test/output/block-expression.html b/test/output/block-expression.html index 54f66acd7..3665956f7 100644 --- a/test/output/block-expression.html +++ b/test/output/block-expression.html @@ -1 +1 @@ -
+
diff --git a/test/output/build/archives.posix/tar.html b/test/output/build/archives.posix/tar.html index 1e6eefd35..ad9ec7e5a 100644 --- a/test/output/build/archives.posix/tar.html +++ b/test/output/build/archives.posix/tar.html @@ -86,13 +86,13 @@

Tar

-
-
-
-
-
-
-
+
+
+
+
+
+
+