smartrte-core 1.1.0 → 1.2.0

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ All changes are additive: documents without captions or metadata export byte-identically to 1.1.x (HTML, clipboard HTML/plain text, Markdown, DOCX), checked against golden output from the 1.1.x source.
6
+
7
+ - **Image captions.** `block_image` gains an optional plain-text `caption` attribute (normalised: control characters stripped, whitespace collapsed, at most 500 characters). A captioned image exports as `<figure data-smart-figure="true">(<a>)<img …><figcaption>…</figcaption></figure>`, with every attribute still on the `<img>`. It also exports to Markdown (an italic line under the image), DOCX (a Caption-style paragraph) and plain text (`alt - caption`). On import, any `<figure>` with one image and one `<figcaption>` becomes a captioned image; marks inside the caption are flattened. A figcaption with no image becomes a paragraph instead of `[Unsupported: figcaption]`. The live renderer shows the caption as a non-editable projection directly under the image. See `docs/bugs/pasted-web-images-and-hr-render-as-unsupported.md`.
8
+ - **Heads-up, changes how existing content parses:** HTML that earlier versions saved as a loose `<img>` followed by `<figcaption>` (a pasted `<figure>` flattened on save) is now repaired on load. The figcaption becomes the image's caption, and the next save rewrites it as a `<figure>`. See `docs/bugs/figure-wrapper-lost-on-save.md`. Stored JSON documents still hold the old `unknown` figcaption node; the repair runs on HTML parsing only.
9
+ - **Host image metadata.** `block_image` gains an optional `metadata` map of host-defined `data-*` attributes (at most 10; values at most 500 characters; the `data-smart-` namespace is reserved). It's exported as escaped attributes on the `<img>`. `parseCanonicalListHtml(html, { imageMetadataAttributes })` (a new optional options parameter, also threaded through paste/drop via `CanonicalInputPipelineOptions.imageMetadataAttributes`) keeps only the names you allowlist; the default keeps none, as before.
10
+ - **Document queries:** `getSectionContext(document, selection, { maxChars })` returns the nearest top-level heading at the caret and that section's plain text. `listDocumentImages(document)` lists every image in document order.
11
+ - `CanonicalSubtreeRenderer.render` takes an optional `{ syncDomSelection }`, which renders without moving the browser selection (and therefore focus) into the editor.
12
+ - Fix: a block image's alignment was lost on every HTML save → reload (`data-smart-align` was written but never read back). See `docs/bugs/block-image-align-lost-on-html-reload.md`.
13
+ - Fix (latent): model/DOM offset mapping counted renderer projections (table caption/colgroup, checklist controls) as block children. See `docs/bugs/block-boundary-offsets-counted-renderer-projections.md`.
14
+ - New `image-captions` row in `builtInFormatFidelity`.
15
+
3
16
  ## 1.1.0
4
17
 
5
18
  - Add `backgroundColor`, `textColor`, and `borderLeft` attrs to `blockquote` (the last a single composed CSS shorthand, since a blockquote only ever shows one visible border side, unlike `table_cell`'s 4-sided borders) — rendered as real CSS in the same change, alongside the existing `align`/`indentLevel`/`lineHeight` block attrs. See `docs/bugs/blockquote-styling-context-menu.md`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Smart RTE Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  - **Browser input surface** (`surface/`) — the `beforeinput`/paste/cut/drop/composition handling layer a real contentEditable-backed UI wires up to; this is what `smartrte-react` builds its editing surface on.
31
31
  - **Format adapters** (`formats/`) — HTML, Markdown, DOCX (via `mammoth`/`@xmldom`), and PDF (`pdfjs-dist`) import/export, each declaring an explicit fidelity contract (`builtInFormatFidelity`) for exactly what's lossless vs. lossy per format/feature pair.
32
32
  - **Diff, versioning, comments, suggestions, plugin system** (`diff/`, `versioning/`, `comments/`, `suggestions/`, `plugin/`) — the primitives `smartrte-react`'s version history, comment threads, and track-changes UI are built on. A `PluginRegistry` (`createPluginRegistry`, `builtInPlugins`) is how new commands, keyboard shortcuts, toolbar contributions, and renderer behavior are added — see [`docs/PLUGIN_ARCHITECTURE.md`](https://github.com/ayush1852017/smart-rte/blob/master/docs/PLUGIN_ARCHITECTURE.md) in the repository.
33
+ - **Images: captions, host metadata, document queries** (`atom/imageExtras`, `query/`) — `block_image` takes an optional plain-text `caption` (exported as `<figure>`/`<figcaption>`) and an optional `metadata` map of host-defined `data-*` attributes. `parseCanonicalListHtml(html, { imageMetadataAttributes: ["data-source"] })` keeps only the attributes you allowlist (none by default). `getSectionContext(document, selection)` returns the nearest top-level heading at the caret plus that section's plain text, and `listDocumentImages(document)` lists every image in document order.
33
34
  - **Collab contract** (`collab/`) — the operation-transform machinery (`mapOperation`) a real-time transport would rebase concurrent edits through. A contract for hosts building multi-writer collaboration against; no transport implementation ships here.
34
35
 
35
36
  ## Subpath exports
@@ -4,17 +4,6 @@ export declare const atomToHtml: (node: SmartElementNode, options?: {
4
4
  renderFormulaHtml?: boolean;
5
5
  }) => string;
6
6
  export declare const atomFromHtmlElement: (rawElement: Element) => SmartElementNode | null;
7
- /**
8
- * page_break: Markdown has no pagination concept at all (declared
9
- * `unsupported` in formats/fidelity.ts) - emitting nothing would silently
10
- * delete the marker with no trace, the same class of bug this project has
11
- * hit before for images/formulas (see formats/fidelity.ts's own
12
- * images-media/formulas notes on that history). An HTML comment is inert
13
- * in every real Markdown renderer (so it never appears as visible garbage
14
- * text) but keeps the marker's *position* recorded in the exported file -
15
- * genuinely honest `unsupported`, not a round-trippable format: nothing
16
- * parses this comment back into a page_break node on import.
17
- */
18
7
  export declare const atomToMarkdown: (node: SmartElementNode) => string;
19
8
  export interface AtomDocxRun {
20
9
  readonly kind: "image" | "text" | "pageBreak";
@@ -8,6 +8,7 @@ import "katex/contrib/mhchem";
8
8
  import { createNodeId } from "../identity.js";
9
9
  import { sanitizeLinkHref, sanitizeLinkTarget } from "../security/urlPolicy.js";
10
10
  import { sanitizeAtomSource } from "./security.js";
11
+ import { isValidImageMetadataName, normalizeImageCaption } from "./imageExtras.js";
11
12
  const escape = (value) => String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
13
  const attr = (name, value) => value === undefined ? "" : ` ${name}="${escape(value)}"`;
13
14
  const dimensions = (node) => `${attr("width", node.attrs?.width)}${attr("height", node.attrs?.height)}`;
@@ -23,6 +24,15 @@ const renderFormulaToHtml = (source) => {
23
24
  return escape(source);
24
25
  }
25
26
  };
27
+ /** Host metadata (atom/imageExtras.ts) as plain escaped `data-*` attributes, in stored order. Only `block_image` carries it; the stored map is already shape-validated by the schema. */
28
+ const imageMetadataAttributes = (node) => {
29
+ const metadata = node.type === "block_image" ? node.attrs?.metadata : undefined;
30
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
31
+ return "";
32
+ return Object.entries(metadata)
33
+ .filter(([name, value]) => isValidImageMetadataName(name) && typeof value === "string")
34
+ .map(([name, value]) => attr(name, value)).join("");
35
+ };
26
36
  export const atomToHtml = (node, options) => {
27
37
  if (node.type === "image" || node.type === "block_image") {
28
38
  const src = sanitizeAtomSource(String(node.attrs?.src || ""), { kind: "image", allowBlobPreview: node.attrs?.status === "pending" }) || "";
@@ -32,17 +42,24 @@ export const atomToHtml = (node, options) => {
32
42
  // running the live surface renderer's own style logic against it
33
43
  // wouldn't see visual rounding either way, same as align today; that's
34
44
  // 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)}>`;
45
+ 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)}${imageMetadataAttributes(node)}>`;
36
46
  // A real <a> wrapper, unlike the live surface renderer's plain data
37
47
  // attributes on the bare <img> - see surface/renderer.ts's own
38
48
  // reasoning (stable-element-per-id constraint that doesn't apply to
39
49
  // this string-based export) and modelDom.ts's identical choice for the
40
50
  // one-shot clipboard/print DOM builder.
41
51
  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>`;
52
+ const target = linkHref ? sanitizeLinkTarget(typeof node.attrs?.target === "string" ? node.attrs.target : undefined) : undefined;
53
+ const linked = linkHref ? `<a href="${escape(linkHref)}"${target ? ` target="${escape(target)}"` : ""}>${img}</a>` : img;
54
+ // A captioned block image becomes a real <figure>/<figcaption> - the
55
+ // accessible caption for any static HTML consumer. Every attribute stays
56
+ // on the <img> itself, so a pre-1.2 parser (which unwraps <figure> as a
57
+ // transparent container) still reads the image correctly. The link, if
58
+ // any, wraps only the <img>. data-smart-figure is a debugging marker;
59
+ // list/formats.ts's parser never depends on it, since third-party
60
+ // figures don't carry it. Uncaptioned output is unchanged.
61
+ const caption = node.type === "block_image" && typeof node.attrs?.caption === "string" ? node.attrs.caption : "";
62
+ return caption ? `<figure data-smart-figure="true">${linked}<figcaption>${escape(caption)}</figcaption></figure>` : linked;
46
63
  }
47
64
  if (node.type === "formula" || node.type === "block_formula") {
48
65
  const tag = node.type === "formula" ? "span" : "div";
@@ -82,7 +99,13 @@ export const atomFromHtmlElement = (rawElement) => {
82
99
  // (which still carries every attribute, including data-smart-href,
83
100
  // directly) before parsing, matching what a single element's own
84
101
  // attributes already fully describe.
85
- const element = rawElement.tagName === "A" && rawElement.children.length === 1 ? rawElement.children[0] : rawElement;
102
+ // A captioned block image exports as <figure>(<a>)<img><figcaption>; the
103
+ // image element still carries every attribute, so unwrap to it and read
104
+ // the caption from the figcaption.
105
+ const figureImage = rawElement.tagName === "FIGURE" ? rawElement.querySelector(":scope > img, :scope > a > img") : null;
106
+ const figureCaption = figureImage ? normalizeImageCaption(rawElement.querySelector(":scope > figcaption")?.textContent) : undefined;
107
+ const unwrapped = figureImage || rawElement;
108
+ const element = unwrapped.tagName === "A" && unwrapped.children.length === 1 ? unwrapped.children[0] : unwrapped;
86
109
  const declared = element.getAttribute("data-smart-type");
87
110
  const type = declared || (element.tagName === "IMG" ? "image" : element.tagName.toLowerCase());
88
111
  const id = element.getAttribute("data-smart-id") || createNodeId();
@@ -105,6 +128,7 @@ export const atomFromHtmlElement = (rawElement) => {
105
128
  ...(element.getAttribute("data-smart-license-type") ? { licenseType: element.getAttribute("data-smart-license-type") } : {}),
106
129
  ...(element.getAttribute("data-smart-license-version") ? { licenseVersion: element.getAttribute("data-smart-license-version") } : {}),
107
130
  ...(element.getAttribute("data-smart-license-attribution") ? { licenseAttribution: element.getAttribute("data-smart-license-attribution") } : {}),
131
+ ...(type === "block_image" && figureCaption ? { caption: figureCaption } : {}),
108
132
  } };
109
133
  }
110
134
  if (type === "formula" || type === "block_formula") {
@@ -133,9 +157,15 @@ export const atomFromHtmlElement = (rawElement) => {
133
157
  * genuinely honest `unsupported`, not a round-trippable format: nothing
134
158
  * parses this comment back into a page_break node on import.
135
159
  */
160
+ const escapeMarkdownText = (value) => value.replace(/[\\`*_[\]<>]/g, (character) => `\\${character}`);
136
161
  export const atomToMarkdown = (node) => {
137
- if (node.type === "image" || node.type === "block_image")
138
- return `![${String(node.attrs?.alt || "")}](${String(node.attrs?.src || "")})`;
162
+ if (node.type === "image" || node.type === "block_image") {
163
+ const image = `![${String(node.attrs?.alt || "")}](${String(node.attrs?.src || "")})`;
164
+ // Markdown has no caption syntax; an italic line straight after the
165
+ // image is the common convention. Not parsed back into a caption.
166
+ const caption = node.type === "block_image" && typeof node.attrs?.caption === "string" ? node.attrs.caption : "";
167
+ return caption ? `${image}\n*${escapeMarkdownText(caption)}*` : image;
168
+ }
139
169
  if (node.type === "formula" || node.type === "block_formula")
140
170
  return node.type === "formula" ? `$${String(node.attrs?.source || "")}$` : `$$\n${String(node.attrs?.source || "")}\n$$`;
141
171
  // Media is unsupported in Markdown. Preserve a readable link instead of dropping content.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Block-image captions and host-defined metadata (1.2.0). Shared by the
3
+ * schema (shape validation), the HTML parser (normalisation on import), the
4
+ * exporters and the React insert/edit paths, so every entry point agrees on
5
+ * what a valid caption or metadata entry is.
6
+ *
7
+ * Both are `block_image`-only: a caption under an image that sits inside a
8
+ * line of text has no layout meaning, and keeping the inline `image` node
9
+ * unchanged keeps the risk of this feature contained.
10
+ */
11
+ export declare const IMAGE_CAPTION_MAX_LENGTH = 500;
12
+ export declare const IMAGE_METADATA_MAX_ENTRIES = 10;
13
+ export declare const IMAGE_METADATA_MAX_NAME_LENGTH = 40;
14
+ export declare const IMAGE_METADATA_MAX_VALUE_LENGTH = 500;
15
+ /**
16
+ * Plain text, control characters stripped, whitespace collapsed to single
17
+ * spaces, trimmed, capped at IMAGE_CAPTION_MAX_LENGTH. Returns `undefined`
18
+ * for anything that normalises to empty, so callers delete the attribute
19
+ * instead of storing `""` (an absent caption must export byte-identically
20
+ * to a pre-1.2 document).
21
+ */
22
+ export declare const normalizeImageCaption: (value: unknown) => string | undefined;
23
+ /** Schema check: only an already-normalised, non-empty caption is valid. */
24
+ export declare const isValidImageCaption: (value: unknown) => boolean;
25
+ /** `data-*` in lowercase kebab-case, at most 40 characters, never in the reserved `data-smart-` namespace. */
26
+ export declare const isValidImageMetadataName: (name: unknown) => name is string;
27
+ /**
28
+ * Schema check for `block_image.attrs.metadata`. Shape only - the schema is
29
+ * static, so it cannot know a host's allowlist. Allowlist enforcement lives
30
+ * at the entry points (parser option, insertImage).
31
+ */
32
+ export declare const isValidImageMetadata: (value: unknown) => boolean;
33
+ export interface ImageMetadataAllowlist {
34
+ readonly names: readonly string[];
35
+ readonly rejected: readonly string[];
36
+ }
37
+ /**
38
+ * Validates a host-supplied `imageMetadataAttributes` list: invalid names
39
+ * are dropped (reported in `rejected` so the caller can warn), duplicates
40
+ * collapse, and at most IMAGE_METADATA_MAX_ENTRIES names are kept.
41
+ */
42
+ export declare const normalizeImageMetadataAllowlist: (names: readonly unknown[] | undefined) => ImageMetadataAllowlist;
43
+ /**
44
+ * Keeps only allowlisted names with string values, truncating each value to
45
+ * IMAGE_METADATA_MAX_VALUE_LENGTH. Returns `undefined` when nothing
46
+ * survives, so the attribute is omitted rather than stored as `{}`.
47
+ */
48
+ export declare const filterImageMetadata: (read: (name: string) => unknown, allowlist: readonly string[]) => Record<string, string> | undefined;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Block-image captions and host-defined metadata (1.2.0). Shared by the
3
+ * schema (shape validation), the HTML parser (normalisation on import), the
4
+ * exporters and the React insert/edit paths, so every entry point agrees on
5
+ * what a valid caption or metadata entry is.
6
+ *
7
+ * Both are `block_image`-only: a caption under an image that sits inside a
8
+ * line of text has no layout meaning, and keeping the inline `image` node
9
+ * unchanged keeps the risk of this feature contained.
10
+ */
11
+ export const IMAGE_CAPTION_MAX_LENGTH = 500;
12
+ export const IMAGE_METADATA_MAX_ENTRIES = 10;
13
+ export const IMAGE_METADATA_MAX_NAME_LENGTH = 40;
14
+ export const IMAGE_METADATA_MAX_VALUE_LENGTH = 500;
15
+ // C0/C1 control characters other than the whitespace ones collapsed below.
16
+ // eslint-disable-next-line no-control-regex
17
+ const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
18
+ /**
19
+ * Plain text, control characters stripped, whitespace collapsed to single
20
+ * spaces, trimmed, capped at IMAGE_CAPTION_MAX_LENGTH. Returns `undefined`
21
+ * for anything that normalises to empty, so callers delete the attribute
22
+ * instead of storing `""` (an absent caption must export byte-identically
23
+ * to a pre-1.2 document).
24
+ */
25
+ export const normalizeImageCaption = (value) => {
26
+ if (typeof value !== "string")
27
+ return undefined;
28
+ const normalized = value.replace(CONTROL_CHARACTERS, "").replace(/\s+/g, " ").trim().slice(0, IMAGE_CAPTION_MAX_LENGTH).trim();
29
+ return normalized || undefined;
30
+ };
31
+ /** Schema check: only an already-normalised, non-empty caption is valid. */
32
+ export const isValidImageCaption = (value) => typeof value === "string" && value.length > 0 && normalizeImageCaption(value) === value;
33
+ const METADATA_NAME = /^data-[a-z0-9]+(?:-[a-z0-9]+)*$/;
34
+ /** `data-*` in lowercase kebab-case, at most 40 characters, never in the reserved `data-smart-` namespace. */
35
+ export const isValidImageMetadataName = (name) => typeof name === "string" && name.length <= IMAGE_METADATA_MAX_NAME_LENGTH
36
+ && METADATA_NAME.test(name) && !name.startsWith("data-smart-");
37
+ /**
38
+ * Schema check for `block_image.attrs.metadata`. Shape only - the schema is
39
+ * static, so it cannot know a host's allowlist. Allowlist enforcement lives
40
+ * at the entry points (parser option, insertImage).
41
+ */
42
+ export const isValidImageMetadata = (value) => {
43
+ if (!value || typeof value !== "object" || Array.isArray(value))
44
+ return false;
45
+ const entries = Object.entries(value);
46
+ return entries.length > 0 && entries.length <= IMAGE_METADATA_MAX_ENTRIES
47
+ && entries.every(([name, entry]) => isValidImageMetadataName(name)
48
+ && typeof entry === "string" && entry.length <= IMAGE_METADATA_MAX_VALUE_LENGTH);
49
+ };
50
+ /**
51
+ * Validates a host-supplied `imageMetadataAttributes` list: invalid names
52
+ * are dropped (reported in `rejected` so the caller can warn), duplicates
53
+ * collapse, and at most IMAGE_METADATA_MAX_ENTRIES names are kept.
54
+ */
55
+ export const normalizeImageMetadataAllowlist = (names) => {
56
+ const accepted = [];
57
+ const rejected = [];
58
+ (names || []).forEach((name) => {
59
+ if (!isValidImageMetadataName(name)) {
60
+ rejected.push(String(name));
61
+ return;
62
+ }
63
+ if (accepted.includes(name))
64
+ return;
65
+ if (accepted.length >= IMAGE_METADATA_MAX_ENTRIES) {
66
+ rejected.push(name);
67
+ return;
68
+ }
69
+ accepted.push(name);
70
+ });
71
+ return { names: accepted, rejected };
72
+ };
73
+ /**
74
+ * Keeps only allowlisted names with string values, truncating each value to
75
+ * IMAGE_METADATA_MAX_VALUE_LENGTH. Returns `undefined` when nothing
76
+ * survives, so the attribute is omitted rather than stored as `{}`.
77
+ */
78
+ export const filterImageMetadata = (read, allowlist) => {
79
+ const output = {};
80
+ allowlist.forEach((name) => {
81
+ if (!isValidImageMetadataName(name))
82
+ return;
83
+ const value = read(name);
84
+ if (typeof value === "string")
85
+ output[name] = value.slice(0, IMAGE_METADATA_MAX_VALUE_LENGTH);
86
+ });
87
+ return Object.keys(output).length ? output : undefined;
88
+ };
@@ -8,3 +8,4 @@ export * from "./persistence.js";
8
8
  export * from "./lifecycle.js";
9
9
  export * from "./selection.js";
10
10
  export * from "./formats.js";
11
+ export * from "./imageExtras.js";
@@ -8,3 +8,4 @@ export * from "./persistence.js";
8
8
  export * from "./lifecycle.js";
9
9
  export * from "./selection.js";
10
10
  export * from "./formats.js";
11
+ export * from "./imageExtras.js";
@@ -1,4 +1,5 @@
1
1
  import { normalizeLinkInput } from "../security/urlPolicy.js";
2
+ import { isValidImageCaption, isValidImageMetadata } from "./imageExtras.js";
2
3
  const optionalString = { validate: (value) => typeof value === "string" };
3
4
  const requiredString = { required: true, validate: (value) => typeof value === "string" };
4
5
  const dimension = { validate: (value) => Number.isFinite(value) && Number(value) > 0 && Number(value) <= 100000 };
@@ -36,6 +37,24 @@ const imageAttrs = {
36
37
  licenseDescription: optionalString, licenseSourceUrl: optionalString,
37
38
  licenseType: optionalString, licenseVersion: optionalString, licenseAttribution: optionalString,
38
39
  };
40
+ /**
41
+ * block_image only (atom/imageExtras.ts explains why the inline `image`
42
+ * stays unchanged). `caption` is plain text; `metadata` is a host-defined
43
+ * map of allowlisted `data-*` attributes, shape-checked here and
44
+ * allowlist-checked at every entry point. Both are absent - never "" or {} -
45
+ * when unset, so a pre-1.2 document exports byte-identically.
46
+ */
47
+ const blockImageAttrs = {
48
+ ...imageAttrs,
49
+ caption: { validate: isValidImageCaption },
50
+ metadata: { validate: isValidImageMetadata },
51
+ };
52
+ /** The schema otherwise ignores unknown attributes; these two are explicitly rejected on the inline image so repair() strips them instead of carrying block-only data on an inline node. */
53
+ const inlineImageAttrs = {
54
+ ...imageAttrs,
55
+ caption: { validate: () => false },
56
+ metadata: { validate: () => false },
57
+ };
39
58
  const formulaAttrs = {
40
59
  source: requiredString,
41
60
  notation: { required: true, default: "latex", validate: (value) => value === "latex" || value === "mathml" },
@@ -44,8 +63,8 @@ const formulaAttrs = {
44
63
  const mediaAttrs = { src: requiredString, poster: optionalString, width: dimension, height: dimension, status, uploadId: optionalString, error: optionalString };
45
64
  /** Inline and block variants are distinct because schema groups are static. */
46
65
  export const atomNodeSpecs = [
47
- { type: "image", group: "inline", atomic: true, selectable: true, marks: "", attributes: imageAttrs },
48
- { type: "block_image", group: "block", atomic: true, selectable: true, marks: "", attributes: imageAttrs },
66
+ { type: "image", group: "inline", atomic: true, selectable: true, marks: "", attributes: inlineImageAttrs },
67
+ { type: "block_image", group: "block", atomic: true, selectable: true, marks: "", attributes: blockImageAttrs },
49
68
  { type: "formula", group: "inline", atomic: true, selectable: true, marks: "", attributes: formulaAttrs },
50
69
  { type: "block_formula", group: "block", atomic: true, selectable: true, marks: "", attributes: formulaAttrs },
51
70
  { type: "video", group: "block", atomic: true, selectable: true, marks: "", attributes: mediaAttrs },
@@ -52,7 +52,7 @@ export const parseClipboardPayload = (payload, options) => {
52
52
  const normalized = normalizer.normalize(sanitized);
53
53
  const parsed = detection.source === "native" && payload.native
54
54
  ? parseNativeClipboardDocument(payload.native)
55
- : parseCanonicalListHtml(normalized.html);
55
+ : parseCanonicalListHtml(normalized.html, { imageMetadataAttributes: options.imageMetadataAttributes });
56
56
  const repaired = repair({ ...parsed, id: parsed.id || createNodeId() }, options.schema ?? foundationSchema);
57
57
  return { source: detection.source, document: repaired.doc, repairs: [...normalized.repairs, ...repaired.repairs] };
58
58
  };
@@ -6,8 +6,11 @@ const nodeText = (node) => {
6
6
  return node.text;
7
7
  if (node.type === "hard_break")
8
8
  return "\n";
9
- if (node.type === "image" || node.type === "block_image")
10
- return String(node.attrs?.alt || "");
9
+ if (node.type === "image" || node.type === "block_image") {
10
+ const alt = String(node.attrs?.alt || "");
11
+ const caption = node.type === "block_image" && typeof node.attrs?.caption === "string" ? node.attrs.caption : "";
12
+ return caption ? (alt ? `${alt} - ${caption}` : caption) : alt;
13
+ }
11
14
  if (node.type === "formula" || node.type === "block_formula")
12
15
  return String(node.attrs?.source || "");
13
16
  const separator = node.type === "table_cell" ? "\t" : node.type === "paragraph" || node.type === "heading"
@@ -45,6 +45,8 @@ export interface ClipboardRepresentations {
45
45
  }
46
46
  export interface ClipboardPipelineOptions {
47
47
  readonly ownerDocument: Document;
48
+ /** Passed to parseCanonicalListHtml for HTML payloads. Native SmartRTE fragments keep their stored metadata as-is. */
49
+ readonly imageMetadataAttributes?: readonly string[];
48
50
  readonly normalizers?: readonly SourceNormalizer[];
49
51
  /** Test/audit switch proving detection is never required for correctness. */
50
52
  readonly normalizerMode?: "detected" | "generic";
@@ -243,8 +243,18 @@ const blockXml = (block, context, listLevel = 0) => {
243
243
  }
244
244
  if (block.type === "table")
245
245
  return tableXml(block, context);
246
- if (block.type === "block_image")
247
- return paragraphXml([{ ...block, type: "image" }], block, context);
246
+ if (block.type === "block_image") {
247
+ const image = paragraphXml([{ ...block, type: "image" }], block, context);
248
+ const caption = typeof block.attrs?.caption === "string" ? block.attrs.caption : "";
249
+ if (!caption)
250
+ return image;
251
+ // Word's built-in "Caption" paragraph style, aligned like the image.
252
+ // This package writes no styles.xml (headings reference Heading1-6 the
253
+ // same undefined way), so the run also carries the caption look
254
+ // directly - italic, 9pt - and reads as a caption even where the style
255
+ // id resolves to Normal.
256
+ return `${image}<w:p><w:pPr>${paragraphProperties(block, '<w:pStyle w:val="Caption"/>')}</w:pPr><w:r><w:rPr><w:i/><w:sz w:val="18"/></w:rPr><w:t xml:space="preserve">${xmlEscape(caption)}</w:t></w:r></w:p>`;
257
+ }
248
258
  if (block.type === "block_formula")
249
259
  return paragraphXml([{ ...block, type: "formula" }], block, context);
250
260
  // Previously fell through to the generic fallback below (an empty
@@ -1,7 +1,7 @@
1
1
  import type { FormatFidelityLevel, FormatId } from "./codec.js";
2
2
  export type FidelityFormat = FormatId;
3
3
  export type FidelityLevel = FormatFidelityLevel;
4
- export type FidelityFeature = "inline-marks" | "colors-fonts-sizes" | "headings-alignment" | "blockquote-code" | "lists" | "checklists" | "tables" | "links" | "images-media" | "formulas" | "special-characters" | "page-break" | "line-height";
4
+ export type FidelityFeature = "inline-marks" | "colors-fonts-sizes" | "headings-alignment" | "blockquote-code" | "lists" | "checklists" | "tables" | "links" | "images-media" | "formulas" | "special-characters" | "page-break" | "line-height" | "image-captions";
5
5
  export interface FormatFidelityCapability {
6
6
  level: FidelityLevel;
7
7
  note: string;
@@ -95,7 +95,7 @@ export const builtInFormatFidelity = [
95
95
  {
96
96
  feature: "images-media",
97
97
  formats: {
98
- html: capability("semantic", "Images, audio, and video round-trip; host-only metadata may be lossy."),
98
+ html: capability("semantic", "Images, audio, and video round-trip. Host-defined block-image metadata (`data-*` attributes) is exported as stored, but on import is kept only for names in the `imageMetadataAttributes` allowlist passed to the parser; anything else is dropped."),
99
99
  markdown: capability("lossy", "Inline and block images round-trip as ![alt](src) (SS2.3: fixed a real silent-data-loss bug - markdownInlineText/markdownBlock's fallback for any atom node was an empty string, deleting images with no trace; images are now routed through atomToMarkdown/parsed back via a real image AST case). Audio and video degrade to a readable [video: url](url) link on export - the link and URL survive, but re-import produces a generic link, not a video/audio atom."),
100
100
  docx: capability("semantic", "Data-URL PNG/JPEG/GIF images embed as native Word media relationships; remote or unsupported sources fall back to a portable text marker recovered on import (SS2.1, re-verified). No canonical DOCX projection exists yet for video/audio (block_image only)."),
101
101
  pdf: capability("lossy", "Export is visual; semantic media import is unsupported."),
@@ -137,5 +137,14 @@ export const builtInFormatFidelity = [
137
137
  pdf: capability("full", "This package's actual 'Save as PDF' is a real browser print of the same HTML export, so it inherits the html row's real, correctly-spaced layout exactly - verified against the real print document's computed styles, not assumed."),
138
138
  },
139
139
  },
140
+ {
141
+ feature: "image-captions",
142
+ formats: {
143
+ html: capability("full", "A captioned block image exports as <figure data-smart-figure=\"true\"><img ...><figcaption>text</figcaption></figure> with every image attribute still on the <img>; any <figure> holding one image plus one <figcaption> (third-party or our own) imports back as one captioned block_image. Uncaptioned images export unchanged. Captions are plain text: marks and links inside an imported figcaption are flattened."),
144
+ markdown: capability("lossy", "Exported as an italic line directly under the image (`![alt](src)` then `*caption*`). Markdown has no caption syntax, so re-import yields an image followed by an italic paragraph, not a caption."),
145
+ docx: capability("lossy", "Exported as a paragraph in Word's built-in Caption style (plus direct italic 9pt formatting, since this exporter writes no styles.xml) directly after the image paragraph. DOCX import does not reconstruct the caption: mammoth places images inline inside paragraphs, so the caption returns as an ordinary paragraph."),
146
+ pdf: capability("full", "'Save as PDF' prints the HTML export, so the <figcaption> prints under the image exactly as in the html row."),
147
+ },
148
+ },
140
149
  ];
141
150
  export const getFormatFidelity = (feature, format) => builtInFormatFidelity.find((entry) => entry.feature === feature).formats[format];
@@ -28,3 +28,4 @@ export * from "./comments/index.js";
28
28
  export * from "./suggestions/index.js";
29
29
  export * from "./plugin/index.js";
30
30
  export * from "./collab/index.js";
31
+ export * from "./query/index.js";
@@ -28,3 +28,4 @@ export * from "./comments/index.js";
28
28
  export * from "./suggestions/index.js";
29
29
  export * from "./plugin/index.js";
30
30
  export * from "./collab/index.js";
31
+ export * from "./query/index.js";
@@ -4,7 +4,18 @@ export declare const serializeCanonicalListHtml: (document: SmartDocument, optio
4
4
  fragment?: boolean;
5
5
  renderFormulaHtml?: boolean;
6
6
  }) => string;
7
- export declare const parseCanonicalListHtml: (html: string) => SmartDocument;
7
+ export interface ParseCanonicalListHtmlOptions {
8
+ /**
9
+ * Host-defined `data-*` attributes to keep from each block image as
10
+ * `attrs.metadata` (atom/imageExtras.ts). Invalid names are ignored.
11
+ * Omitted or empty (the default): nothing is collected, exactly as
12
+ * before this option existed. A host that persists HTML and reloads it
13
+ * through this function directly must pass the same list it gives the
14
+ * editor, or the metadata is dropped on that path.
15
+ */
16
+ readonly imageMetadataAttributes?: readonly string[];
17
+ }
18
+ export declare const parseCanonicalListHtml: (html: string, options?: ParseCanonicalListHtmlOptions) => SmartDocument;
8
19
  /** Alignment and indent are unsupported in Markdown; block content survives semantically. */
9
20
  export declare const serializeCanonicalListMarkdown: (document: SmartDocument) => string;
10
21
  export declare const parseCanonicalListMarkdown: (markdown: string) => SmartDocument;
@@ -7,6 +7,7 @@ import { canonicalMarkAttrs, canonicalMarkOrder } from "../marks/canonical.js";
7
7
  import { occupancyGridFor } from "../table/grid.js";
8
8
  import { atomToHtml, atomToMarkdown } from "../atom/formats.js";
9
9
  import { sanitizeAtomSource } from "../atom/security.js";
10
+ import { filterImageMetadata, normalizeImageCaption, normalizeImageMetadataAllowlist } from "../atom/imageExtras.js";
10
11
  import { foundationListStyleForPresetDepth, isFoundationSmartListPreset } from "./presets.js";
11
12
  const escapeHtml = (value) => String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
13
  const attr = (node, name) => node.attrs?.find((candidate) => candidate.name === name)?.value;
@@ -39,6 +40,14 @@ const imageStyleAndLicenseAttrs = (node) => {
39
40
  };
40
41
  /** See serializeCanonicalListHtml's own doc comment for why this is a module-scoped flag rather than a threaded parameter. */
41
42
  let renderFormulaHtmlMode = false;
43
+ /** parseCanonicalListHtml's `imageMetadataAttributes` option - module-scoped for the same reason as renderFormulaHtmlMode, restored in `finally`. Empty (the default) collects nothing, matching pre-1.2 behaviour. */
44
+ let imageMetadataAllowlist = [];
45
+ const parsedImageMetadata = (node) => {
46
+ if (!imageMetadataAllowlist.length)
47
+ return {};
48
+ const metadata = filterImageMetadata((name) => attr(node, name), imageMetadataAllowlist);
49
+ return metadata ? { metadata } : {};
50
+ };
42
51
  const serializeInline = (node) => {
43
52
  if (!isTextNode(node)) {
44
53
  if (node.type === "hard_break")
@@ -443,7 +452,14 @@ const parseBlock = (node) => {
443
452
  // third-party block-level image paste, not just this app's own.
444
453
  ...(attr(node, "data-smart-href") ? { href: attr(node, "data-smart-href") } : {}),
445
454
  ...(attr(node, "data-smart-target") ? { target: attr(node, "data-smart-target") } : {}),
455
+ // atomToHtml has always written data-smart-align for a block image,
456
+ // but this branch never read it back, so an aligned image lost its
457
+ // alignment on every HTML save -> reload (docs/bugs/
458
+ // block-image-align-lost-on-html-reload.md). Same allowed values as
459
+ // the schema's own align validator.
460
+ ...(["left", "center", "right"].includes(attr(node, "data-smart-align") || "") ? { align: attr(node, "data-smart-align") } : {}),
446
461
  ...imageStyleAndLicenseAttrs(node),
462
+ ...parsedImageMetadata(node),
447
463
  } };
448
464
  }
449
465
  if (declaredAtom === "block_formula")
@@ -477,6 +493,7 @@ const parseBlock = (node) => {
477
493
  return { type: "block_image", id: generatedId(node, "image"), attrs: {
478
494
  src, alt: attr(node, "alt") || "", status: "ready",
479
495
  ...(width !== null ? { width } : {}), ...(height !== null ? { height } : {}),
496
+ ...parsedImageMetadata(node),
480
497
  } };
481
498
  }
482
499
  }
@@ -712,9 +729,48 @@ const GENERIC_INLINE_TAGS = ["span", "strong", "b", "em", "i", "u", "s", "strike
712
729
  * block image - so it's tried as a block first and only folded into the
713
730
  * inline run if that doesn't apply.
714
731
  */
715
- const parseMixedBlockContent = (nodes, blockTags) => {
732
+ const isWhitespaceText = (node) => node.nodeName === "#text" && !(node.value || "").trim();
733
+ const isImageSource = (node) => node.tagName === "img"
734
+ || node.tagName === "a" && elementChildren(node).length === 1 && elementChildren(node)[0].tagName === "img"
735
+ && (node.childNodes || []).every((child) => child.tagName || isWhitespaceText(child));
736
+ /**
737
+ * A captioned figure - exactly one image source (`<img>`, `<a><img></a>`, or
738
+ * this app's own `img[data-smart-type="block_image"]`) plus at most one
739
+ * `<figcaption>`, in either order, ignoring whitespace - becomes ONE
740
+ * block_image with `caption`. The image itself goes through parseBlock, so
741
+ * every existing attribute/width/license rule applies unchanged; the
742
+ * caption is the figcaption's plain text (marks and links flattened).
743
+ * Returns null for any other figure shape (several images, a table, no
744
+ * image, stray text) so the caller falls back to the transparent-container
745
+ * path. Deliberately does NOT depend on atomToHtml's data-smart-figure
746
+ * marker - third-party figures never carry it.
747
+ */
748
+ const parseFigure = (node) => {
749
+ const significant = (node.childNodes || []).filter((child) => !isWhitespaceText(child) && !isEditorUiNode(child));
750
+ const images = significant.filter(isImageSource);
751
+ const captions = significant.filter((child) => child.tagName === "figcaption");
752
+ if (images.length !== 1 || captions.length > 1 || images.length + captions.length !== significant.length)
753
+ return null;
754
+ const image = parseBlock(images[0]);
755
+ if (!image || image.type !== "block_image")
756
+ return null;
757
+ const caption = captions.length ? normalizeImageCaption(rawText(captions[0])) : undefined;
758
+ return caption ? { ...image, attrs: { ...image.attrs, caption } } : image;
759
+ };
760
+ /** A figcaption that has no image to attach to keeps its text as an ordinary paragraph instead of an "[Unsupported: figcaption]" placeholder. */
761
+ const figcaptionParagraph = (node) => {
762
+ const children = (node.childNodes || []).flatMap((child) => textWithMarks(child));
763
+ return children.some((child) => !isTextNode(child) || child.text.trim() !== "")
764
+ ? { type: "paragraph", id: generatedId(node, "p"), children }
765
+ : null;
766
+ };
767
+ const parseMixedBlockContent = (nodes, blockTags, insideUnrecognizedFigure = false) => {
716
768
  const result = [];
717
769
  let inlineRun = [];
770
+ // Index (in `result`) of a block_image that a directly following
771
+ // <figcaption> sibling may still attach to - see the figcaption branch
772
+ // below. Cleared by anything other than whitespace in between.
773
+ let captionTarget = -1;
718
774
  const flushInlineRun = () => {
719
775
  if (!inlineRun.length)
720
776
  return;
@@ -731,11 +787,16 @@ const parseMixedBlockContent = (nodes, blockTags) => {
731
787
  if (hasContent)
732
788
  result.push({ type: "paragraph", id: createNodeId(), children });
733
789
  };
790
+ const pushParsed = (parsed) => {
791
+ if (!parsed)
792
+ return;
793
+ result.push(parsed);
794
+ if (parsed.type === "block_image" && parsed.attrs?.caption === undefined)
795
+ captionTarget = result.length - 1;
796
+ };
734
797
  const pushBlock = (node) => {
735
798
  flushInlineRun();
736
- const parsed = parseBlock(node);
737
- if (parsed)
738
- result.push(parsed);
799
+ pushParsed(parseBlock(node));
739
800
  };
740
801
  nodes.forEach((node) => {
741
802
  // Matches textWithMarks's own first check - a UI-only element (e.g. a
@@ -746,10 +807,45 @@ const parseMixedBlockContent = (nodes, blockTags) => {
746
807
  if (isEditorUiNode(node))
747
808
  return;
748
809
  const tag = node.tagName;
810
+ const target = captionTarget;
811
+ if (!isWhitespaceText(node))
812
+ captionTarget = -1;
749
813
  if (!tag) {
750
814
  inlineRun.push(node);
751
815
  return;
752
816
  }
817
+ if (tag === "figcaption") {
818
+ // Legacy repair: before 1.2, a <figure> was unwrapped and its
819
+ // <figcaption> survived only as a verbatim "unknown" node, so every
820
+ // save wrote `<img ...><figcaption>...</figcaption>` as loose
821
+ // siblings (docs/bugs/figure-wrapper-lost-on-save.md). A figcaption
822
+ // directly after a still-uncaptioned block image (whitespace aside)
823
+ // is re-attached as that image's caption. Not inside a figure this
824
+ // parser already declined to treat as one captioned image (e.g. two
825
+ // images): there the caption describes the whole group, so it stays
826
+ // a paragraph rather than being pinned to the last image.
827
+ flushInlineRun();
828
+ const caption = normalizeImageCaption(rawText(node));
829
+ const image = target >= 0 && target === result.length - 1 ? result[target] : null;
830
+ if (!insideUnrecognizedFigure && image?.type === "block_image" && caption) {
831
+ result[target] = { ...image, attrs: { ...image.attrs, caption } };
832
+ }
833
+ else {
834
+ pushParsed(figcaptionParagraph(node));
835
+ }
836
+ return;
837
+ }
838
+ if (tag === "figure" && !attr(node, "data-smart-type")) {
839
+ const figure = parseFigure(node);
840
+ if (figure) {
841
+ flushInlineRun();
842
+ result.push(figure);
843
+ return;
844
+ }
845
+ flushInlineRun();
846
+ result.push(...parseMixedBlockContent(node.childNodes || [], blockTags, true));
847
+ return;
848
+ }
753
849
  // A bare third-party <div> (no data-smart-type) is genuinely
754
850
  // meaningless wrapping and should be unwrapped - but this app's own
755
851
  // round-tripped div-tagged atoms (block_formula, page_break; see
@@ -763,7 +859,7 @@ const parseMixedBlockContent = (nodes, blockTags) => {
763
859
  // children back.
764
860
  if (TRANSPARENT_CONTAINER_TAGS.includes(tag) && !attr(node, "data-smart-type")) {
765
861
  flushInlineRun();
766
- result.push(...parseMixedBlockContent(node.childNodes || [], blockTags));
862
+ result.push(...parseMixedBlockContent(node.childNodes || [], blockTags, insideUnrecognizedFigure));
767
863
  return;
768
864
  }
769
865
  if (blockTags.includes(tag))
@@ -773,7 +869,7 @@ const parseMixedBlockContent = (nodes, blockTags) => {
773
869
  const parsed = parseBlock(node);
774
870
  if (parsed && parsed.type !== "unknown") {
775
871
  flushInlineRun();
776
- result.push(parsed);
872
+ pushParsed(parsed);
777
873
  return;
778
874
  }
779
875
  }
@@ -785,13 +881,20 @@ const parseMixedBlockContent = (nodes, blockTags) => {
785
881
  flushInlineRun();
786
882
  return result;
787
883
  };
788
- export const parseCanonicalListHtml = (html) => {
789
- const fragment = parseFragment(html);
790
- const wrapper = elementChildren(fragment).find((node) => attr(node, "data-smart-document") === "true");
791
- const source = wrapper || fragment;
792
- const rootBlockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table", "hr", "img", "video", "audio"];
793
- const children = parseMixedBlockContent(source.childNodes || [], rootBlockTags);
794
- return { type: "doc", id: attr(source, "data-smart-id") || createNodeId(), children: children.length ? children : [{ type: "paragraph", id: createNodeId(), children: [] }] };
884
+ export const parseCanonicalListHtml = (html, options = {}) => {
885
+ const previous = imageMetadataAllowlist;
886
+ imageMetadataAllowlist = normalizeImageMetadataAllowlist(options.imageMetadataAttributes).names;
887
+ try {
888
+ const fragment = parseFragment(html);
889
+ const wrapper = elementChildren(fragment).find((node) => attr(node, "data-smart-document") === "true");
890
+ const source = wrapper || fragment;
891
+ const rootBlockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table", "hr", "img", "video", "audio"];
892
+ const children = parseMixedBlockContent(source.childNodes || [], rootBlockTags);
893
+ return { type: "doc", id: attr(source, "data-smart-id") || createNodeId(), children: children.length ? children : [{ type: "paragraph", id: createNodeId(), children: [] }] };
894
+ }
895
+ finally {
896
+ imageMetadataAllowlist = previous;
897
+ }
795
898
  };
796
899
  const plainText = (node) => (node.children || []).map((child) => isTextNode(child) ? child.text : child.type === "hard_break" ? "\n" : "").join("");
797
900
  const markdownInlineText = (node) => (node.children || []).map((child) => {
@@ -33,6 +33,18 @@ export declare class FoundationModelDomMapping implements ModelDomMapping {
33
33
  node: Node;
34
34
  offset: number;
35
35
  } | null;
36
+ /**
37
+ * Model offsets between block children count model children only; a
38
+ * renderer projection sitting between blocks (a block image's caption,
39
+ * a table's caption/colgroup, a checklist control) must not shift them.
40
+ * Offset k maps to just before the k-th model child. The end offset maps
41
+ * past the last model child and past any image caption trailing it, so a
42
+ * caret "after the image" lands below its caption, not between the two.
43
+ */
44
+ private blockBoundaryDomOffset;
45
+ private isImageCaptionProjection;
46
+ /** A DOM point inside (or on) a block image's caption projection reads as the boundary right after that image. */
47
+ private captionBoundaryPos;
36
48
  domToPos(node: Node, offset: number): SmartPos | null;
37
49
  isEditorUiNode(node: Node): boolean;
38
50
  }
@@ -248,7 +248,9 @@ export class FoundationModelDomMapping {
248
248
  return null;
249
249
  if (!this.isInlineOwner(model)) {
250
250
  const children = this.modelDomChildren(element);
251
- return pos.offset <= children.length ? { node: element, offset: pos.offset } : null;
251
+ if (pos.offset > children.length)
252
+ return null;
253
+ return { node: element, offset: this.blockBoundaryDomOffset(element, children, pos.offset) };
252
254
  }
253
255
  let remaining = pos.offset;
254
256
  const domChildren = this.modelDomChildren(element);
@@ -284,9 +286,46 @@ export class FoundationModelDomMapping {
284
286
  offset: lastModelChild ? [...element.childNodes].indexOf(lastModelChild) + 1 : 0,
285
287
  };
286
288
  }
289
+ /**
290
+ * Model offsets between block children count model children only; a
291
+ * renderer projection sitting between blocks (a block image's caption,
292
+ * a table's caption/colgroup, a checklist control) must not shift them.
293
+ * Offset k maps to just before the k-th model child. The end offset maps
294
+ * past the last model child and past any image caption trailing it, so a
295
+ * caret "after the image" lands below its caption, not between the two.
296
+ */
297
+ blockBoundaryDomOffset(element, children, offset) {
298
+ const all = [...element.childNodes];
299
+ if (offset < children.length)
300
+ return all.indexOf(children[offset]);
301
+ if (!children.length)
302
+ return 0;
303
+ let index = all.indexOf(children[children.length - 1]) + 1;
304
+ while (index < all.length && this.isImageCaptionProjection(all[index]))
305
+ index += 1;
306
+ return index;
307
+ }
308
+ isImageCaptionProjection(node) {
309
+ return node instanceof Element && node.getAttribute(SMART_PROJECTION_ATTRIBUTE) === "image-caption";
310
+ }
311
+ /** A DOM point inside (or on) a block image's caption projection reads as the boundary right after that image. */
312
+ captionBoundaryPos(node) {
313
+ const element = node.nodeType === node.ELEMENT_NODE ? node : node.parentElement;
314
+ const caption = element?.closest(`[${SMART_PROJECTION_ATTRIBUTE}="image-caption"]`);
315
+ if (!caption || !this.root?.contains(caption))
316
+ return null;
317
+ const imageId = caption.getAttribute("data-smart-caption-for");
318
+ const imagePath = imageId ? this.pathById.get(imageId) : undefined;
319
+ if (!imagePath?.length)
320
+ return null;
321
+ return { path: imagePath.slice(0, -1), offset: imagePath[imagePath.length - 1] + 1 };
322
+ }
287
323
  domToPos(node, offset) {
288
324
  if (!this.root || !this.document || this.isEditorUiNode(node))
289
325
  return null;
326
+ const captionPos = this.captionBoundaryPos(node);
327
+ if (captionPos)
328
+ return captionPos;
290
329
  const owner = this.domToNode(node);
291
330
  if (!owner)
292
331
  return null;
@@ -298,14 +337,17 @@ export class FoundationModelDomMapping {
298
337
  if (!path || !element)
299
338
  return null;
300
339
  if (!this.isInlineOwner(ownerNode)) {
340
+ const children = this.modelDomChildren(element);
301
341
  if (node !== element) {
302
342
  const direct = node.nodeType === node.ELEMENT_NODE ? node : node.parentElement;
303
343
  const child = direct?.closest(`[${SMART_NODE_ID_ATTRIBUTE}]`);
304
- const children = [...element.children].filter((candidate) => !this.isEditorUiNode(candidate));
305
344
  const index = child ? children.indexOf(child) : -1;
306
345
  return index >= 0 ? { path: [...path], offset: index + (offset > 0 ? 1 : 0) } : null;
307
346
  }
308
- return { path: [...path], offset };
347
+ // A raw DOM offset counts projections too; count only model children
348
+ // before it (see blockBoundaryDomOffset for the inverse).
349
+ const all = [...element.childNodes];
350
+ return { path: [...path], offset: children.filter((child) => all.indexOf(child) < offset).length };
309
351
  }
310
352
  if (node === element) {
311
353
  const direct = this.modelDomChildren(element);
@@ -0,0 +1 @@
1
+ export * from "./sectionContext.js";
@@ -0,0 +1 @@
1
+ export * from "./sectionContext.js";
@@ -0,0 +1,41 @@
1
+ import type { SmartDocument, SmartSelection } from "../types.js";
2
+ /**
3
+ * Read-only document queries for host apps (1.2.0) - "which section is the
4
+ * cursor in, and what does it say?" and "which images does this document
5
+ * already have?". Pure functions of (document, selection); the React
6
+ * runtime's getSectionContext/listImages are thin wrappers over these.
7
+ */
8
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
9
+ export interface SectionContext {
10
+ /** Nearest top-level heading at or above the cursor, or null when the cursor precedes every heading. */
11
+ heading: {
12
+ id: string;
13
+ level: HeadingLevel;
14
+ text: string;
15
+ } | null;
16
+ /** Plain text from that heading (exclusive) to the next top-level heading of the same or higher level. */
17
+ text: string;
18
+ /** True when `text` was cut to `maxChars`. */
19
+ truncated: boolean;
20
+ }
21
+ export interface SectionContextOptions {
22
+ /** Default 8000. */
23
+ maxChars?: number;
24
+ }
25
+ export interface DocumentImageInfo {
26
+ nodeId: string;
27
+ src: string;
28
+ alt: string;
29
+ caption?: string;
30
+ metadata?: Record<string, string>;
31
+ }
32
+ export declare const DEFAULT_SECTION_CONTEXT_MAX_CHARS = 8000;
33
+ /**
34
+ * Only top-level blocks start or end a section; a heading nested inside a
35
+ * table, list or blockquote is ordinary text of the section it sits in.
36
+ * With no meaningful cursor (`type: "none"` or an empty path) the start of
37
+ * the document is used.
38
+ */
39
+ export declare const getSectionContext: (document: SmartDocument, selection: SmartSelection | null | undefined, options?: SectionContextOptions) => SectionContext;
40
+ /** Every inline `image` and `block_image`, in document order. */
41
+ export declare const listDocumentImages: (document: SmartDocument) => DocumentImageInfo[];
@@ -0,0 +1,94 @@
1
+ import { isTextNode } from "../identity.js";
2
+ export const DEFAULT_SECTION_CONTEXT_MAX_CHARS = 8000;
3
+ const headingLevel = (node) => {
4
+ const level = Number(node.attrs?.level);
5
+ return (Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1);
6
+ };
7
+ const inlineText = (node) => (node.children || []).map((child) => isTextNode(child) ? child.text : child.type === "hard_break" ? "\n" : "").join("");
8
+ /**
9
+ * Text extraction skips atoms, except that a captioned block image becomes
10
+ * `[Figure: caption]` so a host sees the figures a section already has.
11
+ * Tables become tab-separated cells, one row per line.
12
+ */
13
+ const blockText = (node) => {
14
+ if (isTextNode(node))
15
+ return node.text;
16
+ if (node.type === "paragraph" || node.type === "heading" || node.type === "code_block")
17
+ return inlineText(node);
18
+ if (node.type === "block_image") {
19
+ return typeof node.attrs?.caption === "string" && node.attrs.caption ? `[Figure: ${node.attrs.caption}]` : "";
20
+ }
21
+ if (node.type === "table") {
22
+ return (node.children || []).map((row) => isTextNode(row) ? ""
23
+ : (row.children || []).map((cell) => isTextNode(cell) ? cell.text : childBlocksText(cell, " ")).join("\t")).join("\n");
24
+ }
25
+ if (node.type === "table_cell")
26
+ return childBlocksText(node, " ");
27
+ return childBlocksText(node, "\n");
28
+ };
29
+ const childBlocksText = (node, separator) => (node.children || []).map(blockText).filter((text) => text !== "").join(separator);
30
+ /**
31
+ * Only top-level blocks start or end a section; a heading nested inside a
32
+ * table, list or blockquote is ordinary text of the section it sits in.
33
+ * With no meaningful cursor (`type: "none"` or an empty path) the start of
34
+ * the document is used.
35
+ */
36
+ export const getSectionContext = (document, selection, options = {}) => {
37
+ const blocks = document.children;
38
+ const requested = Number(options.maxChars);
39
+ const maxChars = Number.isFinite(requested) && requested >= 0 ? Math.floor(requested) : DEFAULT_SECTION_CONTEXT_MAX_CHARS;
40
+ const cursor = selection && selection.type !== "none" && selection.head.path.length
41
+ ? Math.min(Math.max(0, selection.head.path[0]), Math.max(0, blocks.length - 1))
42
+ : 0;
43
+ let headingIndex = -1;
44
+ for (let index = cursor; index >= 0; index -= 1) {
45
+ const block = blocks[index];
46
+ if (block && !isTextNode(block) && block.type === "heading") {
47
+ headingIndex = index;
48
+ break;
49
+ }
50
+ }
51
+ const headingNode = headingIndex >= 0 ? blocks[headingIndex] : null;
52
+ const level = headingNode ? headingLevel(headingNode) : 6;
53
+ const parts = [];
54
+ for (let index = headingIndex + 1; index < blocks.length; index += 1) {
55
+ const block = blocks[index];
56
+ // With no heading above the cursor the "section" is the preamble, which
57
+ // any heading ends.
58
+ if (!isTextNode(block) && block.type === "heading" && headingLevel(block) <= level)
59
+ break;
60
+ const text = blockText(block);
61
+ if (text !== "")
62
+ parts.push(text);
63
+ }
64
+ const full = parts.join("\n\n");
65
+ const truncated = full.length > maxChars;
66
+ return {
67
+ heading: headingNode ? { id: headingNode.id, level, text: inlineText(headingNode) } : null,
68
+ text: truncated ? full.slice(0, maxChars) : full,
69
+ truncated,
70
+ };
71
+ };
72
+ /** Every inline `image` and `block_image`, in document order. */
73
+ export const listDocumentImages = (document) => {
74
+ const images = [];
75
+ const visit = (node) => {
76
+ if (isTextNode(node))
77
+ return;
78
+ if (node.type === "image" || node.type === "block_image") {
79
+ const caption = typeof node.attrs?.caption === "string" && node.attrs.caption ? node.attrs.caption : undefined;
80
+ const metadata = node.attrs?.metadata && typeof node.attrs.metadata === "object" && !Array.isArray(node.attrs.metadata)
81
+ ? { ...node.attrs.metadata } : undefined;
82
+ images.push({
83
+ nodeId: node.id,
84
+ src: String(node.attrs?.src || ""),
85
+ alt: String(node.attrs?.alt || ""),
86
+ ...(caption ? { caption } : {}),
87
+ ...(metadata ? { metadata } : {}),
88
+ });
89
+ }
90
+ node.children?.forEach(visit);
91
+ };
92
+ document.children.forEach(visit);
93
+ return images;
94
+ };
@@ -511,7 +511,13 @@ export class FoundationInputPipeline {
511
511
  this.renderer.render(this.editor.document, this.editor.selection);
512
512
  return;
513
513
  }
514
- const target = event.target instanceof Element ? event.target.closest("[data-smart-atomic]") : null;
514
+ // A block image's caption is a renderer projection after the <img>, not
515
+ // part of it; clicking the caption selects its image, the same as
516
+ // clicking the image itself.
517
+ const caption = event.target instanceof Element ? event.target.closest('[data-smart-projection="image-caption"]') : null;
518
+ const captionOwner = caption?.getAttribute("data-smart-caption-for");
519
+ const target = captionOwner ? this.renderer.mapping.nodeToDom(captionOwner)
520
+ : event.target instanceof Element ? event.target.closest("[data-smart-atomic]") : null;
515
521
  const mapped = target ? this.renderer.mapping.domToNode(target) : null;
516
522
  if (!mapped || isTextNode(mapped.node) || this.editor.schema.nodes[mapped.node.type]?.selectable !== true)
517
523
  return;
@@ -629,7 +635,7 @@ export class FoundationInputPipeline {
629
635
  }
630
636
  const payload = this.payloadFromTransfer(event.clipboardData);
631
637
  try {
632
- const parsed = parseClipboardPayload(payload, { ownerDocument: this.ownerDocument, schema: this.editor.schema });
638
+ const parsed = parseClipboardPayload(payload, { ownerDocument: this.ownerDocument, schema: this.editor.schema, imageMetadataAttributes: this.options.imageMetadataAttributes });
633
639
  this.options.onClipboardDiagnostic?.(reportParsedClipboard(payload, parsed));
634
640
  const fragment = remintClipboardFragmentIds(parsed.document, createNodeId);
635
641
  const result = insertClipboardFragment(this.editor.document, this.editor.selection, fragment, {
@@ -695,7 +701,7 @@ export class FoundationInputPipeline {
695
701
  this.commitClipboard({ ...insertion, operations: [...deletion.operations, ...insertion.operations] }, "drop");
696
702
  return;
697
703
  }
698
- const parsed = parseClipboardPayload(this.payloadFromTransfer(event.dataTransfer), { ownerDocument: this.ownerDocument, schema: this.editor.schema });
704
+ const parsed = parseClipboardPayload(this.payloadFromTransfer(event.dataTransfer), { ownerDocument: this.ownerDocument, schema: this.editor.schema, imageMetadataAttributes: this.options.imageMetadataAttributes });
699
705
  const fragment = remintClipboardFragmentIds(parsed.document, createNodeId);
700
706
  const result = insertClipboardFragment(this.editor.document, targetSelection, fragment, {
701
707
  schema: this.editor.schema, positions: this.editor.positions, idFactory: createNodeId,
@@ -56,10 +56,30 @@ export declare class FoundationSubtreeRenderer implements CanonicalSubtreeRender
56
56
  * must not leave a stale checkbox projection behind. Reconcile these
57
57
  * projections from the current model after every render; attribute writes
58
58
  * remain idempotent.
59
+ *
60
+ * The same single document walk also collects every captioned block
61
+ * image for syncImageCaptions, so captions add no second traversal.
59
62
  */
60
63
  private syncListProjections;
64
+ /**
65
+ * A block image's caption is a renderer projection - a non-editable
66
+ * sibling element placed immediately after the <img>, since an <img> can't
67
+ * hold children the way <table> holds its caption projection. Projection
68
+ * elements are already excluded from modelChildren/modelDomChildren, so
69
+ * the caption never enters the model or the selection mapping.
70
+ *
71
+ * diffElement moves, replaces and removes model elements without knowing
72
+ * about this sibling, so instead of patching every one of those paths this
73
+ * re-asserts the invariant after each render pass: every captioned image
74
+ * is followed directly by exactly one caption projection with the current
75
+ * text, and no other caption projection exists. Reuses (moves) an
76
+ * existing projection element rather than recreating it.
77
+ */
78
+ private syncImageCaptions;
61
79
  private announceSelectedLevel;
62
- render(document: SmartDocument, selection: SmartSelection): void;
80
+ render(document: SmartDocument, selection: SmartSelection, options?: {
81
+ syncDomSelection?: boolean;
82
+ }): void;
63
83
  /**
64
84
  * Renderer-integrated content-visibility (Phase 11 Tier 3): the naive
65
85
  * per-block experiment (stamping content-visibility:auto on every
@@ -840,12 +840,12 @@ export class FoundationSubtreeRenderer {
840
840
  return selection.anchorNode === anchor.node && selection.anchorOffset === anchor.offset
841
841
  && selection.focusNode === head.node && selection.focusOffset === head.offset;
842
842
  }
843
- restoreSelection(model) {
843
+ restoreSelection(model, syncDom = true) {
844
844
  const selection = this.root.ownerDocument.getSelection();
845
845
  if (!selection)
846
846
  return;
847
847
  if (model.type === "none") {
848
- if (selection.rangeCount)
848
+ if (syncDom && selection.rangeCount)
849
849
  selection.removeAllRanges();
850
850
  return;
851
851
  }
@@ -853,7 +853,7 @@ export class FoundationSubtreeRenderer {
853
853
  const head = this.mapping.posToDom(model.head);
854
854
  if (!anchor || !head)
855
855
  return;
856
- if (!this.selectionMatches(selection, anchor, head)) {
856
+ if (syncDom && !this.selectionMatches(selection, anchor, head)) {
857
857
  if (typeof selection.setBaseAndExtent === "function") {
858
858
  selection.setBaseAndExtent(anchor.node, anchor.offset, head.node, head.offset);
859
859
  }
@@ -958,8 +958,12 @@ export class FoundationSubtreeRenderer {
958
958
  * must not leave a stale checkbox projection behind. Reconcile these
959
959
  * projections from the current model after every render; attribute writes
960
960
  * remain idempotent.
961
+ *
962
+ * The same single document walk also collects every captioned block
963
+ * image for syncImageCaptions, so captions add no second traversal.
961
964
  */
962
965
  syncListProjections(document) {
966
+ const captions = new Map();
963
967
  const visit = (node, listDepth = 0) => {
964
968
  if (isTextNode(node))
965
969
  return;
@@ -968,10 +972,76 @@ export class FoundationSubtreeRenderer {
968
972
  if (element)
969
973
  this.syncNodeAttributes(element, node, listDepth);
970
974
  }
975
+ else if (node.type === "block_image" && typeof node.attrs?.caption === "string" && node.attrs.caption) {
976
+ captions.set(node.id, node);
977
+ }
971
978
  const childListDepth = node.type === "list" ? listDepth + 1 : listDepth;
972
979
  node.children?.forEach((child) => visit(child, childListDepth));
973
980
  };
974
981
  visit(document);
982
+ this.syncImageCaptions(captions);
983
+ }
984
+ /**
985
+ * A block image's caption is a renderer projection - a non-editable
986
+ * sibling element placed immediately after the <img>, since an <img> can't
987
+ * hold children the way <table> holds its caption projection. Projection
988
+ * elements are already excluded from modelChildren/modelDomChildren, so
989
+ * the caption never enters the model or the selection mapping.
990
+ *
991
+ * diffElement moves, replaces and removes model elements without knowing
992
+ * about this sibling, so instead of patching every one of those paths this
993
+ * re-asserts the invariant after each render pass: every captioned image
994
+ * is followed directly by exactly one caption projection with the current
995
+ * text, and no other caption projection exists. Reuses (moves) an
996
+ * existing projection element rather than recreating it.
997
+ */
998
+ syncImageCaptions(captions) {
999
+ const existing = new Map();
1000
+ this.root.querySelectorAll(`[${SMART_PROJECTION_ATTRIBUTE}="image-caption"]`).forEach((element) => {
1001
+ const owner = element.getAttribute("data-smart-caption-for") || "";
1002
+ if (!captions.has(owner) || existing.has(owner)) {
1003
+ element.remove();
1004
+ this.recordWrite(owner);
1005
+ }
1006
+ else
1007
+ existing.set(owner, element);
1008
+ });
1009
+ captions.forEach((node, id) => {
1010
+ const image = this.mapping.nodeToDom(id);
1011
+ if (!image?.parentNode || image === this.root)
1012
+ return;
1013
+ let caption = existing.get(id);
1014
+ if (!caption) {
1015
+ caption = image.ownerDocument.createElement("div");
1016
+ caption.setAttribute(SMART_PROJECTION_ATTRIBUTE, "image-caption");
1017
+ caption.setAttribute("data-smart-caption-for", id);
1018
+ caption.setAttribute("aria-hidden", "true");
1019
+ caption.setAttribute("contenteditable", "false");
1020
+ this.recordWrite(id);
1021
+ }
1022
+ if (image.nextSibling !== caption) {
1023
+ image.after(caption);
1024
+ this.recordWrite(id);
1025
+ }
1026
+ const text = String(node.attrs?.caption);
1027
+ if (caption.textContent !== text) {
1028
+ caption.textContent = text;
1029
+ this.recordWrite(id);
1030
+ }
1031
+ const align = node.attrs?.align === "left" || node.attrs?.align === "center" || node.attrs?.align === "right" ? String(node.attrs.align) : null;
1032
+ if (align)
1033
+ this.setAttribute(caption, "data-smart-align", align, id);
1034
+ else
1035
+ this.removeAttribute(caption, "data-smart-align", id);
1036
+ // Wrap to the image's own width when it has one, so a narrow image
1037
+ // doesn't get a page-wide caption line.
1038
+ const width = Number(node.attrs?.width);
1039
+ const maxWidth = Number.isFinite(width) && width > 0 ? `${width}px` : "";
1040
+ if (caption.style.maxWidth !== maxWidth) {
1041
+ caption.style.maxWidth = maxWidth;
1042
+ this.recordWrite(id);
1043
+ }
1044
+ });
975
1045
  }
976
1046
  announceSelectedLevel(before, after, selection) {
977
1047
  const previous = this.listItemDepths(before);
@@ -1004,7 +1074,8 @@ export class FoundationSubtreeRenderer {
1004
1074
  }
1005
1075
  this.liveRegion.textContent = `List level ${active[1].depth + 1}`;
1006
1076
  }
1007
- render(document, selection) {
1077
+ render(document, selection, options = {}) {
1078
+ const syncDom = options.syncDomSelection !== false;
1008
1079
  if (!this.current) {
1009
1080
  this.mapping.beginUpdate(document);
1010
1081
  document.children.forEach((node, index) => this.root.appendChild(this.createNode(node, [index])));
@@ -1013,12 +1084,12 @@ export class FoundationSubtreeRenderer {
1013
1084
  this.syncListProjections(document);
1014
1085
  this.syncTableAccessibility();
1015
1086
  this.modelById.set(document.id, document);
1016
- this.restoreSelection(selection);
1087
+ this.restoreSelection(selection, syncDom);
1017
1088
  this.syncCellSelectionProjection(selection);
1018
1089
  return;
1019
1090
  }
1020
1091
  if (this.current === document) {
1021
- this.restoreSelection(selection);
1092
+ this.restoreSelection(selection, syncDom);
1022
1093
  this.syncCellSelectionProjection(selection);
1023
1094
  this.syncContentVisibility(document, document, selection);
1024
1095
  return;
@@ -1033,7 +1104,7 @@ export class FoundationSubtreeRenderer {
1033
1104
  this.syncListProjections(document);
1034
1105
  if (structural)
1035
1106
  this.syncTableAccessibility();
1036
- this.restoreSelection(selection);
1107
+ this.restoreSelection(selection, syncDom);
1037
1108
  this.syncCellSelectionProjection(selection);
1038
1109
  this.syncContentVisibility(before, document, selection);
1039
1110
  this.announceSelectedLevel(before, document, selection);
@@ -6,7 +6,15 @@ export interface CanonicalSubtreeRenderer {
6
6
  readonly composingNodeId: string | null;
7
7
  readonly domWriteCount: number;
8
8
  readonly composingDomWriteCount: number;
9
- render(document: SmartDocument, selection: SmartSelection): void;
9
+ /**
10
+ * `syncDomSelection: false` renders and scrolls the selection into view
11
+ * without moving the browser selection into the editor - setting it
12
+ * would pull focus out of a host's own UI (e.g. a side panel calling
13
+ * insertImage). Default true.
14
+ */
15
+ render(document: SmartDocument, selection: SmartSelection, options?: {
16
+ syncDomSelection?: boolean;
17
+ }): void;
10
18
  beginComposition(nodeId: string): void;
11
19
  endComposition(): void;
12
20
  resetWriteCounters(): void;
@@ -45,4 +53,6 @@ export interface CanonicalInputPipelineOptions {
45
53
  onFiles?: (files: readonly File[], position: SmartSelection) => void;
46
54
  /** Privacy-safe clipboard telemetry; reports hashes and structure, never text. */
47
55
  onClipboardDiagnostic?: (report: ClipboardDiagnosticReport) => void;
56
+ /** Host-defined `data-*` attributes kept on pasted block images (see parseCanonicalListHtml's option of the same name). Default: none. */
57
+ imageMetadataAttributes?: readonly string[];
48
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartrte-core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Framework-agnostic document model and editing primitives for Smart RTE",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,12 +36,6 @@
36
36
  "README.md",
37
37
  "CHANGELOG.md"
38
38
  ],
39
- "scripts": {
40
- "build": "rm -rf dist && tsc -p tsconfig.json",
41
- "prepublishOnly": "npm run build",
42
- "test": "vitest run",
43
- "lint": "tsc -p tsconfig.json --noEmit"
44
- },
45
39
  "publishConfig": {
46
40
  "access": "public"
47
41
  },
@@ -63,5 +57,10 @@
63
57
  "devDependencies": {
64
58
  "typescript": "^5.6.2",
65
59
  "vitest": "^2.1.4"
60
+ },
61
+ "scripts": {
62
+ "build": "rm -rf dist && tsc -p tsconfig.json",
63
+ "test": "vitest run",
64
+ "lint": "tsc -p tsconfig.json --noEmit"
66
65
  }
67
- }
66
+ }