smartrte-core 1.0.0-beta.1 → 1.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/foundation/atom/formats.d.ts +5 -2
- package/dist/foundation/atom/formats.js +71 -5
- package/dist/foundation/atom/schema.js +29 -0
- package/dist/foundation/list/formats.d.ts +1 -0
- package/dist/foundation/list/formats.js +71 -8
- package/dist/foundation/modelDom.js +18 -0
- package/dist/foundation/surface/renderer.js +56 -0
- package/dist/foundation/table/formats.d.ts +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.3
|
|
4
|
+
|
|
5
|
+
- Add `href`/`target` (a clickable link), `borderRadius` (corner rounding), and license metadata fields (`licenseDescription`, `licenseSourceUrl`, `licenseType`, `licenseVersion`, `licenseAttribution`) to the image atom schema. `href` is validated the same way as the existing `link` mark; exported HTML wraps a linked image in a real `<a>` for portability outside the editor. Fix a pre-existing renderer gap where clearing an image's `align` back to unset never removed the CSS float/display/margin it had previously applied.
|
|
6
|
+
|
|
7
|
+
## 1.0.0-beta.2
|
|
8
|
+
|
|
9
|
+
- Add an opt-in `renderFormulaHtml` option to `serializeCanonicalListHtml` that bakes real KaTeX-rendered HTML into exported formula elements, instead of leaving an empty placeholder — for consumers (e.g. a read-only preview) that display this HTML directly without also running KaTeX against it themselves. Off by default; no change for existing consumers.
|
|
10
|
+
|
|
3
11
|
## 1.0.0-beta.1
|
|
4
12
|
|
|
5
13
|
**BREAKING CHANGE.** This is the first release built on the canonical, schema-driven document model. Every published version through 0.2.1 was built on the older discriminated-union model; none of the work below has shipped before.
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import "katex/contrib/mhchem";
|
|
1
2
|
import type { SmartElementNode } from "../types.js";
|
|
2
|
-
export declare const atomToHtml: (node: SmartElementNode
|
|
3
|
-
|
|
3
|
+
export declare const atomToHtml: (node: SmartElementNode, options?: {
|
|
4
|
+
renderFormulaHtml?: boolean;
|
|
5
|
+
}) => string;
|
|
6
|
+
export declare const atomFromHtmlElement: (rawElement: Element) => SmartElementNode | null;
|
|
4
7
|
export declare const atomToMarkdown: (node: SmartElementNode) => string;
|
|
5
8
|
export interface AtomDocxRun {
|
|
6
9
|
readonly kind: "image" | "text";
|
|
@@ -1,16 +1,61 @@
|
|
|
1
|
+
import katex from "katex";
|
|
2
|
+
// Registers \ce{...} chemistry-equation notation on the shared katex module
|
|
3
|
+
// instance, matching surface/renderer.ts's own live-render import - a
|
|
4
|
+
// formula baked here via renderFormulaHtml must support the exact same
|
|
5
|
+
// notation the live editor does, or a chemistry entry would render live but
|
|
6
|
+
// fall back to plain text in the exported/baked HTML.
|
|
7
|
+
import "katex/contrib/mhchem";
|
|
1
8
|
import { createNodeId } from "../identity.js";
|
|
9
|
+
import { sanitizeLinkHref, sanitizeLinkTarget } from "../security/urlPolicy.js";
|
|
2
10
|
import { sanitizeAtomSource } from "./security.js";
|
|
3
11
|
const escape = (value) => String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4
12
|
const attr = (name, value) => value === undefined ? "" : ` ${name}="${escape(value)}"`;
|
|
5
13
|
const dimensions = (node) => `${attr("width", node.attrs?.width)}${attr("height", node.attrs?.height)}`;
|
|
6
|
-
|
|
14
|
+
// Mirrors surface/renderer.ts's KATEX_OPTIONS and try/catch-to-plain-text
|
|
15
|
+
// fallback exactly - a formula that fails to render live must fail the same
|
|
16
|
+
// way when baked into exported HTML, not throw and abort the whole export.
|
|
17
|
+
const KATEX_OPTIONS = { trust: false, strict: "error" };
|
|
18
|
+
const renderFormulaToHtml = (source) => {
|
|
19
|
+
try {
|
|
20
|
+
return katex.renderToString(source, KATEX_OPTIONS);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return escape(source);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
export const atomToHtml = (node, options) => {
|
|
7
27
|
if (node.type === "image" || node.type === "block_image") {
|
|
8
28
|
const src = sanitizeAtomSource(String(node.attrs?.src || ""), { kind: "image", allowBlobPreview: node.attrs?.status === "pending" }) || "";
|
|
9
|
-
|
|
29
|
+
// Corner radius and license fields are data-attributes only (round-trip
|
|
30
|
+
// fidelity), matching this same function's existing data-smart-align -
|
|
31
|
+
// a raw HTML consumer displaying this string directly without also
|
|
32
|
+
// running the live surface renderer's own style logic against it
|
|
33
|
+
// wouldn't see visual rounding either way, same as align today; that's
|
|
34
|
+
// a deliberate, pre-existing scope boundary this doesn't change.
|
|
35
|
+
const img = `<img data-smart-id="${escape(node.id)}" data-smart-type="${node.type}" src="${escape(src)}" alt="${escape(node.attrs?.alt)}"${dimensions(node)}${attr("data-smart-status", node.attrs?.status || "ready")}${node.attrs?.decorative === true ? ' data-smart-decorative="true"' : ""}${attr("data-smart-align", node.attrs?.align)}${attr("data-smart-radius", node.attrs?.borderRadius)}${attr("data-smart-license-description", node.attrs?.licenseDescription)}${attr("data-smart-license-source-url", node.attrs?.licenseSourceUrl)}${attr("data-smart-license-type", node.attrs?.licenseType)}${attr("data-smart-license-version", node.attrs?.licenseVersion)}${attr("data-smart-license-attribution", node.attrs?.licenseAttribution)}${attr("data-smart-href", node.attrs?.href)}${attr("data-smart-target", node.attrs?.target)}>`;
|
|
36
|
+
// A real <a> wrapper, unlike the live surface renderer's plain data
|
|
37
|
+
// attributes on the bare <img> - see surface/renderer.ts's own
|
|
38
|
+
// reasoning (stable-element-per-id constraint that doesn't apply to
|
|
39
|
+
// this string-based export) and modelDom.ts's identical choice for the
|
|
40
|
+
// one-shot clipboard/print DOM builder.
|
|
41
|
+
const linkHref = sanitizeLinkHref(typeof node.attrs?.href === "string" ? node.attrs.href : undefined);
|
|
42
|
+
if (!linkHref)
|
|
43
|
+
return img;
|
|
44
|
+
const target = sanitizeLinkTarget(typeof node.attrs?.target === "string" ? node.attrs.target : undefined);
|
|
45
|
+
return `<a href="${escape(linkHref)}"${target ? ` target="${escape(target)}"` : ""}>${img}</a>`;
|
|
10
46
|
}
|
|
11
47
|
if (node.type === "formula" || node.type === "block_formula") {
|
|
12
48
|
const tag = node.type === "formula" ? "span" : "div";
|
|
13
|
-
|
|
49
|
+
const source = String(node.attrs?.source || "");
|
|
50
|
+
// The rendered KaTeX HTML is purely a self-contained visual convenience
|
|
51
|
+
// for consumers that display this HTML string directly without also
|
|
52
|
+
// running katex.render() against it themselves (docs/bugs/
|
|
53
|
+
// formula-not-rendered-in-static-html-consumers.md) - data-smart-formula
|
|
54
|
+
// stays the single source of truth for round-tripping back into the
|
|
55
|
+
// model (list/formats.ts's parser reads the attribute, never this
|
|
56
|
+
// inner content), so baking this in can never desync source vs. render.
|
|
57
|
+
const inner = options?.renderFormulaHtml ? renderFormulaToHtml(source) : "";
|
|
58
|
+
return `<${tag} data-smart-id="${escape(node.id)}" data-smart-type="${node.type}" data-smart-notation="${escape(node.attrs?.notation || "latex")}" data-smart-formula="${escape(source)}" role="math" aria-label="${escape(`Mathematical formula: ${source}`)}">${inner}</${tag}>`;
|
|
14
59
|
}
|
|
15
60
|
if (node.type === "video" || node.type === "audio") {
|
|
16
61
|
const src = sanitizeAtomSource(String(node.attrs?.src || ""), { kind: node.type }) || "";
|
|
@@ -20,7 +65,14 @@ export const atomToHtml = (node) => {
|
|
|
20
65
|
return `<hr data-smart-id="${escape(node.id)}" data-smart-type="divider">`;
|
|
21
66
|
throw new Error(`Unsupported atom type "${node.type}".`);
|
|
22
67
|
};
|
|
23
|
-
export const atomFromHtmlElement = (
|
|
68
|
+
export const atomFromHtmlElement = (rawElement) => {
|
|
69
|
+
// atomToHtml's own inverse - a linked image now wraps in a real <a>
|
|
70
|
+
// (needed so the link survives outside a live editor at all, see
|
|
71
|
+
// atomToHtml's own doc comment); unwrap to the actual atom element
|
|
72
|
+
// (which still carries every attribute, including data-smart-href,
|
|
73
|
+
// directly) before parsing, matching what a single element's own
|
|
74
|
+
// attributes already fully describe.
|
|
75
|
+
const element = rawElement.tagName === "A" && rawElement.children.length === 1 ? rawElement.children[0] : rawElement;
|
|
24
76
|
const declared = element.getAttribute("data-smart-type");
|
|
25
77
|
const type = declared || (element.tagName === "IMG" ? "image" : element.tagName.toLowerCase());
|
|
26
78
|
const id = element.getAttribute("data-smart-id") || createNodeId();
|
|
@@ -29,7 +81,21 @@ export const atomFromHtmlElement = (element) => {
|
|
|
29
81
|
const src = sanitizeAtomSource(element.getAttribute("src"), { kind: "image" });
|
|
30
82
|
if (!src)
|
|
31
83
|
return null;
|
|
32
|
-
|
|
84
|
+
const radius = Number(element.getAttribute("data-smart-radius"));
|
|
85
|
+
return { type, id, attrs: {
|
|
86
|
+
src, alt: element.getAttribute("alt") || "", status: element.getAttribute("data-smart-status") || "ready",
|
|
87
|
+
...(element.getAttribute("data-smart-decorative") === "true" ? { decorative: true } : {}),
|
|
88
|
+
...(element.getAttribute("data-smart-align") ? { align: element.getAttribute("data-smart-align") } : {}),
|
|
89
|
+
...(number("width") ? { width: number("width") } : {}), ...(number("height") ? { height: number("height") } : {}),
|
|
90
|
+
...(Number.isFinite(radius) && radius > 0 ? { borderRadius: radius } : {}),
|
|
91
|
+
...(element.getAttribute("data-smart-href") ? { href: element.getAttribute("data-smart-href") } : {}),
|
|
92
|
+
...(element.getAttribute("data-smart-target") ? { target: element.getAttribute("data-smart-target") } : {}),
|
|
93
|
+
...(element.getAttribute("data-smart-license-description") ? { licenseDescription: element.getAttribute("data-smart-license-description") } : {}),
|
|
94
|
+
...(element.getAttribute("data-smart-license-source-url") ? { licenseSourceUrl: element.getAttribute("data-smart-license-source-url") } : {}),
|
|
95
|
+
...(element.getAttribute("data-smart-license-type") ? { licenseType: element.getAttribute("data-smart-license-type") } : {}),
|
|
96
|
+
...(element.getAttribute("data-smart-license-version") ? { licenseVersion: element.getAttribute("data-smart-license-version") } : {}),
|
|
97
|
+
...(element.getAttribute("data-smart-license-attribution") ? { licenseAttribution: element.getAttribute("data-smart-license-attribution") } : {}),
|
|
98
|
+
} };
|
|
33
99
|
}
|
|
34
100
|
if (type === "formula" || type === "block_formula") {
|
|
35
101
|
return { type, id, attrs: { source: element.getAttribute("data-smart-formula") || "", notation: element.getAttribute("data-smart-notation") === "mathml" ? "mathml" : "latex" } };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeLinkInput } from "../security/urlPolicy.js";
|
|
1
2
|
const optionalString = { validate: (value) => typeof value === "string" };
|
|
2
3
|
const requiredString = { required: true, validate: (value) => typeof value === "string" };
|
|
3
4
|
const dimension = { validate: (value) => Number.isFinite(value) && Number(value) > 0 && Number(value) <= 100000 };
|
|
@@ -6,6 +7,34 @@ const imageAttrs = {
|
|
|
6
7
|
src: requiredString, alt: requiredString, width: dimension, height: dimension,
|
|
7
8
|
status, uploadId: optionalString, error: optionalString, decorative: { validate: (value) => typeof value === "boolean" },
|
|
8
9
|
align: { validate: (value) => value === "center" || value === "left" || value === "right" },
|
|
10
|
+
/**
|
|
11
|
+
* docs/bugs/media-details-old-editor-field-parity.md: the old editor's
|
|
12
|
+
* "Link"/"Target" fields have no equivalent yet - the `link` mark already
|
|
13
|
+
* has this exact href/target shape (marks/commands.ts), but marks are
|
|
14
|
+
* explicitly disallowed on every atom node (`marks: ""` below), so this
|
|
15
|
+
* is a dedicated pair of atom-level attrs instead of reusing the mark.
|
|
16
|
+
*/
|
|
17
|
+
/** Same normalizeLinkInput validation the "link" mark's own schema uses (marks/schema.ts) - rejects at the command layer, not just sanitizing later at render/export time. */
|
|
18
|
+
href: { validate: (value) => typeof value === "string" && normalizeLinkInput(value).href !== null },
|
|
19
|
+
target: optionalString,
|
|
20
|
+
/** Corner rounding (px) - confirmed absent anywhere in this schema before now, unlike align/alt/width which already existed with no UI. */
|
|
21
|
+
borderRadius: { validate: (value) => Number.isFinite(value) && Number(value) >= 0 && Number(value) <= 1000 },
|
|
22
|
+
/**
|
|
23
|
+
* Mirrors MediaItem.license's own shape (packages/react/src/mediaProvider.ts)
|
|
24
|
+
* field-for-field where a real equivalent exists, so a library-sourced
|
|
25
|
+
* image's already-fetched license metadata can be copied straight across
|
|
26
|
+
* at insert time (CanonicalAuthorityEditor.tsx's selectFromMediaManager)
|
|
27
|
+
* instead of being read, shown in the library browser, and then silently
|
|
28
|
+
* discarded the way it was before this - the atom schema had nowhere to
|
|
29
|
+
* persist it. Renamed to this project's own vocabulary (the old editor's
|
|
30
|
+
* "Attribution text" ~ MediaItem.license.author, "License description" ~
|
|
31
|
+
* workName/licenseText) and kept independently user-editable, since a
|
|
32
|
+
* freshly uploaded image has no provider-supplied license data at all.
|
|
33
|
+
* `licenseVersion` has no MediaItem.license equivalent (its shape has no
|
|
34
|
+
* separate version field) - always user-entered, never auto-populated.
|
|
35
|
+
*/
|
|
36
|
+
licenseDescription: optionalString, licenseSourceUrl: optionalString,
|
|
37
|
+
licenseType: optionalString, licenseVersion: optionalString, licenseAttribution: optionalString,
|
|
9
38
|
};
|
|
10
39
|
const formulaAttrs = {
|
|
11
40
|
source: requiredString,
|
|
@@ -2,6 +2,7 @@ import type { SmartDocument } from "../types.js";
|
|
|
2
2
|
export declare const serializeCanonicalListHtml: (document: SmartDocument, options?: {
|
|
3
3
|
clean?: boolean;
|
|
4
4
|
fragment?: boolean;
|
|
5
|
+
renderFormulaHtml?: boolean;
|
|
5
6
|
}) => string;
|
|
6
7
|
export declare const parseCanonicalListHtml: (html: string) => SmartDocument;
|
|
7
8
|
/** Alignment and indent are unsupported in Markdown; block content survives semantically. */
|
|
@@ -20,12 +20,31 @@ const isEditorUiNode = (node) => attr(node, "data-smart-ui") !== undefined || at
|
|
|
20
20
|
* parseBlockList below for how they're unwrapped instead of swallowed.
|
|
21
21
|
*/
|
|
22
22
|
const TRANSPARENT_CONTAINER_TAGS = ["div", "section", "article", "figure"];
|
|
23
|
+
/** Shared by both the inline `image` and block `block_image` parsing branches below - radius/license round-trip identically for both, only href/target's link-mark-fallback behavior deliberately differs between them (see each call site's own comment). */
|
|
24
|
+
const imageStyleAndLicenseAttrs = (node) => {
|
|
25
|
+
const radius = Number(attr(node, "data-smart-radius"));
|
|
26
|
+
const licenseDescription = attr(node, "data-smart-license-description");
|
|
27
|
+
const licenseSourceUrl = attr(node, "data-smart-license-source-url");
|
|
28
|
+
const licenseType = attr(node, "data-smart-license-type");
|
|
29
|
+
const licenseVersion = attr(node, "data-smart-license-version");
|
|
30
|
+
const licenseAttribution = attr(node, "data-smart-license-attribution");
|
|
31
|
+
return {
|
|
32
|
+
...(Number.isFinite(radius) && radius > 0 ? { borderRadius: radius } : {}),
|
|
33
|
+
...(licenseDescription ? { licenseDescription } : {}),
|
|
34
|
+
...(licenseSourceUrl ? { licenseSourceUrl } : {}),
|
|
35
|
+
...(licenseType ? { licenseType } : {}),
|
|
36
|
+
...(licenseVersion ? { licenseVersion } : {}),
|
|
37
|
+
...(licenseAttribution ? { licenseAttribution } : {}),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/** See serializeCanonicalListHtml's own doc comment for why this is a module-scoped flag rather than a threaded parameter. */
|
|
41
|
+
let renderFormulaHtmlMode = false;
|
|
23
42
|
const serializeInline = (node) => {
|
|
24
43
|
if (!isTextNode(node)) {
|
|
25
44
|
if (node.type === "hard_break")
|
|
26
45
|
return `<br data-smart-id="${escapeHtml(node.id)}" data-smart-type="hard_break">`;
|
|
27
46
|
if (["image", "formula"].includes(node.type))
|
|
28
|
-
return atomToHtml(node);
|
|
47
|
+
return atomToHtml(node, { renderFormulaHtml: renderFormulaHtmlMode });
|
|
29
48
|
const raw = node.attrs?.raw;
|
|
30
49
|
if (typeof raw?.html === "string")
|
|
31
50
|
return raw.html;
|
|
@@ -91,7 +110,7 @@ const serializeBlock = (node, includeIds, listDepth = 0, tableCellA11y) => {
|
|
|
91
110
|
}
|
|
92
111
|
const id = includeIds ? ` data-smart-id="${escapeHtml(node.id)}"` : "";
|
|
93
112
|
if (["block_image", "block_formula", "video", "audio", "divider"].includes(node.type))
|
|
94
|
-
return atomToHtml(node);
|
|
113
|
+
return atomToHtml(node, { renderFormulaHtml: renderFormulaHtmlMode });
|
|
95
114
|
if (node.type === "paragraph" || node.type === "heading") {
|
|
96
115
|
const tag = node.type === "heading" ? `h${String(node.attrs?.level || 1)}` : "p";
|
|
97
116
|
return `<${tag}${id}${blockAttributes(node)}>${(node.children || []).map(serializeInline).join("")}</${tag}>`;
|
|
@@ -179,12 +198,28 @@ const serializeBlock = (node, includeIds, listDepth = 0, tableCellA11y) => {
|
|
|
179
198
|
}
|
|
180
199
|
return `<div${id}>${(node.children || []).map((child) => isTextNode(child) ? serializeInline(child) : serializeBlock(child, includeIds)).join("")}</div>`;
|
|
181
200
|
};
|
|
182
|
-
export const serializeCanonicalListHtml = (document, options = {}) =>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
201
|
+
export const serializeCanonicalListHtml = (document, options = {}) => {
|
|
202
|
+
// A module-scoped flag, not a threaded parameter: serializeInline/
|
|
203
|
+
// serializeBlock recurse through dozens of call sites (lists, tables,
|
|
204
|
+
// blockquotes) - threading a new parameter through every one of them is
|
|
205
|
+
// exactly the kind of change that's easy to miss at one nested call site
|
|
206
|
+
// and silently break there. Safe here because serialization is
|
|
207
|
+
// synchronous, single-threaded, non-reentrant tree recursion - the flag
|
|
208
|
+
// is always restored in `finally` before this function ever returns.
|
|
209
|
+
const previous = renderFormulaHtmlMode;
|
|
210
|
+
renderFormulaHtmlMode = options.renderFormulaHtml === true;
|
|
211
|
+
try {
|
|
212
|
+
return options.clean === true
|
|
213
|
+
? document.children.map((node) => isTextNode(node) ? serializeInline(node) : serializeBlock(node, false)).join("")
|
|
214
|
+
.replace(/\sdata-smart-id=(?:"[^"]*"|'[^']*')/g, "")
|
|
215
|
+
: options.fragment === true
|
|
216
|
+
? document.children.map((node) => isTextNode(node) ? serializeInline(node) : serializeBlock(node, true)).join("")
|
|
217
|
+
: `<div data-smart-document="true" data-smart-id="${escapeHtml(document.id)}">${document.children.map((node) => isTextNode(node) ? serializeInline(node) : serializeBlock(node, true)).join("")}</div>`;
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
renderFormulaHtmlMode = previous;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
188
223
|
const textWithMarks = (node, inherited = []) => {
|
|
189
224
|
if (isEditorUiNode(node))
|
|
190
225
|
return [];
|
|
@@ -209,12 +244,27 @@ const textWithMarks = (node, inherited = []) => {
|
|
|
209
244
|
// synchronous and the image hasn't loaded yet at this point.
|
|
210
245
|
const width = parsePixelWidth(styleValue(node, "width")) ?? parsePixelWidth(attr(node, "width"));
|
|
211
246
|
const height = parsePixelWidth(styleValue(node, "height")) ?? parsePixelWidth(attr(node, "height"));
|
|
247
|
+
// <a><img></a> (this project's own export - list/formats.ts's own
|
|
248
|
+
// serializeBlock/atom/formats.ts's atomToHtml wrap a linked image in a
|
|
249
|
+
// real anchor - or any real-world source doing the same): the link
|
|
250
|
+
// mark from that ancestor <a> arrives here via `inherited`, but
|
|
251
|
+
// SmartElementNode has no `marks` field at all (only SmartTextNode
|
|
252
|
+
// does) - without this, the href would be silently discarded the
|
|
253
|
+
// moment it reached this atomic-image branch. The live editor's own
|
|
254
|
+
// DOM instead encodes this as plain data-smart-href/-target
|
|
255
|
+
// attributes directly on the <img> (surface/renderer.ts) with no
|
|
256
|
+
// wrapping <a> at all, so both encodings are checked.
|
|
257
|
+
const inheritedLink = inherited.find((mark) => mark.type === "link");
|
|
258
|
+
const href = attr(node, "data-smart-href") || inheritedLink?.attrs?.href;
|
|
259
|
+
const target = attr(node, "data-smart-target") || inheritedLink?.attrs?.target;
|
|
212
260
|
return [{ type: "image", id: generatedId(node, "image"), attrs: {
|
|
213
261
|
src, alt: attr(node, "alt") || "", status: attr(node, "data-smart-status") || "ready",
|
|
214
262
|
...(attr(node, "data-smart-decorative") === "true" ? { decorative: true } : {}),
|
|
215
263
|
...(attr(node, "title") ? { title: attr(node, "title") } : {}),
|
|
216
264
|
...(attr(node, "data-smart-align") ? { align: attr(node, "data-smart-align") } : {}),
|
|
217
265
|
...(width !== null ? { width } : {}), ...(height !== null ? { height } : {}),
|
|
266
|
+
...(href ? { href } : {}), ...(target ? { target } : {}),
|
|
267
|
+
...imageStyleAndLicenseAttrs(node),
|
|
218
268
|
} }];
|
|
219
269
|
}
|
|
220
270
|
}
|
|
@@ -357,6 +407,19 @@ const parseBlock = (node) => {
|
|
|
357
407
|
src, alt: attr(node, "alt") || "", status: attr(node, "data-smart-status") || "ready",
|
|
358
408
|
...(attr(node, "data-smart-decorative") === "true" ? { decorative: true } : {}),
|
|
359
409
|
...(width !== null ? { width } : {}), ...(height !== null ? { height } : {}),
|
|
410
|
+
// Only from data-smart-href/-target directly on this element (our own
|
|
411
|
+
// round-tripped export, per this whole branch's own `declaredAtom`
|
|
412
|
+
// gate) - deliberately NOT also checking for a wrapping <a> the way
|
|
413
|
+
// the inline `image` atom's parsing does just above in textWithMarks.
|
|
414
|
+
// The bare-<img>-at-block-level fallback a few lines down has its own
|
|
415
|
+
// long-standing, evidence-based decision (a real Wikipedia "copy
|
|
416
|
+
// image" paste) to unwrap and drop an incidental <a> wrapper rather
|
|
417
|
+
// than treat it as a user-authored link - extending that to also
|
|
418
|
+
// read href here would silently reverse that decision for every
|
|
419
|
+
// third-party block-level image paste, not just this app's own.
|
|
420
|
+
...(attr(node, "data-smart-href") ? { href: attr(node, "data-smart-href") } : {}),
|
|
421
|
+
...(attr(node, "data-smart-target") ? { target: attr(node, "data-smart-target") } : {}),
|
|
422
|
+
...imageStyleAndLicenseAttrs(node),
|
|
360
423
|
} };
|
|
361
424
|
}
|
|
362
425
|
if (declaredAtom === "block_formula")
|
|
@@ -2,6 +2,7 @@ import { isTextNode } from "./identity.js";
|
|
|
2
2
|
import { nodeAtPath } from "./positions.js";
|
|
3
3
|
import { renderMarkedText } from "./marks/dom.js";
|
|
4
4
|
import { sanitizeAtomSource } from "./atom/security.js";
|
|
5
|
+
import { sanitizeLinkHref, sanitizeLinkTarget } from "./security/urlPolicy.js";
|
|
5
6
|
const atomTypes = new Set(["image", "block_image", "formula", "block_formula", "video", "audio", "divider"]);
|
|
6
7
|
export const SMART_UI_ATTRIBUTE = "data-smart-ui";
|
|
7
8
|
export const SMART_PROJECTION_ATTRIBUTE = "data-smart-projection";
|
|
@@ -93,6 +94,23 @@ const renderNode = (node, ownerDocument) => {
|
|
|
93
94
|
if (source)
|
|
94
95
|
element.setAttribute("src", source);
|
|
95
96
|
element.setAttribute("alt", typeof node.attrs?.alt === "string" ? node.attrs.alt : "");
|
|
97
|
+
if (node.attrs?.borderRadius)
|
|
98
|
+
element.style.borderRadius = `${Number(node.attrs.borderRadius)}px`;
|
|
99
|
+
const linkHref = sanitizeLinkHref(typeof node.attrs?.href === "string" ? node.attrs.href : undefined);
|
|
100
|
+
if (linkHref) {
|
|
101
|
+
// A real <a> wrapper, unlike the live surface renderer's plain data
|
|
102
|
+
// attributes - this is a one-shot tree builder (no per-node element
|
|
103
|
+
// identity to preserve across renders), and clipboard/print output
|
|
104
|
+
// needs a genuine anchor for the link to survive outside this editor
|
|
105
|
+
// at all.
|
|
106
|
+
const link = ownerDocument.createElement("a");
|
|
107
|
+
link.setAttribute("href", linkHref);
|
|
108
|
+
const linkTarget = sanitizeLinkTarget(typeof node.attrs?.target === "string" ? node.attrs.target : undefined);
|
|
109
|
+
if (linkTarget)
|
|
110
|
+
link.setAttribute("target", linkTarget);
|
|
111
|
+
link.appendChild(element);
|
|
112
|
+
return link;
|
|
113
|
+
}
|
|
96
114
|
}
|
|
97
115
|
if (node.type === "formula" || node.type === "block_formula") {
|
|
98
116
|
const source = String(node.attrs?.source || "");
|
|
@@ -405,6 +405,62 @@ export class FoundationSubtreeRenderer {
|
|
|
405
405
|
element.style.float = imageAlign;
|
|
406
406
|
element.style.margin = imageAlign === "left" ? "0 8px 8px 0" : "0 0 8px 8px";
|
|
407
407
|
}
|
|
408
|
+
else {
|
|
409
|
+
// No align set - reset every property the two branches above can
|
|
410
|
+
// set, not just skip setting new ones. A pre-existing gap (found
|
|
411
|
+
// while verifying docs/bugs/media-details-old-editor-field-parity.md's
|
|
412
|
+
// new "clear align back to None" UI path): this renderer's other
|
|
413
|
+
// conditional style branches (e.g. borderRadius just below) always
|
|
414
|
+
// pair their "set" case with an explicit "else remove" case for
|
|
415
|
+
// exactly this reason - align's own else branches never existed
|
|
416
|
+
// before this UI made "align was set, then explicitly cleared" a
|
|
417
|
+
// real, reachable transition for the first time.
|
|
418
|
+
element.style.removeProperty("display");
|
|
419
|
+
element.style.removeProperty("float");
|
|
420
|
+
element.style.removeProperty("margin");
|
|
421
|
+
}
|
|
422
|
+
if (node.attrs?.borderRadius)
|
|
423
|
+
element.style.borderRadius = `${Number(node.attrs.borderRadius)}px`;
|
|
424
|
+
else
|
|
425
|
+
element.style.removeProperty("border-radius");
|
|
426
|
+
// Plain data attributes, not a wrapping <a> - this element's identity
|
|
427
|
+
// is stable across renders (see this class's own diffing contract);
|
|
428
|
+
// introducing a conditional DOM-structure change here for something
|
|
429
|
+
// input.ts's click handling already reads directly is unnecessary
|
|
430
|
+
// risk. atomToHtml/modelDom.ts DO wrap in a real <a> for the
|
|
431
|
+
// string/one-shot export paths, which have no such stability
|
|
432
|
+
// constraint and need a real anchor for the link to work outside
|
|
433
|
+
// this live editor at all.
|
|
434
|
+
if (typeof node.attrs?.href === "string" && node.attrs.href)
|
|
435
|
+
this.setAttribute(element, "data-smart-href", node.attrs.href, node.id);
|
|
436
|
+
else
|
|
437
|
+
this.removeAttribute(element, "data-smart-href", node.id);
|
|
438
|
+
if (typeof node.attrs?.target === "string" && node.attrs.target)
|
|
439
|
+
this.setAttribute(element, "data-smart-target", node.attrs.target, node.id);
|
|
440
|
+
else
|
|
441
|
+
this.removeAttribute(element, "data-smart-target", node.id);
|
|
442
|
+
// Mirrored as data attributes too, matching href/target above - a
|
|
443
|
+
// host inspecting the live DOM (or this project's own e2e suite)
|
|
444
|
+
// should be able to see this data the same way it can see href, not
|
|
445
|
+
// just find it by reading the model directly. Purely descriptive
|
|
446
|
+
// metadata, so unlike align/borderRadius there's no matching CSS
|
|
447
|
+
// side effect to apply here.
|
|
448
|
+
if (node.attrs?.borderRadius)
|
|
449
|
+
this.setAttribute(element, "data-smart-radius", String(node.attrs.borderRadius), node.id);
|
|
450
|
+
else
|
|
451
|
+
this.removeAttribute(element, "data-smart-radius", node.id);
|
|
452
|
+
[
|
|
453
|
+
["licenseDescription", "data-smart-license-description"],
|
|
454
|
+
["licenseSourceUrl", "data-smart-license-source-url"],
|
|
455
|
+
["licenseType", "data-smart-license-type"],
|
|
456
|
+
["licenseVersion", "data-smart-license-version"],
|
|
457
|
+
["licenseAttribution", "data-smart-license-attribution"],
|
|
458
|
+
].forEach(([key, attrName]) => {
|
|
459
|
+
if (typeof node.attrs?.[key] === "string" && node.attrs[key])
|
|
460
|
+
this.setAttribute(element, attrName, String(node.attrs[key]), node.id);
|
|
461
|
+
else
|
|
462
|
+
this.removeAttribute(element, attrName, node.id);
|
|
463
|
+
});
|
|
408
464
|
}
|
|
409
465
|
else if (node.type === "formula" || node.type === "block_formula") {
|
|
410
466
|
const source = String(node.attrs?.source || "");
|
|
@@ -3,6 +3,7 @@ export declare const parseCanonicalTableHtml: (html: string) => SmartDocument;
|
|
|
3
3
|
export declare const serializeCanonicalTableHtml: (document: SmartDocument, options?: {
|
|
4
4
|
clean?: boolean;
|
|
5
5
|
fragment?: boolean;
|
|
6
|
+
renderFormulaHtml?: boolean;
|
|
6
7
|
}) => string;
|
|
7
8
|
/** GFM is intentionally lossy: anchor content survives and covered coordinates are emitted empty. */
|
|
8
9
|
export declare const serializeCanonicalTableMarkdown: (document: SmartDocument) => string;
|