smartrte-react 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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ Additive release; depends on `smartrte-core@1.2.0`.
6
+
7
+ - **Image captions:** Media details has a new **Caption** field (block images only, after Alt text). The caption shows under the image, and clicking it reopens Media details on that field. Clearing the field removes the caption. Each edit is a single undo step. Content saved by earlier versions as `<img>` followed by a loose `<figcaption>` is repaired into a captioned image on load, and is rewritten as `<figure>` on its first save (see `smartrte-core`'s 1.2.0 entry). Right-clicking an image still opens Media details directly; there's no separate image context menu, so there are no "Add/Edit/Remove caption" menu items.
8
+ - **`insertImage(input, opts)`** on the editor handle, for inserting images from your app (e.g. a side panel). It inserts at the last caret position, using exactly the toolbar insert's placement rules (now shared in `insertion.ts`), or at the end. It does not take focus by default, and returns `{ ok: true, nodeId }` or `{ ok: false, reason: "read_only" | "invalid_image" | "no_valid_position" }` instead of throwing.
9
+ - **`imageMetadataAttributes` prop/runtime option:** an allowlist of host `data-*` attributes kept on block images through loading, paste, file import and `insertImage`. Invalid names are dropped with a development-mode warning. If you call `parseCanonicalListHtml` yourself (e.g. in a `replaceValue` wrapper), pass the same list there.
10
+ - **`getSectionContext()` and `listImages()`** on the editor handle. Both are read-only and allowed in `readOnly` mode.
11
+ - `CanonicalEditorRuntime.setReadOnly()`/`isReadOnly()`; the component keeps it in sync with the `readOnly` prop.
12
+ - `ExecuteOperationsOptions` gains `focus` (default `true`) and `selectNodeId`.
13
+
14
+ ## 1.1.1
15
+
16
+ - Fix `1.1.0`'s published package listing its `smartrte-core` dependency as the literal string `workspace:^` instead of a real semver range — publishing via plain `npm publish` skips pnpm's workspace-protocol rewrite (that only happens through `pnpm publish`), so any consumer installing `1.1.0` hit `npm error EUNSUPPORTEDPROTOCOL`. No code changes; `1.1.0` should be treated as broken and not installed.
17
+
3
18
  ## 1.1.0
4
19
 
5
20
  - Right-clicking inside a blockquote now opens a context menu with "Blockquote background colour," "Blockquote text colour," and "Blockquote border options" (style/width/colour for its left border — no per-side toggle, since a blockquote only ever shows one visible side) — previously right-clicking one opened nothing. A table cell nested inside a blockquote still shows cell options, not these. Depends on `smartrte-core@1.1.0`. 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
@@ -17,6 +17,9 @@ It pairs with [`smartrte-core`](https://www.npmjs.com/package/smartrte-core), a
17
17
  - [Capability presets](#capability-presets-table-onoff)
18
18
  - [Host-owned providers](#host-owned-providers-media-versions-comments-suggestions)
19
19
  - [Imperative handle](#imperative-handle-ref)
20
+ - [Image captions](#image-captions)
21
+ - [Inserting images from your app](#inserting-images-from-your-app)
22
+ - [Upgrading to 1.2.0](#upgrading-to-120)
20
23
  - [Import & export formats](#import--export-formats)
21
24
  - [Theming](#theming)
22
25
  - [Standalone / non-React embed](#standalone--non-react-embed)
@@ -88,6 +91,7 @@ The commonly-used `CanonicalAuthorityEditor` props:
88
91
  | `authorId` | `string` | `"anonymous"` | Attributed to new comment replies and suggestions. |
89
92
  | `renderFormulaHtml` | `boolean` | `false` | Bake real KaTeX-rendered HTML into `onHtmlChange`'s formula markup instead of an empty placeholder — turn this on if you render that HTML anywhere outside the editor (email, PDF export, a read-only view without KaTeX loaded). |
90
93
  | `onClipboardDiagnostic` | `(report: ClipboardDiagnosticReport) => void` | `undefined` | Inspect what a paste was parsed as / why it was rejected — useful while debugging a host's own copy sources. |
94
+ | `imageMetadataAttributes` | `string[]` | `[]` | Host-defined `data-*` attributes (e.g. `["data-ai-generated", "data-source"]`) that block images keep through loading, paste, import and `insertImage`, and write back out in HTML. Construction-time only. See [Inserting images from your app](#inserting-images-from-your-app). |
91
95
  | `placeholder` | `string` | `undefined` | Placeholder text shown when the editor is empty. |
92
96
  | `minHeight` / `maxHeight` | `number \| string` | `undefined` | Editing-surface height bounds. |
93
97
  | `readOnly` | `boolean` | `false` | Disables editing; toolbar tools become inert. |
@@ -252,11 +256,96 @@ interface SmartEditorHandle {
252
256
  restoreCheckpoint(checkpoint: SmartEditorCheckpoint): void;
253
257
  saveVersion(opts?: { label?: string; authorId?: string }): DocumentVersion;
254
258
  restoreVersion(version: DocumentVersion, opts?: { keepSelection?: boolean }): void;
259
+ // 1.2.0
260
+ insertImage(input: InsertImageInput, opts?: InsertImageOptions): InsertImageResult;
261
+ getSectionContext(opts?: { maxChars?: number }): SectionContext;
262
+ listImages(): DocumentImageInfo[];
255
263
  }
256
264
  ```
257
265
 
258
266
  `saveVersion`/`restoreVersion` are the same operations the toolbar's Version History panel calls — use them directly if you want your own save-version UI instead of (or alongside) the built-in one.
259
267
 
268
+ ## Image captions
269
+
270
+ Any block image can have a plain-text caption. Right-click an image (or use "Edit selected media") to open **Media details** and fill in **Caption**; the caption shows in small muted text under the image, and clicking it reopens Media details on that field. Clear the field to remove the caption. Inline images (inside a line of text) don't take captions.
271
+
272
+ In saved HTML a captioned image is a standard `<figure>`, with every image attribute still on the `<img>`:
273
+
274
+ ```html
275
+ <figure data-smart-figure="true"><img data-smart-type="block_image" src="…" alt="…"><figcaption>Caption text</figcaption></figure>
276
+ ```
277
+
278
+ Images without a caption are saved exactly as before. Pasting or loading any `<figure>` with one image and a `<figcaption>` (from a web page, a CMS, or SmartRTE itself) gives you a captioned image. Content saved by older versions as a loose `<img>` followed by `<figcaption>` is repaired on load, and the next save writes it back as a `<figure>`. Markdown exports the caption as an italic line under the image, DOCX as a paragraph in Word's Caption style, and plain-text copy as `alt - caption`; see `builtInFormatFidelity` (`image-captions`) for what comes back on import.
279
+
280
+ ## Inserting images from your app
281
+
282
+ `insertImage` puts an image (optionally captioned) where the user last had the caret — the same place the toolbar's image insert would — without uploading or rewriting `src`, and without taking focus from your own UI unless you ask:
283
+
284
+ ```tsx
285
+ const ref = useRef<SmartEditorHandle>(null);
286
+
287
+ function onUseFigure(src: string, caption: string) {
288
+ const result = ref.current?.insertImage(
289
+ { src, alt: caption, caption, metadata: { "data-ai-generated": "true" } },
290
+ );
291
+ if (result && !result.ok) showMessage(result.reason); // "read_only" | "invalid_image" | "no_valid_position"
292
+ }
293
+
294
+ <CanonicalAuthorityEditor ref={ref} imageMetadataAttributes={["data-ai-generated"]} />
295
+ ```
296
+
297
+ ```ts
298
+ interface InsertImageInput {
299
+ src: string; // used as given; must pass the editor's image URL policy
300
+ alt: string; // required unless decorative: true
301
+ decorative?: boolean;
302
+ caption?: string; // plain text, whitespace collapsed, capped at 500 characters
303
+ width?: number; height?: number;
304
+ align?: "left" | "center" | "right";
305
+ metadata?: Record<string, string>; // only names in imageMetadataAttributes are kept
306
+ }
307
+ interface InsertImageOptions {
308
+ position?: "selection" | "end"; // default "selection"; falls back to "end" if there's no usable caret
309
+ focus?: boolean; // default false
310
+ }
311
+ type InsertImageResult = { ok: true; nodeId: string } | { ok: false; reason: "read_only" | "invalid_image" | "no_valid_position" };
312
+ ```
313
+
314
+ The insert is one undo step and fires `onChange`/`onHtmlChange` like any edit; the new image is selected afterwards. It returns a result instead of throwing, so you can show a message such as "Unlock the editor to insert" for `read_only`.
315
+
316
+ **Image metadata.** `imageMetadataAttributes` lists the `data-*` attributes your app wants to attach to images (at most 10; each `data-` + lowercase kebab-case, at most 40 characters, not starting with the reserved `data-smart-`). They round-trip through editing, copy/paste and HTML as plain escaped text and are never shown in the editor UI. If your app parses saved HTML itself with `parseCanonicalListHtml` (from `smartrte-core/foundation`) — for example in a `replaceValue` wrapper — pass the same list there, or metadata is dropped on that path:
317
+
318
+ ```ts
319
+ parseCanonicalListHtml(html, { imageMetadataAttributes: ["data-ai-generated"] });
320
+ ```
321
+
322
+ **Reading context.** For features like "suggest a figure for this section":
323
+
324
+ ```ts
325
+ const { heading, text, truncated } = ref.current!.getSectionContext({ maxChars: 8000 });
326
+ // heading: nearest top-level heading at/above the caret ({ id, level, text }) or null
327
+ // text: that section's plain text; existing figures appear as "[Figure: caption]", tables as tab-separated rows
328
+ const images = ref.current!.listImages(); // [{ nodeId, src, alt, caption?, metadata? }] in document order
329
+ ```
330
+
331
+ Both are read-only and work in `readOnly` mode. The same logic is available without React as `getSectionContext(document, selection)` and `listDocumentImages(document)` from `smartrte-core/foundation`.
332
+
333
+ ## Upgrading to 1.2.0
334
+
335
+ 1.2.0 is additive: no props, handle methods or exports were removed or renamed. Upgrade both packages together, because `smartrte-react@1.2.0` requires `smartrte-core@^1.2.0`.
336
+
337
+ **What's new**
338
+ - Image captions on block images ([Image captions](#image-captions)).
339
+ - `insertImage`, `getSectionContext` and `listImages` on the editor handle ([Inserting images from your app](#inserting-images-from-your-app)).
340
+ - The `imageMetadataAttributes` prop.
341
+ - New types: `InsertImageInput`, `InsertImageOptions`, `InsertImageResult`, `SectionContext`, `SectionContextOptions` and `DocumentImageInfo` (from `smartrte-react`), and `ParseCanonicalListHtmlOptions` (from `smartrte-core/foundation`).
342
+
343
+ **On by default: captions.** You don't need to configure anything. Images without a caption save exactly as before, byte for byte. A captioned image saves as `<figure data-smart-figure="true">…<figcaption>…</figcaption></figure>`. If your app renders saved HTML outside the editor, style `figure`/`figcaption` there. One change applies to existing content: HTML saved by earlier versions as `<img>` followed by a loose `<figcaption>` now loads as a captioned image, and the next save rewrites it as a `<figure>`. Stored JSON documents are not repaired; the repair runs only when HTML is parsed.
344
+
345
+ **Opt-in: image metadata.** `data-*` attributes on images are dropped unless they're listed in `imageMetadataAttributes`, as in 1.1.x. The default is an empty list.
346
+
347
+ **Caveat: `parseCanonicalListHtml`.** The editor passes the allowlist on every path it owns: initial value, paste, drop, file import and `insertImage`. If your app calls `parseCanonicalListHtml` itself, for example in a `replaceValue` wrapper or server-side, pass `{ imageMetadataAttributes }` there too. Otherwise metadata is silently dropped on that path. Captions need no option there.
348
+
260
349
  ## Import & export formats
261
350
 
262
351
  The toolbar's "Import" and "Save as ..." tools cover HTML, Markdown, DOCX (Word), PDF, and the package's own JSON document format out of the box — no extra setup. DOCX import preserves real Word styling (fonts, colors, spacing) where possible; PDF export prints the same HTML the editor renders, so formulas, tables, and images all appear as they do live.
@@ -1,4 +1,4 @@
1
- import { type CanonicalInputPipeline, type ClipboardDiagnosticReport, type CanonicalSubtreeRenderer, type DocumentVersion, type FoundationEditor, type PersistedEditorDocument, type SmartMark, type SmartOperation, type SmartSelection, type SmartTransaction } from "smartrte-core/foundation";
1
+ import { type CanonicalInputPipeline, type ClipboardDiagnosticReport, type CanonicalSubtreeRenderer, type DocumentImageInfo, type DocumentVersion, type FoundationEditor, type PersistedEditorDocument, type SmartMark, type SmartOperation, type SmartSelection, type SectionContext, type SectionContextOptions, type SmartTransaction } from "smartrte-core/foundation";
2
2
  import { type EditorCapabilityPreset } from "./capabilityPresets.js";
3
3
  export interface SmartEditorCheckpoint {
4
4
  envelope: PersistedEditorDocument;
@@ -27,7 +27,40 @@ export interface SmartEditorHandle {
27
27
  restoreVersion(version: DocumentVersion, opts?: {
28
28
  keepSelection?: boolean;
29
29
  }): void;
30
+ /** Inserts a block image from the host app (e.g. a side panel) at the last caret position. Never throws for bad input: see InsertImageResult. */
31
+ insertImage(input: InsertImageInput, opts?: InsertImageOptions): InsertImageResult;
32
+ /** The section around the last caret position: nearest top-level heading above it and that section's plain text. Allowed in read-only mode. */
33
+ getSectionContext(opts?: SectionContextOptions): SectionContext;
34
+ /** Every block and inline image, in document order. */
35
+ listImages(): DocumentImageInfo[];
30
36
  }
37
+ export interface InsertImageInput {
38
+ /** Used as given - no upload, no data-URL conversion. Must pass the same image URL policy as every other image path. */
39
+ src: string;
40
+ /** Required unless `decorative: true`. */
41
+ alt: string;
42
+ decorative?: boolean;
43
+ /** Plain text; normalised (whitespace collapsed) and capped at 500 characters. */
44
+ caption?: string;
45
+ width?: number;
46
+ height?: number;
47
+ align?: "left" | "center" | "right";
48
+ /** Filtered by the editor's `imageMetadataAttributes` allowlist; anything else is dropped. */
49
+ metadata?: Record<string, string>;
50
+ }
51
+ export interface InsertImageOptions {
52
+ /** "selection" (default) inserts after the block at the last caret position, like the toolbar; "end" appends to the document. */
53
+ position?: "selection" | "end";
54
+ /** Default false: the host's own panel keeps focus. */
55
+ focus?: boolean;
56
+ }
57
+ export type InsertImageResult = {
58
+ ok: true;
59
+ nodeId: string;
60
+ } | {
61
+ ok: false;
62
+ reason: "read_only" | "invalid_image" | "no_valid_position";
63
+ };
31
64
  export interface SaveVersionOptions {
32
65
  label?: string;
33
66
  authorId?: string;
@@ -40,6 +73,10 @@ export interface ExecuteOperationsOptions {
40
73
  /** Put the caret in a newly-created inline owner after the operation. */
41
74
  selectionOwnerId?: string;
42
75
  selectionOffset?: number;
76
+ /** Select this (newly inserted) node as a node selection after the operation. */
77
+ selectNodeId?: string;
78
+ /** Focus the editor after a history-eligible commit. Default true (the long-standing behaviour); host APIs pass false so the host's panel keeps focus. */
79
+ focus?: boolean;
43
80
  }
44
81
  export interface CanonicalEditorRuntimeOptions {
45
82
  initialValue?: string | PersistedEditorDocument;
@@ -68,6 +105,15 @@ export interface CanonicalEditorRuntimeOptions {
68
105
  * it themselves (e.g. a read-only preview panel built from saved HTML).
69
106
  */
70
107
  renderFormulaHtml?: boolean;
108
+ /**
109
+ * Host-defined `data-*` attributes (e.g. `["data-ai-generated",
110
+ * "data-source"]`) that block images keep as `attrs.metadata` through
111
+ * HTML loading, paste and insertImage, and write back out on export.
112
+ * Names must match `data-[a-z0-9-]+`, may not start with `data-smart-`,
113
+ * at most 40 characters, at most 10 names; invalid names are dropped with
114
+ * a development-mode warning. Default: none, so no behaviour changes.
115
+ */
116
+ imageMetadataAttributes?: readonly string[];
71
117
  }
72
118
  /** Persistent, React-independent owner for one product editor instance. */
73
119
  export declare class CanonicalEditorRuntime implements SmartEditorHandle {
@@ -84,6 +130,11 @@ export declare class CanonicalEditorRuntime implements SmartEditorHandle {
84
130
  private readonly renderFormulaHtml;
85
131
  private htmlChangeTimer;
86
132
  private pendingHtmlDocument;
133
+ private readOnly;
134
+ /** Set only while executeOperations commits with `focus: false` - see the subscribe callback in mount(). */
135
+ private holdDomSelection;
136
+ /** The validated `imageMetadataAttributes` allowlist. Hosts that call parseCanonicalListHtml themselves should pass this same list. */
137
+ readonly imageMetadataAttributes: readonly string[];
87
138
  constructor(options?: CanonicalEditorRuntimeOptions);
88
139
  setCallbacks(onChange?: (change: SmartEditorChange) => void, onHtmlChange?: (html: string) => void): void;
89
140
  private scheduleHtmlChange;
@@ -98,6 +149,14 @@ export declare class CanonicalEditorRuntime implements SmartEditorHandle {
98
149
  getRevision(): number;
99
150
  focus(): void;
100
151
  executeOperations(operations: readonly SmartOperation[], opts?: ExecuteOperationsOptions): void;
152
+ /** Mirrors the component's `readOnly` prop so host-facing APIs (insertImage) can refuse writes. Does not itself change the editing surface. */
153
+ setReadOnly(readOnly: boolean): void;
154
+ isReadOnly(): boolean;
155
+ insertImage(input: InsertImageInput, opts?: InsertImageOptions): InsertImageResult;
156
+ /** Builds and validates block_image attrs from host input, or null if they would be invalid. */
157
+ private imageAttrsFrom;
158
+ getSectionContext(opts?: SectionContextOptions): SectionContext;
159
+ listImages(): DocumentImageInfo[];
101
160
  createCheckpoint(): SmartEditorCheckpoint;
102
161
  restoreCheckpoint(checkpoint: SmartEditorCheckpoint): void;
103
162
  /**
@@ -1,5 +1,10 @@
1
- import { createFoundationEditor, applyOperations, createInputPipeline, createNodeId, createSubtreeRenderer, createTransactionMap, foundationSchema, isTextNode, parseCanonicalListHtml, serializeCanonicalListHtml, } from "smartrte-core/foundation";
1
+ import { createFoundationEditor, applyOperations, atomDeclarations, filterImageMetadata, getSectionContext as sectionContextOf, listDocumentImages, normalizeImageCaption, normalizeImageMetadataAllowlist, sanitizeAtomSource, createInputPipeline, createNodeId, createSubtreeRenderer, createTransactionMap, foundationSchema, isTextNode, parseCanonicalListHtml, serializeCanonicalListHtml, } from "smartrte-core/foundation";
2
2
  import { capabilityPresetRegistry } from "./capabilityPresets.js";
3
+ import { planBlockAtomInsertion } from "./insertion.js";
4
+ const isDevelopment = () => {
5
+ const global = globalThis;
6
+ return global.process?.env?.NODE_ENV !== "production";
7
+ };
3
8
  const nodeAtPath = (root, path) => {
4
9
  let node = root;
5
10
  for (const index of path) {
@@ -46,10 +51,10 @@ const firstTextSelection = (document) => {
46
51
  const pos = visit(document, []) || { path: [], offset: 0 };
47
52
  return { type: "text", anchor: pos, head: pos };
48
53
  };
49
- const envelopeFrom = (value) => {
54
+ const envelopeFrom = (value, imageMetadataAttributes) => {
50
55
  if (typeof value === "object" && value)
51
56
  return structuredClone(value);
52
- const document = parseCanonicalListHtml(typeof value === "string" ? value : "<p></p>");
57
+ const document = parseCanonicalListHtml(typeof value === "string" ? value : "<p></p>", { imageMetadataAttributes });
53
58
  return { schemaVersion: foundationSchema.version, revision: 0, document };
54
59
  };
55
60
  const sameOperations = (transaction) => transaction.operations.length > 0;
@@ -82,7 +87,15 @@ export class CanonicalEditorRuntime {
82
87
  this.unsubscribe = null;
83
88
  this.htmlChangeTimer = null;
84
89
  this.pendingHtmlDocument = null;
85
- const envelope = envelopeFrom(options.initialValue);
90
+ this.readOnly = false;
91
+ /** Set only while executeOperations commits with `focus: false` - see the subscribe callback in mount(). */
92
+ this.holdDomSelection = false;
93
+ const allowlist = normalizeImageMetadataAllowlist(options.imageMetadataAttributes);
94
+ if (allowlist.rejected.length && isDevelopment()) {
95
+ console.warn(`[Smart RTE] imageMetadataAttributes: ignored invalid or excess names: ${allowlist.rejected.join(", ")}. Names must match data-[a-z0-9-]+, must not start with data-smart-, be at most 40 characters, and at most 10 names are kept.`);
96
+ }
97
+ this.imageMetadataAttributes = allowlist.names;
98
+ const envelope = envelopeFrom(options.initialValue, this.imageMetadataAttributes);
86
99
  // "full" (the default) intentionally passes no schema/commands/etc., so
87
100
  // FoundationEditor's own defaulting (`options.schema || foundationSchema`)
88
101
  // applies - every existing consumer who never sets `preset` is
@@ -138,9 +151,12 @@ export class CanonicalEditorRuntime {
138
151
  this.unmount();
139
152
  this.root = root;
140
153
  this.renderer = createSubtreeRenderer(root, { contentVisibility: this.contentVisibility });
141
- this.pipeline = createInputPipeline(this.editor, this.renderer, root, { onClipboardDiagnostic: this.onClipboardDiagnostic });
154
+ this.pipeline = createInputPipeline(this.editor, this.renderer, root, { onClipboardDiagnostic: this.onClipboardDiagnostic, imageMetadataAttributes: this.imageMetadataAttributes });
142
155
  this.unsubscribe = this.editor.subscribe((transaction, state) => {
143
- this.renderer?.render(this.editor.document, this.editor.selection);
156
+ // A commit that must not take focus (a host panel calling insertImage)
157
+ // also must not move the browser selection into the editor: in real
158
+ // browsers that alone moves focus to the contenteditable.
159
+ this.renderer?.render(this.editor.document, this.editor.selection, { syncDomSelection: !this.holdDomSelection });
144
160
  this.onChange?.({ revision: state.revision, documentChanged: sameOperations(transaction), transaction });
145
161
  if (sameOperations(transaction))
146
162
  this.scheduleHtmlChange(state.document);
@@ -211,7 +227,7 @@ export class CanonicalEditorRuntime {
211
227
  return;
212
228
  const beforeSelection = this.editor.selection;
213
229
  let selection = createTransactionMap(operations).mapSelection(beforeSelection);
214
- if (opts.selectionOwnerId || opts.preserveSelectionById) {
230
+ if (opts.selectionOwnerId || opts.preserveSelectionById || opts.selectNodeId) {
215
231
  const beforeDocument = this.editor.document;
216
232
  const preview = applyOperations(beforeDocument, operations);
217
233
  const beforeCellAnchor = beforeSelection.type === "cell" ? cellIdAt(beforeDocument, beforeSelection.anchor) : null;
@@ -240,7 +256,15 @@ export class CanonicalEditorRuntime {
240
256
  return null;
241
257
  return { path, offset: Math.min(forcedId ? opts.selectionOffset ?? 0 : pos.offset, inlineWidth(owner)) };
242
258
  };
243
- if (preservedCellSelection) {
259
+ if (opts.selectNodeId) {
260
+ const path = pathOfNode(preview, opts.selectNodeId);
261
+ if (path?.length) {
262
+ const parentPath = path.slice(0, -1);
263
+ const index = path[path.length - 1];
264
+ selection = { type: "node", anchor: { path: parentPath, offset: index }, head: { path: parentPath, offset: index + 1 } };
265
+ }
266
+ }
267
+ else if (preservedCellSelection) {
244
268
  // The cell selection was mapped above; do not reinterpret its
245
269
  // structural endpoints as a text selection.
246
270
  }
@@ -256,23 +280,88 @@ export class CanonicalEditorRuntime {
256
280
  selection = { ...beforeSelection, anchor, head };
257
281
  }
258
282
  }
259
- this.editor.transact((builder) => {
260
- builder.operations.push(...operations);
261
- builder.setSelection(selection);
262
- }, {
263
- source: "toolbar",
264
- addToHistory: opts.addToHistory ?? true,
265
- ...(opts.historyGroup ? { historyGroup: opts.historyGroup } : {}),
266
- });
283
+ this.holdDomSelection = opts.focus === false;
284
+ try {
285
+ this.editor.transact((builder) => {
286
+ builder.operations.push(...operations);
287
+ builder.setSelection(selection);
288
+ }, {
289
+ source: "toolbar",
290
+ addToHistory: opts.addToHistory ?? true,
291
+ ...(opts.historyGroup ? { historyGroup: opts.historyGroup } : {}),
292
+ });
293
+ }
294
+ finally {
295
+ this.holdDomSelection = false;
296
+ }
267
297
  // Skipped for a non-history (preview) operation: focusing the main
268
298
  // editor surface would steal keyboard/pointer focus away from whatever
269
299
  // UI is driving the preview (e.g. ColorPickerPopover's native color
270
300
  // input mid-drag) on every single preview frame - breaking Escape-to-
271
301
  // cancel and, in a real browser, potentially interrupting the drag
272
302
  // itself. A real, history-eligible commit still focuses as before.
273
- if (opts.addToHistory ?? true)
303
+ if ((opts.addToHistory ?? true) && opts.focus !== false)
274
304
  this.focus();
275
305
  }
306
+ /** Mirrors the component's `readOnly` prop so host-facing APIs (insertImage) can refuse writes. Does not itself change the editing surface. */
307
+ setReadOnly(readOnly) { this.readOnly = readOnly; }
308
+ isReadOnly() { return this.readOnly; }
309
+ insertImage(input, opts = {}) {
310
+ if (this.readOnly)
311
+ return { ok: false, reason: "read_only" };
312
+ const attrs = this.imageAttrsFrom(input);
313
+ if (!attrs)
314
+ return { ok: false, reason: "invalid_image" };
315
+ const nodeId = createNodeId();
316
+ // An unusable caret position (none yet, or one that can't take a block
317
+ // here) falls back to appending - still a successful insert.
318
+ const plan = (opts.position !== "end" ? planBlockAtomInsertion(this.editor, "block_image", attrs, nodeId, "selection") : null)
319
+ || planBlockAtomInsertion(this.editor, "block_image", attrs, nodeId, "end");
320
+ if (!plan)
321
+ return { ok: false, reason: "no_valid_position" };
322
+ this.executeOperations(plan.operations, { selectNodeId: nodeId, focus: opts.focus === true });
323
+ return { ok: true, nodeId };
324
+ }
325
+ /** Builds and validates block_image attrs from host input, or null if they would be invalid. */
326
+ imageAttrsFrom(input) {
327
+ if (!input || typeof input !== "object")
328
+ return null;
329
+ const decorative = input.decorative === true;
330
+ const alt = typeof input.alt === "string" ? input.alt : decorative ? "" : null;
331
+ const src = typeof input.src === "string" ? sanitizeAtomSource(input.src, { kind: "image" }) : null;
332
+ if (alt === null || !src)
333
+ return null;
334
+ const dimension = (value) => value === undefined || (typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 100000);
335
+ if (!dimension(input.width) || !dimension(input.height))
336
+ return null;
337
+ if (input.align !== undefined && input.align !== "left" && input.align !== "center" && input.align !== "right")
338
+ return null;
339
+ const caption = normalizeImageCaption(input.caption);
340
+ const metadata = input.metadata && typeof input.metadata === "object"
341
+ ? filterImageMetadata((name) => input.metadata[name], this.imageMetadataAttributes)
342
+ : undefined;
343
+ const attrs = {
344
+ src, alt, status: "ready",
345
+ ...(decorative ? { decorative: true } : {}),
346
+ ...(input.width !== undefined ? { width: input.width } : {}),
347
+ ...(input.height !== undefined ? { height: input.height } : {}),
348
+ ...(input.align ? { align: input.align } : {}),
349
+ ...(caption ? { caption } : {}),
350
+ ...(metadata ? { metadata } : {}),
351
+ };
352
+ const spec = this.editor.schema.nodes.block_image;
353
+ if (!spec)
354
+ return null;
355
+ const schemaValid = Object.entries(attrs).every(([name, value]) => spec.attributes?.[name]?.validate?.(value) ?? true);
356
+ const declaration = atomDeclarations.find((entry) => entry.type === "block_image");
357
+ return schemaValid && declaration?.validate(attrs) ? attrs : null;
358
+ }
359
+ getSectionContext(opts) {
360
+ return sectionContextOf(this.editor.document, this.editor.selection, opts);
361
+ }
362
+ listImages() {
363
+ return listDocumentImages(this.editor.document);
364
+ }
276
365
  createCheckpoint() {
277
366
  return {
278
367
  envelope: this.getValue(),
@@ -31,6 +31,14 @@ export interface CanonicalAuthorityEditorProps {
31
31
  */
32
32
  renderFormulaHtml?: boolean;
33
33
  onClipboardDiagnostic?: (report: ClipboardDiagnosticReport) => void;
34
+ /**
35
+ * Host-defined `data-*` attributes that block images keep as metadata
36
+ * through loading, paste, file import and `insertImage` - see
37
+ * canonicalEditorRuntime.ts's CanonicalEditorRuntimeOptions for the
38
+ * rules. Construction-time only, same contract as `preset`. Default:
39
+ * none.
40
+ */
41
+ imageMetadataAttributes?: readonly string[];
34
42
  /** Host-owned upload/search/remove boundary for canonical media insertion. */
35
43
  mediaProvider?: MediaProvider;
36
44
  /** Replaceable picker; the default only selects a local file. Used for video/audio, and for images when mediaManager is false. */
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
3
- import { atomDeclarations, continueListNumbering, createList, createNodeId, deleteAtom, executeMarkTool, indentBlockCommand, indentList, inlineToolDeclarations, isTextNode, insertAtom, insertTableColumnCommand, insertTableCommand, insertTableRowCommand, mergeTableCellsCommand, moveListItems, moveTableColumnCommand, moveTableRowCommand, setTableCellAttributesCommand, setTableColumnWidthCommand, setTableRowHeightCommand, parseCanonicalListHtml, parseCanonicalListMarkdown, removeTableColumnCommand, removeTableCommand, removeTableRowCommand, reportMarkApplication, resizeAtom, runAtomUpload, restartListNumbering, serializeCanonicalListHtml, serializeCanonicalListMarkdown, setListChecked, setListPreset, setListStyle, setTableHeaderCommand, setBlockAttributes, setBlockTypeCommand, moveBlockCommand, outdentBlockCommand, splitTableCellCommand, unwrapBlocks, wrapBlocks, unwrapList, updateAtom, outdentList, commentRangeFromSelection, rebaseCommentThreadsThroughTransaction, foundationSchema, resolvePos, suggestDeleteCommand, suggestInsertOperation, acceptSuggestionCommand, rejectSuggestionCommand, structuralSuggestionFromNode, acceptStructuralSuggestionCommand, rebaseStructuralSuggestionsThroughTransaction, } from "smartrte-core/foundation";
3
+ import { atomDeclarations, continueListNumbering, createList, createNodeId, deleteAtom, executeMarkTool, indentBlockCommand, indentList, inlineToolDeclarations, isTextNode, insertAtom, insertTableColumnCommand, insertTableCommand, insertTableRowCommand, mergeTableCellsCommand, moveListItems, moveTableColumnCommand, moveTableRowCommand, normalizeImageCaption, setTableCellAttributesCommand, setTableColumnWidthCommand, setTableRowHeightCommand, parseCanonicalListHtml, parseCanonicalListMarkdown, removeTableColumnCommand, removeTableCommand, removeTableRowCommand, reportMarkApplication, resizeAtom, runAtomUpload, restartListNumbering, serializeCanonicalListHtml, serializeCanonicalListMarkdown, setListChecked, setListPreset, setListStyle, setTableHeaderCommand, setBlockAttributes, setBlockTypeCommand, moveBlockCommand, outdentBlockCommand, splitTableCellCommand, unwrapBlocks, wrapBlocks, unwrapList, updateAtom, outdentList, commentRangeFromSelection, rebaseCommentThreadsThroughTransaction, foundationSchema, resolvePos, suggestDeleteCommand, suggestInsertOperation, acceptSuggestionCommand, rejectSuggestionCommand, structuralSuggestionFromNode, acceptStructuralSuggestionCommand, rebaseStructuralSuggestionsThroughTransaction, } from "smartrte-core/foundation";
4
4
  import { FOUNDATION_SMART_LIST_PRESETS as SMART_LIST_PRESETS } from "smartrte-core/foundation";
5
5
  import { ensureStyleSheet } from "../theme.js";
6
6
  import { VersionHistoryPanel } from "./VersionHistoryPanel.js";
@@ -26,6 +26,7 @@ import { ToolbarButton, ToolbarDropdown, ToolbarGroup, ToolbarMenuItem, MobileMo
26
26
  import { resolveToolbarTools } from "../toolbarTools.js";
27
27
  import { exportDocxDocument, importStyledDocxDocument, importPdfDocument } from "smartrte-core/foundation";
28
28
  import { printSmartDocumentAsPdf } from "../adapters/pdfPrint.js";
29
+ import { planBlockAtomInsertion } from "../insertion.js";
29
30
  import { CanonicalEditorRuntime, } from "../canonicalEditorRuntime.js";
30
31
  const listSelectionParts = (scope) => {
31
32
  if (scope.kind === "list-selection")
@@ -105,7 +106,7 @@ const parseBorderShorthand = (value) => {
105
106
  const isCollapsedTextSelection = (selection) => selection.type === "text" && selection.anchor.offset === selection.head.offset
106
107
  && selection.anchor.path.length === selection.head.path.length
107
108
  && selection.anchor.path.every((part, index) => part === selection.head.path[index]);
108
- export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEditor({ defaultValue, preset, renderFormulaHtml, onChange, onHtmlChange, onClipboardDiagnostic, mediaProvider, mediaPicker: MediaPicker = DefaultMediaPicker, mediaManager = true, tools, versionProvider, commentProvider, suggestionProvider, authorId, placeholder = "Type here…", minHeight = 200, maxHeight = 500, readOnly = false, className, onRuntime, }, forwardedRef) {
109
+ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEditor({ defaultValue, preset, renderFormulaHtml, onChange, onHtmlChange, onClipboardDiagnostic, imageMetadataAttributes, mediaProvider, mediaPicker: MediaPicker = DefaultMediaPicker, mediaManager = true, tools, versionProvider, commentProvider, suggestionProvider, authorId, placeholder = "Type here…", minHeight = 200, maxHeight = 500, readOnly = false, className, onRuntime, }, forwardedRef) {
109
110
  ensureStyleSheet();
110
111
  const rootRef = useRef(null);
111
112
  const importRef = useRef(null);
@@ -166,10 +167,13 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
166
167
  // selection.head differs from this value.
167
168
  const [linkOverlayDismissedAt, setLinkOverlayDismissedAt] = useState(null);
168
169
  if (!runtimeRef.current)
169
- runtimeRef.current = new CanonicalEditorRuntime({ initialValue: defaultValue, preset, renderFormulaHtml, onChange, onHtmlChange, onClipboardDiagnostic });
170
+ runtimeRef.current = new CanonicalEditorRuntime({ initialValue: defaultValue, preset, renderFormulaHtml, onChange, onHtmlChange, onClipboardDiagnostic, imageMetadataAttributes });
170
171
  const runtime = runtimeRef.current;
171
172
  const [, setEditorTick] = useState(0);
172
173
  runtime.setCallbacks(onChange, onHtmlChange);
174
+ // Kept in step on every render (like setCallbacks) so host-facing runtime
175
+ // APIs such as insertImage refuse writes the moment the prop flips.
176
+ runtime.setReadOnly(readOnly);
173
177
  useImperativeHandle(forwardedRef, () => runtime, [runtime]);
174
178
  useEffect(() => {
175
179
  const root = rootRef.current;
@@ -1129,70 +1133,12 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1129
1133
  root.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertText", data: char, bubbles: true, cancelable: true }));
1130
1134
  runtime.focus();
1131
1135
  };
1136
+ /** Insertion-point rules live in insertion.ts, shared with the host-facing runtime.insertImage. */
1132
1137
  const insertBlockAtom = (type, attrs, nodeId) => {
1133
- const declaration = atomDeclarations.find((entry) => entry.type === type);
1134
- const selection = runtime.editor.selection;
1135
- let parentId;
1136
- let index;
1137
- const resolved = runtime.editor.resolve({ pos: selection.head });
1138
- // A selected atom's own parent is only a legal insertion point for a
1139
- // *block* atom (image/video/audio/table etc., whose parent is the
1140
- // document root or a table cell). An inline atom (formula, inline image)
1141
- // lives inside a paragraph - inserting a block atom there would nest a
1142
- // block inside a paragraph, which insertAtom's schema validation rejects,
1143
- // silently no-opping. Fall through to the generic "insert after the
1144
- // current block" logic below for that case instead.
1145
- const selectedBlockAtom = (() => {
1146
- if (selection.type !== "node")
1147
- return false;
1148
- const scope = atomScope();
1149
- const atomType = scope.kind === "atomic-node" ? findNode(runtime.editor.document, scope.nodeId)?.type : undefined;
1150
- return atomType ? atomDeclarations.find((entry) => entry.type === atomType)?.group === "block" : false;
1151
- })();
1152
- if (selection.type === "cell") {
1153
- // A cell range resolves at the active cell's content boundary. Block
1154
- // atoms belong inside that cell, never as invalid siblings of the row.
1155
- parentId = resolved.nodeId;
1156
- index = resolved.pos.offset;
1157
- }
1158
- else if (resolved.kind === "structural" && resolved.pos.path.length === selection.anchor.path.length
1159
- && resolved.pos.path.every((part, pathIndex) => part === selection.anchor.path[pathIndex])) {
1160
- // A browser may expose a clicked block atom as a structural text range
1161
- // (the atom occupies one unit). Keep insertion in that same parent.
1162
- parentId = resolved.parent.id;
1163
- index = Math.max(selection.anchor.offset, selection.head.offset);
1164
- }
1165
- else if (selectedBlockAtom) {
1166
- parentId = resolved.parent.id;
1167
- index = resolved.pos.offset;
1168
- }
1169
- else {
1170
- const location = runtime.editor.positions.positionOf(resolved.nodeId);
1171
- parentId = location?.parent.id;
1172
- index = location ? location.pos.offset + 1 : undefined;
1173
- }
1174
- if (!parentId || index === undefined)
1175
- return false;
1176
- const operations = insertAtom(runtime.editor.document, atomScope(), {
1177
- declaration, nodeId, parentId, index, attrs,
1178
- }, blockContext());
1179
- if (!operations.length)
1138
+ const plan = planBlockAtomInsertion(runtime.editor, type, attrs, nodeId);
1139
+ if (!plan)
1180
1140
  return false;
1181
- // Block atoms cannot contain a caret. Keep an editable paragraph after a
1182
- // media node inserted at the end of its container (document root or table
1183
- // cell), and place the caret there so the next keystroke has a legal owner.
1184
- const parent = findNode(runtime.editor.document, parentId);
1185
- const contentRange = runtime.editor.positions.contentRangeOf(parentId);
1186
- let selectionOwnerId;
1187
- if (parent?.children && index >= parent.children.length && contentRange) {
1188
- selectionOwnerId = createNodeId();
1189
- operations.push({
1190
- type: "insertNode",
1191
- pos: { path: [...contentRange.from.path], offset: index + 1 },
1192
- node: { type: "paragraph", id: selectionOwnerId, children: [] },
1193
- });
1194
- }
1195
- runtime.executeOperations(operations, selectionOwnerId ? { selectionOwnerId, selectionOffset: 0 } : {});
1141
+ runtime.executeOperations(plan.operations, plan.selectionOwnerId ? { selectionOwnerId: plan.selectionOwnerId, selectionOffset: 0 } : {});
1196
1142
  return true;
1197
1143
  };
1198
1144
  const insertMediaFile = async (kind, file) => {
@@ -1438,14 +1384,21 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1438
1384
  runtime.focus();
1439
1385
  setLinkPopover(null);
1440
1386
  };
1441
- const openMediaDetails = (scope, node, element) => {
1387
+ const openMediaDetails = (scope, node, element, focusField) => {
1442
1388
  if (scope.kind !== "atomic-node")
1443
1389
  return;
1444
- const rect = (element || selectedAtomElement)?.getBoundingClientRect();
1390
+ const anchor = element || selectedAtomElement;
1391
+ // A captioned image's caption sits right under it; open below that, not over it.
1392
+ const caption = anchor?.nextElementSibling?.getAttribute("data-smart-projection") === "image-caption" ? anchor.nextElementSibling : null;
1393
+ const rect = anchor?.getBoundingClientRect();
1394
+ const bottom = caption?.getBoundingClientRect().bottom ?? rect?.bottom;
1445
1395
  setMediaDetailsPopover({
1446
- x: rect ? rect.left : 0, y: rect ? rect.bottom + 6 : 0, scope,
1396
+ x: rect ? rect.left : 0, y: bottom !== undefined ? bottom + 6 : 0, scope,
1397
+ showCaption: node.type === "block_image",
1398
+ ...(focusField ? { focusField } : {}),
1447
1399
  initial: {
1448
1400
  alt: String(node.attrs?.alt || ""),
1401
+ caption: typeof node.attrs?.caption === "string" ? node.attrs.caption : "",
1449
1402
  href: String(node.attrs?.href || ""),
1450
1403
  openInNewTab: node.attrs?.target === "_blank",
1451
1404
  width: typeof node.attrs?.width === "number" ? node.attrs.width : undefined,
@@ -1486,7 +1439,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1486
1439
  const applyMediaDetails = (draft) => {
1487
1440
  if (!mediaDetailsPopover)
1488
1441
  return;
1489
- const { scope } = mediaDetailsPopover;
1442
+ const { scope, showCaption } = mediaDetailsPopover;
1490
1443
  if (scope.kind !== "atomic-node") {
1491
1444
  setMediaDetailsPopover(null);
1492
1445
  return;
@@ -1504,6 +1457,9 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1504
1457
  runtime.executeOperations(updateAtom(runtime.editor.document, scope, {
1505
1458
  attrs: {
1506
1459
  alt: draft.alt,
1460
+ // Same single updateAtom as every other field, so it's one history
1461
+ // entry and travels through collaboration unchanged. Empty clears it.
1462
+ ...(showCaption ? { caption: normalizeImageCaption(draft.caption) } : {}),
1507
1463
  href: draft.href || undefined,
1508
1464
  target: draft.href && draft.openInNewTab ? "_blank" : undefined,
1509
1465
  width: draft.width ?? currentWidth,
@@ -1541,16 +1497,16 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1541
1497
  const runImport = async (file) => {
1542
1498
  if (/\.docx$/i.test(file.name) || file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") {
1543
1499
  const result = await importStyledDocxDocument(await file.arrayBuffer());
1544
- replaceCanonicalDocument(parseCanonicalListHtml(result.layoutHtml));
1500
+ replaceCanonicalDocument(parseCanonicalListHtml(result.layoutHtml, { imageMetadataAttributes: runtime.imageMetadataAttributes }));
1545
1501
  return;
1546
1502
  }
1547
1503
  if (/\.pdf$/i.test(file.name) || file.type === "application/pdf") {
1548
1504
  const result = await importPdfDocument(await file.arrayBuffer());
1549
- replaceCanonicalDocument(parseCanonicalListHtml(result.layoutHtml));
1505
+ replaceCanonicalDocument(parseCanonicalListHtml(result.layoutHtml, { imageMetadataAttributes: runtime.imageMetadataAttributes }));
1550
1506
  return;
1551
1507
  }
1552
1508
  const text = await file.text();
1553
- const document = /\.md(?:own)?$/i.test(file.name) ? parseCanonicalListMarkdown(text) : parseCanonicalListHtml(text);
1509
+ const document = /\.md(?:own)?$/i.test(file.name) ? parseCanonicalListMarkdown(text) : parseCanonicalListHtml(text, { imageMetadataAttributes: runtime.imageMetadataAttributes });
1554
1510
  replaceCanonicalDocument(document);
1555
1511
  };
1556
1512
  const runExport = (format) => {
@@ -1770,7 +1726,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1770
1726
  }, onCancel: () => {
1771
1727
  setTableSizePopover(null);
1772
1728
  runtime.focus();
1773
- } }), tableBorderPopover && _jsx(TableBorderPopover, { x: tableBorderPopover.x, y: tableBorderPopover.y, initial: tableBorderPopover.initial, recentColors: recentColors.border, onPreview: previewCellBorder, onApply: applyCellBorderCommit, onCancel: cancelTableBorderPopover }), blockquoteBorderPopover && _jsx(BlockquoteBorderPopover, { x: blockquoteBorderPopover.x, y: blockquoteBorderPopover.y, initial: blockquoteBorderPopover.initial, recentColors: recentColors.border, onPreview: previewBlockquoteBorder, onApply: applyBlockquoteBorderCommit, onCancel: cancelBlockquoteBorderPopover }), mediaDetailsPopover && _jsx(MediaDetailsPopover, { x: mediaDetailsPopover.x, y: mediaDetailsPopover.y, initial: mediaDetailsPopover.initial, onApply: applyMediaDetails, onCancel: () => { setMediaDetailsPopover(null); runtime.focus(); } }), formulaLibraryPopover && _jsx(FormulaLibraryPopover, { x: formulaLibraryPopover.x, y: formulaLibraryPopover.y, onInsert: insertFormulaFromLibrary, onCancel: () => { setFormulaLibraryPopover(null); runtime.focus(); } }), specialCharPopover && _jsx(SpecialCharacterPopover, { x: specialCharPopover.x, y: specialCharPopover.y, recentCharacters: recentSpecialChars, onInsert: insertSpecialCharacter, onCancel: () => { setSpecialCharPopover(null); runtime.focus(); } }), !readOnly && selectedTableElement && _jsx(TableResizeHandles, { tableElement: selectedTableElement, onResizeColumn: resizeTableColumn, onResizeRow: resizeTableRow }), contextMenu && _jsx(ContextMenu, { x: contextMenu.x, y: contextMenu.y, items: resolveContextMenuItems(), onDismiss: () => { setContextMenu(null); runtime.focus(); } }), _jsx("div", { ref: rootRef, className: "srte-editor", "data-placeholder": placeholder, contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", "aria-label": "Smart RTE editing surface", "aria-multiline": "true", onMouseDown: (event) => {
1729
+ } }), tableBorderPopover && _jsx(TableBorderPopover, { x: tableBorderPopover.x, y: tableBorderPopover.y, initial: tableBorderPopover.initial, recentColors: recentColors.border, onPreview: previewCellBorder, onApply: applyCellBorderCommit, onCancel: cancelTableBorderPopover }), blockquoteBorderPopover && _jsx(BlockquoteBorderPopover, { x: blockquoteBorderPopover.x, y: blockquoteBorderPopover.y, initial: blockquoteBorderPopover.initial, recentColors: recentColors.border, onPreview: previewBlockquoteBorder, onApply: applyBlockquoteBorderCommit, onCancel: cancelBlockquoteBorderPopover }), mediaDetailsPopover && _jsx(MediaDetailsPopover, { x: mediaDetailsPopover.x, y: mediaDetailsPopover.y, initial: mediaDetailsPopover.initial, showCaption: mediaDetailsPopover.showCaption, focusField: mediaDetailsPopover.focusField, onApply: applyMediaDetails, onCancel: () => { setMediaDetailsPopover(null); runtime.focus(); } }), formulaLibraryPopover && _jsx(FormulaLibraryPopover, { x: formulaLibraryPopover.x, y: formulaLibraryPopover.y, onInsert: insertFormulaFromLibrary, onCancel: () => { setFormulaLibraryPopover(null); runtime.focus(); } }), specialCharPopover && _jsx(SpecialCharacterPopover, { x: specialCharPopover.x, y: specialCharPopover.y, recentCharacters: recentSpecialChars, onInsert: insertSpecialCharacter, onCancel: () => { setSpecialCharPopover(null); runtime.focus(); } }), !readOnly && selectedTableElement && _jsx(TableResizeHandles, { tableElement: selectedTableElement, onResizeColumn: resizeTableColumn, onResizeRow: resizeTableRow }), contextMenu && _jsx(ContextMenu, { x: contextMenu.x, y: contextMenu.y, items: resolveContextMenuItems(), onDismiss: () => { setContextMenu(null); runtime.focus(); } }), _jsx("div", { ref: rootRef, className: "srte-editor", "data-placeholder": placeholder, contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", "aria-label": "Smart RTE editing surface", "aria-multiline": "true", onMouseDown: (event) => {
1774
1730
  // A Ctrl/Cmd+click on a link is meant to open it (onClick below),
1775
1731
  // not move the caret into it - but native mousedown's own default
1776
1732
  // action *is* the caret-repositioning, and mousedown fires (and
@@ -1802,6 +1758,24 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1802
1758
  window.open(href, "_blank", "noopener,noreferrer");
1803
1759
  return;
1804
1760
  }
1761
+ // Clicking a rendered caption: input.ts has already selected its
1762
+ // image (the caption is a projection, not part of the model); open
1763
+ // Media details straight on the Caption field.
1764
+ const captionTarget = event.target?.closest('[data-smart-projection="image-caption"]');
1765
+ const captionOwnerId = captionTarget?.getAttribute("data-smart-caption-for");
1766
+ if (captionOwnerId) {
1767
+ if (readOnly)
1768
+ return;
1769
+ const captionImage = runtime.surface.renderer?.mapping.nodeToDom(captionOwnerId);
1770
+ const captionNode = findNode(runtime.editor.document, captionOwnerId);
1771
+ const captionScope = runtime.editor.resolveScope({ want: "atomic-node" });
1772
+ setMediaContextMenu(null);
1773
+ if (!captionImage || !captionNode || captionScope.kind !== "atomic-node" || captionScope.nodeId !== captionOwnerId)
1774
+ return;
1775
+ setMediaResizeTarget({ nodeId: captionOwnerId });
1776
+ openMediaDetails(captionScope, captionNode, captionImage, "caption");
1777
+ return;
1778
+ }
1805
1779
  const imageTarget = event.target?.closest("[data-smart-type=\"image\"], [data-smart-type=\"block_image\"]");
1806
1780
  if (!imageTarget)
1807
1781
  return;
@@ -1825,7 +1799,10 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1825
1799
  // like right-clicking nothing. Mirrors clickListener's own
1826
1800
  // atom-selection logic
1827
1801
  // exactly.
1828
- const atomTarget = event.target?.closest("[data-smart-atomic]");
1802
+ // A right-click on an image's caption projection acts on the image.
1803
+ const captionOwnerId = event.target?.closest('[data-smart-projection="image-caption"]')?.getAttribute("data-smart-caption-for");
1804
+ const atomTarget = captionOwnerId ? runtime.surface.renderer?.mapping.nodeToDom(captionOwnerId) ?? null
1805
+ : event.target?.closest("[data-smart-atomic]");
1829
1806
  const mapped = atomTarget ? runtime.surface.renderer?.mapping.domToNode(atomTarget) : null;
1830
1807
  if (mapped && !isTextNode(mapped.node) && runtime.editor.schema.nodes[mapped.node.type]?.selectable === true) {
1831
1808
  const range = runtime.editor.positions.rangeOf(mapped.nodeId);
@@ -1840,7 +1817,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1840
1817
  const scope = runtime.editor.resolveScope({ want: "atomic-node" });
1841
1818
  setMediaResizeTarget({ nodeId: mapped.nodeId });
1842
1819
  if (scope.kind === "atomic-node")
1843
- openMediaDetails(scope, mapped.node, atomTarget || undefined);
1820
+ openMediaDetails(scope, mapped.node, atomTarget || undefined, captionOwnerId ? "caption" : undefined);
1844
1821
  return;
1845
1822
  }
1846
1823
  setMediaContextMenu({ x: event.clientX, y: event.clientY, nodeId: mapped.nodeId });
@@ -1,6 +1,8 @@
1
1
  export type MediaAlign = "left" | "center" | "right" | undefined;
2
2
  export interface MediaDetailsDraft {
3
3
  alt: string;
4
+ /** Plain-text caption shown under a block image. Ignored for inline images. */
5
+ caption: string;
4
6
  href: string;
5
7
  openInNewTab: boolean;
6
8
  width: number | undefined;
@@ -16,6 +18,10 @@ export interface MediaDetailsPopoverProps {
16
18
  x: number;
17
19
  y: number;
18
20
  initial: MediaDetailsDraft;
21
+ /** Show the Caption field - block images only; a caption under an inline image has no layout meaning. */
22
+ showCaption?: boolean;
23
+ /** Field to focus when the panel opens (e.g. from clicking a rendered caption). */
24
+ focusField?: "caption";
19
25
  onApply: (draft: MediaDetailsDraft) => void;
20
26
  onCancel: () => void;
21
27
  }
@@ -32,4 +38,4 @@ export interface MediaDetailsPopoverProps {
32
38
  * is the image editing surface, while resize handles remain available
33
39
  * independently around the selected image.
34
40
  */
35
- export declare function MediaDetailsPopover({ x, y, initial, onApply, onCancel }: MediaDetailsPopoverProps): import("react/jsx-runtime").JSX.Element;
41
+ export declare function MediaDetailsPopover({ x, y, initial, showCaption, focusField, onApply, onCancel }: MediaDetailsPopoverProps): import("react/jsx-runtime").JSX.Element;
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import { normalizeLinkInput } from "smartrte-core/legacy";
4
+ /** Matches smartrte-core's IMAGE_CAPTION_MAX_LENGTH. */
5
+ const CAPTION_MAX_LENGTH = 500;
4
6
  const LICENSE_TYPE_PRESETS = ["CC BY", "CC BY-SA", "CC BY-ND", "CC BY-NC", "CC BY-NC-SA", "CC BY-NC-ND", "CC0 (Public Domain)", "All rights reserved"];
5
7
  const inputStyle = {
6
8
  width: "100%", height: 34, boxSizing: "border-box", marginTop: 4, padding: "0 9px",
@@ -39,7 +41,7 @@ const alignButton = (current, value, label, onClick) => (_jsx("button", { type:
39
41
  * is the image editing surface, while resize handles remain available
40
42
  * independently around the selected image.
41
43
  */
42
- export function MediaDetailsPopover({ x, y, initial, onApply, onCancel }) {
44
+ export function MediaDetailsPopover({ x, y, initial, showCaption = false, focusField, onApply, onCancel }) {
43
45
  const [draft, setDraft] = useState(initial);
44
46
  const [licenseTypeCustom, setLicenseTypeCustom] = useState(!!initial.licenseType && !LICENSE_TYPE_PRESETS.includes(initial.licenseType));
45
47
  const [error, setError] = useState("");
@@ -47,6 +49,13 @@ export function MediaDetailsPopover({ x, y, initial, onApply, onCancel }) {
47
49
  const [placement, setPlacement] = useState(null);
48
50
  const onCancelRef = useRef(onCancel);
49
51
  onCancelRef.current = onCancel;
52
+ const captionInputRef = useRef(null);
53
+ // Focus only once the panel has been measured and made visible - a
54
+ // visibility:hidden input can't take focus.
55
+ useEffect(() => {
56
+ if (placement && focusField === "caption")
57
+ captionInputRef.current?.focus();
58
+ }, [placement !== null, focusField]);
50
59
  useLayoutEffect(() => {
51
60
  const el = rootRef.current;
52
61
  if (!el)
@@ -125,7 +134,7 @@ export function MediaDetailsPopover({ x, y, initial, onApply, onCancel }) {
125
134
  display: "flex",
126
135
  flexDirection: "column",
127
136
  gap: 10,
128
- }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between" }, children: [_jsx("div", { style: { fontWeight: 650 }, children: "Media details" }), _jsx("button", { type: "button", "aria-label": "Close media details", onClick: onCancel, style: { ...buttonStyle, minWidth: 26, minHeight: 26, padding: 0, border: 0, background: "transparent", fontSize: 18 }, children: "\u00D7" })] }), _jsxs("label", { style: labelStyle, children: ["Alt text", _jsx("input", { "data-srte-media-alt-input": "true", value: draft.alt, onChange: (event) => { update({ alt: event.target.value }); setError(""); }, style: { ...inputStyle, borderColor: error ? "var(--srte-danger)" : "var(--srte-input-border)" } })] }), error && _jsx("div", { role: "alert", style: { color: "var(--srte-danger)", fontSize: 12 }, children: error }), _jsxs("label", { style: labelStyle, children: ["Link", _jsx("input", { "data-srte-media-href-input": "true", value: draft.href, placeholder: "Paste a URL", inputMode: "url", autoCapitalize: "none", autoCorrect: "off", spellCheck: false, onChange: (event) => { update({ href: event.target.value }); setError(""); }, style: { ...inputStyle, borderColor: error ? "var(--srte-danger)" : "var(--srte-input-border)" } })] }), _jsxs("label", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 13, cursor: "pointer" }, children: [_jsx("input", { "data-srte-media-new-tab-input": "true", type: "checkbox", checked: draft.openInNewTab, disabled: !draft.href.trim(), onChange: (event) => update({ openInNewTab: event.target.checked }) }), "Open in a new tab"] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs("label", { style: { ...labelStyle, flex: 1 }, children: ["Width (px)", _jsx("input", { "data-srte-media-width-input": "true", type: "number", min: 16, value: draft.width ?? "", onChange: (event) => update({ width: event.target.value === "" ? undefined : Math.max(16, Number(event.target.value)) }), style: inputStyle })] }), _jsxs("label", { style: { ...labelStyle, flex: 1 }, children: ["Corner radius (px)", _jsx("input", { "data-srte-media-radius-input": "true", type: "number", min: 0, value: draft.borderRadius ?? "", onChange: (event) => update({ borderRadius: event.target.value === "" ? undefined : Math.max(0, Number(event.target.value)) }), style: inputStyle })] })] }), _jsxs("div", { style: labelStyle, children: ["Align", _jsxs("div", { style: { display: "flex", gap: 6, marginTop: 4 }, children: [alignButton(draft.align, undefined, "None", () => update({ align: undefined })), alignButton(draft.align, "left", "Left", () => update({ align: "left" })), alignButton(draft.align, "center", "Center", () => update({ align: "center" })), alignButton(draft.align, "right", "Right", () => update({ align: "right" }))] })] }), _jsx("div", { style: sectionHeaderStyle, children: "License" }), _jsxs("label", { style: labelStyle, children: ["Description", _jsx("input", { "data-srte-media-license-description-input": "true", value: draft.licenseDescription, onChange: (event) => update({ licenseDescription: event.target.value }), style: inputStyle })] }), _jsxs("label", { style: labelStyle, children: ["Source URL", _jsx("input", { "data-srte-media-license-source-input": "true", value: draft.licenseSourceUrl, onChange: (event) => update({ licenseSourceUrl: event.target.value }), style: inputStyle })] }), _jsxs("label", { style: labelStyle, children: ["Type", _jsxs("select", { "data-srte-media-license-type-select": "true", value: licenseTypeCustom ? "custom" : draft.licenseType, onChange: (event) => {
137
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between" }, children: [_jsx("div", { style: { fontWeight: 650 }, children: "Media details" }), _jsx("button", { type: "button", "aria-label": "Close media details", onClick: onCancel, style: { ...buttonStyle, minWidth: 26, minHeight: 26, padding: 0, border: 0, background: "transparent", fontSize: 18 }, children: "\u00D7" })] }), _jsxs("label", { style: labelStyle, children: ["Alt text", _jsx("input", { "data-srte-media-alt-input": "true", value: draft.alt, onChange: (event) => { update({ alt: event.target.value }); setError(""); }, style: { ...inputStyle, borderColor: error ? "var(--srte-danger)" : "var(--srte-input-border)" } })] }), error && _jsx("div", { role: "alert", style: { color: "var(--srte-danger)", fontSize: 12 }, children: error }), showCaption && (_jsxs("label", { style: labelStyle, children: ["Caption", _jsx("input", { ref: captionInputRef, "data-srte-media-caption-input": "true", value: draft.caption, maxLength: CAPTION_MAX_LENGTH, "aria-describedby": "srte-media-caption-help", onChange: (event) => update({ caption: event.target.value }), style: inputStyle }), _jsx("span", { id: "srte-media-caption-help", style: { display: "block", marginTop: 4, fontSize: 11, fontWeight: 400, opacity: 0.75 }, children: "Shown under the image. Alt text is read by screen readers." })] })), _jsxs("label", { style: labelStyle, children: ["Link", _jsx("input", { "data-srte-media-href-input": "true", value: draft.href, placeholder: "Paste a URL", inputMode: "url", autoCapitalize: "none", autoCorrect: "off", spellCheck: false, onChange: (event) => { update({ href: event.target.value }); setError(""); }, style: { ...inputStyle, borderColor: error ? "var(--srte-danger)" : "var(--srte-input-border)" } })] }), _jsxs("label", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 13, cursor: "pointer" }, children: [_jsx("input", { "data-srte-media-new-tab-input": "true", type: "checkbox", checked: draft.openInNewTab, disabled: !draft.href.trim(), onChange: (event) => update({ openInNewTab: event.target.checked }) }), "Open in a new tab"] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs("label", { style: { ...labelStyle, flex: 1 }, children: ["Width (px)", _jsx("input", { "data-srte-media-width-input": "true", type: "number", min: 16, value: draft.width ?? "", onChange: (event) => update({ width: event.target.value === "" ? undefined : Math.max(16, Number(event.target.value)) }), style: inputStyle })] }), _jsxs("label", { style: { ...labelStyle, flex: 1 }, children: ["Corner radius (px)", _jsx("input", { "data-srte-media-radius-input": "true", type: "number", min: 0, value: draft.borderRadius ?? "", onChange: (event) => update({ borderRadius: event.target.value === "" ? undefined : Math.max(0, Number(event.target.value)) }), style: inputStyle })] })] }), _jsxs("div", { style: labelStyle, children: ["Align", _jsxs("div", { style: { display: "flex", gap: 6, marginTop: 4 }, children: [alignButton(draft.align, undefined, "None", () => update({ align: undefined })), alignButton(draft.align, "left", "Left", () => update({ align: "left" })), alignButton(draft.align, "center", "Center", () => update({ align: "center" })), alignButton(draft.align, "right", "Right", () => update({ align: "right" }))] })] }), _jsx("div", { style: sectionHeaderStyle, children: "License" }), _jsxs("label", { style: labelStyle, children: ["Description", _jsx("input", { "data-srte-media-license-description-input": "true", value: draft.licenseDescription, onChange: (event) => update({ licenseDescription: event.target.value }), style: inputStyle })] }), _jsxs("label", { style: labelStyle, children: ["Source URL", _jsx("input", { "data-srte-media-license-source-input": "true", value: draft.licenseSourceUrl, onChange: (event) => update({ licenseSourceUrl: event.target.value }), style: inputStyle })] }), _jsxs("label", { style: labelStyle, children: ["Type", _jsxs("select", { "data-srte-media-license-type-select": "true", value: licenseTypeCustom ? "custom" : draft.licenseType, onChange: (event) => {
129
138
  if (event.target.value === "custom") {
130
139
  setLicenseTypeCustom(true);
131
140
  return;
package/dist/index.d.ts CHANGED
@@ -6,7 +6,8 @@ export type { EditorCapabilityPreset } from './capabilityPresets.js';
6
6
  export type { ToolbarTools } from './toolbarTools.js';
7
7
  export { DEFAULT_TOOLBAR_TOOLS, resolveToolbarTools } from './toolbarTools.js';
8
8
  export { CanonicalEditorRuntime, createCanonicalEditorRuntime } from './canonicalEditorRuntime.js';
9
- export type { SmartEditorChange, SmartEditorCheckpoint, SmartEditorHandle } from './canonicalEditorRuntime.js';
9
+ export type { SmartEditorChange, SmartEditorCheckpoint, SmartEditorHandle, InsertImageInput, InsertImageOptions, InsertImageResult } from './canonicalEditorRuntime.js';
10
+ export type { DocumentImageInfo, SectionContext, SectionContextOptions } from 'smartrte-core/foundation';
10
11
  export type { MediaManagerAdapter, MediaItem, MediaSearchQuery } from './components/MediaManager.js';
11
12
  export type { MediaFilters, MediaKind, MediaProvider, UploadOptions } from './mediaProvider.js';
12
13
  export type { VersionListEntry, VersionProvider } from './versionProvider.js';
@@ -0,0 +1,22 @@
1
+ import { type FoundationEditor, type SmartOperation } from "smartrte-core/foundation";
2
+ export type BlockAtomType = "block_image" | "video" | "audio" | "divider" | "page_break";
3
+ export interface BlockAtomInsertion {
4
+ operations: SmartOperation[];
5
+ /** Set when a trailing paragraph was added (the atom landed at the end of its container): the caret belongs there. */
6
+ selectionOwnerId?: string;
7
+ }
8
+ /**
9
+ * Where a block atom goes, shared by the toolbar/media-library insert and the
10
+ * host-facing `insertImage` API so both behave identically:
11
+ * - caret inside a paragraph (or any text block) -> after that block;
12
+ * - a selected block atom -> after it;
13
+ * - a table cell selection -> inside the cell.
14
+ * `position: "end"` appends to the document root instead.
15
+ *
16
+ * Block atoms cannot hold a caret, so an atom placed at the end of its
17
+ * container (document root or table cell) is followed by a new empty
18
+ * paragraph whose id is returned as `selectionOwnerId`.
19
+ *
20
+ * Returns null when no legal insertion point exists for that request.
21
+ */
22
+ export declare const planBlockAtomInsertion: (editor: FoundationEditor, type: BlockAtomType, attrs: Record<string, unknown>, nodeId: string, position?: "selection" | "end") => BlockAtomInsertion | null;
@@ -0,0 +1,95 @@
1
+ import { atomDeclarations, createNodeId, insertAtom, isTextNode, } from "smartrte-core/foundation";
2
+ const findNode = (root, id) => {
3
+ if (isTextNode(root))
4
+ return null;
5
+ if (root.id === id)
6
+ return root;
7
+ for (const child of root.children || []) {
8
+ const found = findNode(child, id);
9
+ if (found)
10
+ return found;
11
+ }
12
+ return null;
13
+ };
14
+ /**
15
+ * Where a block atom goes, shared by the toolbar/media-library insert and the
16
+ * host-facing `insertImage` API so both behave identically:
17
+ * - caret inside a paragraph (or any text block) -> after that block;
18
+ * - a selected block atom -> after it;
19
+ * - a table cell selection -> inside the cell.
20
+ * `position: "end"` appends to the document root instead.
21
+ *
22
+ * Block atoms cannot hold a caret, so an atom placed at the end of its
23
+ * container (document root or table cell) is followed by a new empty
24
+ * paragraph whose id is returned as `selectionOwnerId`.
25
+ *
26
+ * Returns null when no legal insertion point exists for that request.
27
+ */
28
+ export const planBlockAtomInsertion = (editor, type, attrs, nodeId, position = "selection") => {
29
+ const declaration = atomDeclarations.find((entry) => entry.type === type);
30
+ const ctx = { schema: editor.schema, positions: editor.positions };
31
+ const atomScope = () => editor.resolveScope({ want: "atomic-node" });
32
+ let parentId;
33
+ let index;
34
+ if (position === "end") {
35
+ parentId = editor.document.id;
36
+ index = editor.document.children.length;
37
+ }
38
+ else {
39
+ const selection = editor.selection;
40
+ const resolved = editor.resolve({ pos: selection.head });
41
+ // A selected atom's own parent is only a legal insertion point for a
42
+ // *block* atom (image/video/audio/table etc., whose parent is the
43
+ // document root or a table cell). An inline atom (formula, inline image)
44
+ // lives inside a paragraph - inserting a block atom there would nest a
45
+ // block inside a paragraph, which insertAtom's schema validation rejects,
46
+ // silently no-opping. Fall through to the generic "insert after the
47
+ // current block" logic below for that case instead.
48
+ const selectedBlockAtom = (() => {
49
+ if (selection.type !== "node")
50
+ return false;
51
+ const scope = atomScope();
52
+ const atomType = scope.kind === "atomic-node" ? findNode(editor.document, scope.nodeId)?.type : undefined;
53
+ return atomType ? atomDeclarations.find((entry) => entry.type === atomType)?.group === "block" : false;
54
+ })();
55
+ if (selection.type === "cell") {
56
+ // A cell range resolves at the active cell's content boundary. Block
57
+ // atoms belong inside that cell, never as invalid siblings of the row.
58
+ parentId = resolved.nodeId;
59
+ index = resolved.pos.offset;
60
+ }
61
+ else if (resolved.kind === "structural" && resolved.pos.path.length === selection.anchor.path.length
62
+ && resolved.pos.path.every((part, pathIndex) => part === selection.anchor.path[pathIndex])) {
63
+ // A browser may expose a clicked block atom as a structural text range
64
+ // (the atom occupies one unit). Keep insertion in that same parent.
65
+ parentId = resolved.parent.id;
66
+ index = Math.max(selection.anchor.offset, selection.head.offset);
67
+ }
68
+ else if (selectedBlockAtom) {
69
+ parentId = resolved.parent.id;
70
+ index = resolved.pos.offset;
71
+ }
72
+ else {
73
+ const location = editor.positions.positionOf(resolved.nodeId);
74
+ parentId = location?.parent.id;
75
+ index = location ? location.pos.offset + 1 : undefined;
76
+ }
77
+ }
78
+ if (!parentId || index === undefined)
79
+ return null;
80
+ const operations = insertAtom(editor.document, atomScope(), { declaration, nodeId, parentId, index, attrs }, ctx);
81
+ if (!operations.length)
82
+ return null;
83
+ const parent = findNode(editor.document, parentId);
84
+ const contentRange = editor.positions.contentRangeOf(parentId);
85
+ if (parent?.children && index >= parent.children.length && contentRange) {
86
+ const selectionOwnerId = createNodeId();
87
+ operations.push({
88
+ type: "insertNode",
89
+ pos: { path: [...contentRange.from.path], offset: index + 1 },
90
+ node: { type: "paragraph", id: selectionOwnerId, children: [] },
91
+ });
92
+ return { operations, selectionOwnerId };
93
+ }
94
+ return { operations };
95
+ };
package/dist/theme.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export type SrteTheme = 'light' | 'dark';
2
- export declare const SRTE_DEFAULT_CSS = "\n.srte-editor {\n --srte-background: var(--card, #ffffff);\n --srte-canvas: var(--background, #ffffff);\n --srte-foreground: var(--foreground, #0f172a);\n --srte-muted: var(--muted, #f1f5f9);\n --srte-muted-foreground: var(--muted-foreground, #64748b);\n --srte-ring: var(--ring, #0284c7);\n --srte-radius: var(--radius, 0.625rem);\n --srte-bg: var(--srte-canvas);\n --srte-text: var(--srte-foreground);\n --srte-text-muted: var(--srte-muted-foreground);\n --srte-border: var(--border, #e2e8f0);\n --srte-border-light: var(--srte-border);\n --srte-toolbar-bg: var(--srte-background);\n --srte-input-bg: var(--srte-background);\n --srte-input-text: var(--srte-foreground);\n --srte-input-border: var(--srte-border);\n --srte-modal-backdrop: rgba(0, 0, 0, 0.35);\n --srte-modal-backdrop-filter: blur(2px);\n --srte-modal-bg: var(--card, #ffffff);\n --srte-modal-text: var(--foreground, #000000);\n --srte-menu-bg: var(--srte-background);\n --srte-menu-text: var(--srte-foreground);\n --srte-menu-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);\n --srte-accent: #0284c7;\n --srte-accent-bg: rgba(2, 132, 199, 0.12);\n --srte-danger: #dc2626;\n --srte-primary: #2563eb;\n /* --srte-primary on --srte-accent-bg measures 4.43:1, under WCAG AA's\n 4.5:1 for normal text - used only for the pressed toolbar-button state\n (see .srte-tool-button[aria-pressed=\"true\"]), not a replacement for\n --srte-primary generally. */\n --srte-primary-pressed: #1d4ed8;\n --srte-on-primary: #ffffff;\n --srte-cancel-bg: #f3f4f6;\n --srte-code-bg: #f6f8fa;\n --srte-code-text: #24292f;\n}\n.srte-editor.srte-dark {\n --srte-background: var(--card, #1e293b);\n --srte-canvas: var(--background, #0f172a);\n --srte-foreground: var(--foreground, #f8fafc);\n --srte-muted: var(--muted, #334155);\n --srte-muted-foreground: var(--muted-foreground, #94a3b8);\n --srte-ring: var(--ring, #38bdf8);\n --srte-bg: var(--srte-canvas);\n --srte-text: var(--srte-foreground);\n --srte-text-muted: var(--srte-muted-foreground);\n --srte-border: var(--border, #334155);\n --srte-border-light: var(--srte-border);\n --srte-toolbar-bg: var(--srte-background);\n --srte-input-bg: var(--srte-background);\n --srte-input-text: var(--srte-foreground);\n --srte-input-border: var(--srte-border);\n --srte-modal-backdrop: rgba(0, 0, 0, 0.22);\n --srte-modal-backdrop-filter: blur(10px) saturate(0.9);\n --srte-modal-bg: var(--card, #1e293b);\n --srte-modal-text: var(--foreground, #e0e0e0);\n --srte-menu-bg: var(--srte-background);\n --srte-menu-text: var(--srte-foreground);\n --srte-menu-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);\n --srte-accent: #38bdf8;\n --srte-accent-bg: rgba(56, 189, 248, 0.16);\n --srte-danger: #ef4444;\n --srte-primary: #3b82f6;\n --srte-primary-pressed: var(--srte-primary);\n --srte-on-primary: #ffffff;\n --srte-cancel-bg: #333333;\n --srte-code-bg: #111827;\n --srte-code-text: #e5e7eb;\n}\n.srte-editor {\n border-radius: var(--srte-radius);\n font-family: \"IBM Plex Sans\", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n container: srte-editor / inline-size;\n}\n.srte-canonical-authority > .srte-editor[contenteditable] {\n width: 100%;\n padding: 16px 20px;\n box-sizing: border-box;\n border: 1px solid var(--srte-border);\n border-radius: 0 0 var(--srte-radius) var(--srte-radius);\n outline: none;\n background: var(--srte-canvas);\n color: var(--srte-foreground);\n caret-color: var(--srte-foreground);\n line-height: 1.6;\n}\n.srte-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 4px;\n width: 100%;\n min-height: 48px;\n padding: 8px;\n box-sizing: border-box;\n border-bottom: 1px solid var(--srte-border);\n background: var(--srte-background);\n color: var(--srte-foreground);\n position: sticky;\n top: 0;\n z-index: 10;\n}\n.srte-toolbar-group {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n min-width: 0;\n}\n.srte-toolbar-group + .srte-toolbar-group::before {\n content: \"\";\n width: 1px;\n height: 20px;\n margin: 0 5px 0 3px;\n background: var(--srte-border);\n}\n.srte-tool-button,\n.srte-toolbar select {\n height: 32px;\n min-width: 32px;\n box-sizing: border-box;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: var(--srte-foreground);\n font: 500 13px/1 \"IBM Plex Sans\", ui-sans-serif, system-ui, sans-serif;\n}\n.srte-tool-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 3px;\n padding: 0 7px;\n cursor: pointer;\n}\n.srte-toolbar select {\n max-width: 132px;\n padding: 0 26px 0 9px;\n border-color: var(--srte-border);\n background: var(--srte-input-bg);\n cursor: pointer;\n}\n.srte-tool-button:hover,\n.srte-toolbar select:hover,\n.srte-toolbar-menu[open] > .srte-menu-trigger {\n background: var(--srte-muted);\n border-color: var(--srte-border);\n}\n.srte-tool-button.srte-active,\n.srte-tool-button[aria-pressed=\"true\"] {\n /* var(--srte-primary) (#2563eb) on var(--srte-accent-bg) measured 4.43:1,\n just under WCAG AA's 4.5:1 for normal text (axe-core, Phase 11 Tier 3) -\n a dedicated, darker pressed-state color keeps the same hue family\n while clearing the threshold, without changing --srte-primary's other,\n already-compliant usages (e.g. against solid backgrounds). */\n color: var(--srte-primary-pressed);\n background: var(--srte-accent-bg);\n border-color: color-mix(in srgb, var(--srte-primary) 35%, transparent);\n}\n.srte-tool-button:focus-visible,\n.srte-toolbar select:focus-visible,\n.srte-menu-item:focus-visible {\n outline: 2px solid var(--srte-ring);\n outline-offset: 1px;\n}\n.srte-tool-button:disabled,\n.srte-menu-item:disabled,\n.srte-toolbar select:disabled {\n cursor: not-allowed;\n opacity: .4;\n}\n.srte-toolbar-menu {\n position: relative;\n}\n.srte-toolbar-menu > summary {\n list-style: none;\n}\n.srte-toolbar-menu > summary::-webkit-details-marker {\n display: none;\n}\n.srte-menu {\n position: absolute;\n top: calc(100% + 6px);\n left: 0;\n z-index: 80;\n min-width: 210px;\n padding: 4px;\n overflow: hidden;\n border: 1px solid var(--srte-border);\n border-radius: 12px;\n background: var(--srte-menu-bg);\n color: var(--srte-menu-text);\n box-shadow: var(--srte-menu-shadow);\n}\n/*\n * ToolbarDropdown's own menu panel (docs/bugs/\n * toolbar-dropdown-clipped-by-host-overflow-hidden.md): position:fixed with\n * JS-measured left/top (ToolbarPrimitives.tsx) instead of the default\n * position:absolute above, so it always escapes an ancestor host container's\n * own overflow:hidden regardless of DOM depth - MobileMoreMenu's overflow\n * menu (no data-srte-menu-fixed attribute) is unaffected and keeps its\n * existing position:absolute/right-anchored behavior.\n */\n.srte-menu[data-srte-menu-fixed=\"true\"] {\n position: fixed;\n top: 0;\n left: 0;\n}\n.srte-menu-item {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n height: 36px;\n padding: 0 8px;\n border: 0;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n font: 500 13px/1 \"IBM Plex Sans\", ui-sans-serif, system-ui, sans-serif;\n text-align: left;\n white-space: nowrap;\n}\n.srte-menu-item:hover {\n background: var(--srte-muted);\n}\n.srte-menu-check {\n margin-left: auto;\n color: var(--srte-primary);\n font-weight: 700;\n}\n.srte-menu-separator {\n height: 1px;\n margin: 4px;\n background: var(--srte-border);\n}\n.srte-mobile-more { display: none; }\n.srte-toolbar .srte-command-proxy { display: none; }\n.srte-split-control {\n display: inline-flex;\n}\n.srte-split-control > .srte-tool-button:first-child {\n border-radius: 8px 0 0 8px;\n}\n.srte-split-control > select {\n width: 27px;\n padding: 0;\n border-radius: 0 8px 8px 0;\n border-left: 0;\n appearance: none;\n -webkit-appearance: none;\n color: transparent;\n background-color: var(--srte-muted);\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: center;\n cursor: pointer;\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li {\n display: grid;\n grid-template-columns: 1.1em minmax(0, 1fr);\n column-gap: .45em;\n align-items: start;\n padding-left: 0;\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal-paren\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, decimal) \") \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal-paren\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, lower-alpha) \") \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal-paren\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal-paren\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, lower-roman) \") \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, decimal) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, lower-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, lower-roman) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-alpha\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, upper-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-alpha\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, lower-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-alpha\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-alpha\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, lower-roman) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-roman\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, upper-roman) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-roman\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, upper-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-roman\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-roman\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, decimal) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-leading-zero\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, decimal-leading-zero) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-leading-zero\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, lower-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-leading-zero\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-leading-zero\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, lower-roman) \". \";\n}\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-disc\"][data-srte-list-depth=\"0\"] > li::marker {\n content: \"\u25CF \";\n}\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-disc\"][data-srte-list-depth=\"1\"] > li::marker {\n content: \"\u25CB \";\n}\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-disc\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-disc\"][data-srte-list-depth=\"3\"] > li::marker {\n content: \"\u25A0 \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-outline\"] > li::marker {\n content: counters(list-item, \".\") \". \";\n}\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-diamond\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u2756 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-diamond\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u27A2 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-diamond\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25A0 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-square\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u25A1 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-square\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u25A3 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-square\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25AA \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u279C \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u25C6 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25CF \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-star\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u2605 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-star\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u25CB \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-star\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25A0 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow-circle\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u27A2 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow-circle\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u25CB \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow-circle\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25A0 \"; }\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check] {\n display: inline-flex;\n grid-column: 1;\n grid-row: 1;\n align-items: center;\n justify-content: center;\n width: 1.1em;\n height: 1.6em;\n min-width: 0;\n box-sizing: border-box;\n padding: 0;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: inherit;\n font: inherit;\n cursor: pointer;\n appearance: none;\n outline: none;\n margin: 0;\n line-height: 1.6;\n vertical-align: top;\n position: relative;\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check]:focus-visible {\n outline: 2px solid var(--srte-ring);\n outline-offset: 2px;\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check]::before {\n content: \"\";\n width: .9em;\n height: .9em;\n box-sizing: border-box;\n border: 1.5px solid var(--srte-muted-foreground);\n border-radius: 3px;\n background: var(--srte-canvas);\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check][data-checked=\"true\"]::before {\n border-color: var(--srte-primary);\n background: var(--srte-primary);\n box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--srte-on-primary) 25%, transparent);\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check][data-checked=\"true\"]::after {\n content: \"\";\n position: absolute;\n width: .42em;\n height: .22em;\n border-left: 1.5px solid var(--srte-on-primary);\n border-bottom: 1.5px solid var(--srte-on-primary);\n transform: translateY(-.08em) rotate(-45deg);\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > :is(p,h1,h2,h3,h4,h5,h6,blockquote,pre) {\n grid-column: 2;\n min-width: 0;\n margin-top: 0;\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > :is(ul,ol) {\n grid-column: 2;\n}\n.srte-editor:focus-within {\n border-color: var(--srte-ring) !important;\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--srte-ring) 18%, transparent);\n}\n/*\n * @container (element width), not @media (viewport width): a host can embed\n * the editor at any fraction of the browser window - a split pane next to a\n * preview panel is the case that surfaced this (docs/bugs/\n * toolbar-wide-promotion-fires-on-viewport-width-not-container-width.md). A\n * plain @media breakpoint here would fire based on the *window*, wrongly\n * switching tiers for an editor instance that's actually much narrower (or\n * wider) than the window - .srte-editor already establishes its own\n * container: srte-editor / inline-size (see the .srte-editor rule above),\n * so @container always reflects this specific instance's real rendered width regardless of host\n * layout, with no @media fallback needed (container queries have full\n * support across this project's three target engines).\n */\n@container srte-editor (max-width: 639px) {\n .srte-toolbar { gap: 3px; padding: 6px; }\n .srte-tool-button, .srte-toolbar select { height: 40px; min-width: 40px; }\n .srte-toolbar-group[data-srte-priority=\"3\"],\n .srte-toolbar-menu[data-srte-priority=\"2\"] { display: none; }\n .srte-mobile-more { display: block; }\n /* left/top are no longer set here - ToolbarPrimitives.tsx now computes\n them via inline style (JS-measured, position:fixed, viewport-clamped;\n see MobileMoreMenu's own doc comment), which always wins over any\n value set here regardless of specificity or source order. Sizing/\n scroll behavior stays defined here since inline positioning doesn't\n touch those properties. */\n .srte-mobile-more .srte-menu {\n width: min(280px, calc(100vw - 16px));\n min-width: 0;\n max-height: min(70dvh, 480px);\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n -webkit-overflow-scrolling: touch;\n }\n .srte-menu-item { height: 40px; }\n}\n/*\n * The Bulleted/Numbered list buttons' split-control style picker (a native\n * <select> chevron next to the button - see .srte-split-control) needs\n * ~16px more room than the absolute minimum toolbar width supports.\n * Empirically bisected (not guessed): the toolbar's own \"no button should\n * render narrower than its content\" test only starts failing (Checklist\n * squeezed) at exactly 340-349px container width, and is clean at 350px and\n * every width above it. An earlier version of this fix reused the much\n * wider 639px mobile-tier breakpoint above to hide the select, which \"fixed\"\n * the failing test but also hid the feature across the entire ~340-639px\n * range - it was never actually needed past ~349px, and hiding it that\n * broadly is what caused a live report (\"below 680px it's not showing\n * dropdown icons at list tools\"). Scoped tightly here instead (359px, a\n * small safety margin past the measured 349px floor) so the picker is only\n * absent in the sliver of widths that actually can't fit it - the presets\n * remain reachable via the mobile \"List preset\" control regardless, once\n * .srte-mobile-more takes over at 639px anyway.\n */\n@container srte-editor (max-width: 359px) {\n .srte-split-control > select { display: none; }\n .srte-split-control > .srte-tool-button:first-child { border-radius: 8px; }\n}\n/*\n * Below 480px, the block-structure controls (Paragraph/Heading style,\n * Bulleted list, Numbered list, Checklist) drop their text label and show\n * icon-only - \"visible enough to understand\" plus the existing title/\n * aria-label as a real tooltip, not a silent accessibility loss. Scoped to\n * just these four (via data-srte-narrow-icon-only on the buttons, and a\n * dedicated class on the block-type select) rather than every always-\n * visible button, since only these were reported as needing it - Bold/\n * Italic/etc. keep their labels at every width, unchanged.\n *\n * The block-type control is a native <select> (its shown text is the\n * selected option's value, not a separate label node that can be hidden on\n * its own), so it gets the same treatment .srte-split-control's own select\n * already uses: transparent text plus a background-image icon standing in\n * for it - here, the Pilcrow glyph (already in toolbarIcons as\n * \"paragraphStyle\" but otherwise unused). The native option list itself is\n * unaffected and still shows full text when opened.\n */\n@container srte-editor (max-width: 479px) {\n .srte-tool-button[data-srte-narrow-icon-only=\"true\"] span { display: none; }\n .srte-toolbar select.srte-block-type-select {\n appearance: none;\n -webkit-appearance: none;\n color: transparent;\n width: 40px;\n min-width: 40px;\n max-width: 40px;\n padding: 0;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M13 4v16'/%3E%3Cpath d='M17 4v16'/%3E%3Cpath d='M19 4H9.5a4.5 4.5 0 0 0 0 9H13'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: center;\n }\n}\n/*\n * Wide-viewport promotion (docs/bugs/toolbar-priority-collapse-fixed-threshold-no-wide-promotion.md):\n * below this, the single 639px breakpoint above was this system's ONLY\n * threshold - every dropdown-grouped tool stayed hidden inside its dropdown\n * at every width from 640px up to and past a 2200px+ desktop, since nothing\n * ever measured or scaled with the extra room. A handful of tools frequent\n * enough to matter once there's genuinely spare room (Superscript,\n * Subscript, Text colour, Background colour, Font size, Font family, Remove\n * link, Insert formula, Special characters) get a standalone always-visible\n * ToolbarButton copy (data-srte-wide-promote, rendered directly in the\n * toolbar row) that appears past this breakpoint, while their existing\n * dropdown/mobile-menu ToolbarMenuItem copy (the exact same\n * data-srte-wide-promote attribute, on the .srte-menu-item element instead)\n * hides so the tool isn't offered twice. Below this breakpoint - including\n * all of mobile AND the 640-1439px tablet/typical-laptop band, which keeps\n * today's grouped-dropdown layout unchanged - the standalone copy stays\n * hidden and the dropdown copy is what's reachable.\n *\n * 1440px, not 1280px: Playwright's own default test viewport is exactly\n * 1280x720, and the vast majority of this suite's toolbar interactions run\n * at that default without ever calling page.setViewportSize - a 1280px\n * threshold would have flipped nearly every existing toolbar test's\n * dropdown-item locators (role=menuitem) over to CSS display:none out from\n * under them. 1440px clears that default with room to spare while still\n * comfortably covering \"wide desktop\" (the ~2264px width the original\n * report was filed against).\n *\n * @container only, no @media fallback - same reasoning as the mobile tier\n * above (docs/bugs/toolbar-wide-promotion-fires-on-viewport-width-not-container-width.md):\n * a plain @media(min-width:1440px) fired whenever the *browser window* was\n * wide, even when this editor instance was embedded in a much narrower\n * split pane - promoting 9 extra buttons into a group with no room for\n * them, which squeezed/overlapped the group's existing buttons instead of\n * wrapping cleanly.\n */\n.srte-tool-button[data-srte-wide-promote=\"true\"] { display: none; }\n@container srte-editor (min-width: 1440px) {\n .srte-tool-button[data-srte-wide-promote=\"true\"] { display: inline-flex; }\n .srte-menu-item[data-srte-wide-promote=\"true\"] { display: none; }\n}\n.srte-editor [contenteditable] blockquote {\n border-left: 4px solid var(--srte-accent);\n margin: 0.75em 0;\n padding: 0.5em 1em;\n /* --srte-muted, not the retired --srte-surface-subtle (docs/bugs/\n srte-surface-subtle-not-host-theme-aware.md): every other neutral\n surface/text token here (--srte-background, --srte-foreground,\n --srte-muted, --srte-border) is defined as var(--host-token, fallback)\n so it correctly follows a host's OWN light/dark state even if\n .srte-dark never gets applied for some host-side wiring reason -\n --srte-surface-subtle was the one exception, a bare hardcoded hex with\n no host integration, which is exactly what let a real host (dark mode,\n .srte-dark not reaching this editor instance) render this blockquote\n with a light-mode background and dark-mode text simultaneously. */\n background: var(--srte-muted);\n color: var(--srte-text);\n}\n.srte-editor [contenteditable] p,\n.srte-editor [contenteditable] h1,\n.srte-editor [contenteditable] h2,\n.srte-editor [contenteditable] h3 {\n color: inherit;\n}\n.srte-editor [contenteditable] p {\n display: block;\n margin: 0 0 0.75em;\n font-size: 1em;\n font-weight: 400;\n line-height: 1.6;\n}\n.srte-editor [contenteditable] p[data-srte-caret-boundary=\"true\"] {\n min-height: 1.6em;\n}\n.srte-editor [contenteditable] h1,\n.srte-editor [contenteditable] h2,\n.srte-editor [contenteditable] h3 {\n display: block;\n margin: 0.75em 0 0.4em;\n font-weight: 700;\n line-height: 1.25;\n}\n.srte-editor [contenteditable] h1 {\n font-size: 2em;\n}\n.srte-editor [contenteditable] h2 {\n font-size: 1.5em;\n}\n.srte-editor [contenteditable] h3 {\n font-size: 1.25em;\n}\n.srte-editor [contenteditable] > :first-child {\n margin-top: 0;\n}\n.srte-editor [contenteditable] ul {\n list-style-type: disc;\n list-style-position: outside;\n margin: 0.75em 0;\n padding-left: 1.75em;\n}\n.srte-editor [contenteditable] ol {\n list-style-type: decimal;\n list-style-position: outside;\n margin: 0.75em 0;\n padding-left: 1.75em;\n}\n.srte-editor [contenteditable] li {\n display: list-item;\n margin: 0.25em 0;\n padding-left: 0.25em;\n}\n.srte-editor [contenteditable] li::marker {\n color: currentColor;\n}\n.srte-editor [contenteditable] table {\n width: 100%;\n margin: 0.75em 0;\n border-collapse: collapse;\n}\n.srte-editor [contenteditable] th,\n.srte-editor [contenteditable] td {\n padding: 8px;\n border: 1px solid var(--srte-border);\n vertical-align: top;\n}\n.srte-editor [contenteditable] [data-smart-cell-selected=\"true\"] {\n background: color-mix(in srgb, var(--srte-primary) 12%, var(--srte-canvas));\n box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--srte-primary) 65%, transparent);\n}\n.srte-editor [contenteditable][data-smart-cell-selection-active] ::selection {\n background: transparent;\n color: inherit;\n}\n.srte-editor [contenteditable] th {\n background: var(--srte-muted);\n font-weight: 600;\n text-align: left;\n}\n.srte-editor [contenteditable] td > h1,\n.srte-editor [contenteditable] td > h2,\n.srte-editor [contenteditable] td > h3,\n.srte-editor [contenteditable] th > h1,\n.srte-editor [contenteditable] th > h2,\n.srte-editor [contenteditable] th > h3 {\n margin: 0 0 0.4em;\n overflow-wrap: anywhere;\n}\n.srte-editor [contenteditable] pre {\n display: block;\n margin: 0.75em 0;\n padding: 12px 14px;\n overflow-x: auto;\n border: 1px solid var(--srte-border);\n border-radius: 6px;\n background: var(--srte-code-bg);\n color: var(--srte-code-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", monospace;\n font-size: 0.9em;\n line-height: 1.55;\n white-space: pre-wrap;\n}\n.srte-editor [contenteditable] pre code {\n padding: 0;\n border: 0;\n background: transparent;\n color: inherit;\n font: inherit;\n}\n.srte-editor [contenteditable] code:not(pre code) {\n padding: 0.1em 0.35em;\n border-radius: 3px;\n background: var(--srte-code-bg);\n color: var(--srte-code-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", monospace;\n font-size: 0.9em;\n}\n.srte-editor [contenteditable] a,\n.srte-editor [contenteditable] a:visited {\n color: var(--srte-primary) !important;\n text-decoration: underline !important;\n text-decoration-thickness: 1px !important;\n text-underline-offset: 2px !important;\n cursor: pointer;\n}\n.srte-editor [contenteditable] a:hover {\n color: var(--srte-accent) !important;\n}\n.srte-editor [contenteditable] a:focus-visible {\n outline: 2px solid var(--srte-accent);\n outline-offset: 2px;\n}\n.srte-editor [contenteditable] sub,\n.srte-editor [contenteditable] sup {\n line-height: 0;\n}\n/*\n * A page break has no native visual meaning in a continuously-scrolling\n * web editor - its entire purpose is what happens at export/print time\n * (see atom/formats.ts's atomToHtml doc comment). This gives it a clearly\n * distinct live-editor appearance from the plain solid <hr> a horizontal\n * line renders as by default, so the two are never confused: a dashed\n * line with an explicit \"Page break\" label, per the feature's own design\n * decision.\n */\n.srte-editor [contenteditable] [data-smart-type=\"page_break\"] {\n display: block;\n position: relative;\n height: 0;\n margin: 1.5em 0;\n border: none;\n border-top: 2px dashed var(--srte-border);\n cursor: default;\n}\n.srte-editor [contenteditable] [data-smart-type=\"page_break\"]::after {\n content: \"Page break\";\n position: absolute;\n top: -0.7em;\n left: 50%;\n transform: translateX(-50%);\n background: var(--srte-canvas);\n padding: 0 0.6em;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--srte-text-muted);\n white-space: nowrap;\n}\n";
2
+ export declare const SRTE_DEFAULT_CSS = "\n.srte-editor {\n --srte-background: var(--card, #ffffff);\n --srte-canvas: var(--background, #ffffff);\n --srte-foreground: var(--foreground, #0f172a);\n --srte-muted: var(--muted, #f1f5f9);\n --srte-muted-foreground: var(--muted-foreground, #64748b);\n --srte-ring: var(--ring, #0284c7);\n --srte-radius: var(--radius, 0.625rem);\n --srte-bg: var(--srte-canvas);\n --srte-text: var(--srte-foreground);\n --srte-text-muted: var(--srte-muted-foreground);\n --srte-border: var(--border, #e2e8f0);\n --srte-border-light: var(--srte-border);\n --srte-toolbar-bg: var(--srte-background);\n --srte-input-bg: var(--srte-background);\n --srte-input-text: var(--srte-foreground);\n --srte-input-border: var(--srte-border);\n --srte-modal-backdrop: rgba(0, 0, 0, 0.35);\n --srte-modal-backdrop-filter: blur(2px);\n --srte-modal-bg: var(--card, #ffffff);\n --srte-modal-text: var(--foreground, #000000);\n --srte-menu-bg: var(--srte-background);\n --srte-menu-text: var(--srte-foreground);\n --srte-menu-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);\n --srte-accent: #0284c7;\n --srte-accent-bg: rgba(2, 132, 199, 0.12);\n --srte-danger: #dc2626;\n --srte-primary: #2563eb;\n /* --srte-primary on --srte-accent-bg measures 4.43:1, under WCAG AA's\n 4.5:1 for normal text - used only for the pressed toolbar-button state\n (see .srte-tool-button[aria-pressed=\"true\"]), not a replacement for\n --srte-primary generally. */\n --srte-primary-pressed: #1d4ed8;\n --srte-on-primary: #ffffff;\n --srte-cancel-bg: #f3f4f6;\n --srte-code-bg: #f6f8fa;\n --srte-code-text: #24292f;\n}\n.srte-editor.srte-dark {\n --srte-background: var(--card, #1e293b);\n --srte-canvas: var(--background, #0f172a);\n --srte-foreground: var(--foreground, #f8fafc);\n --srte-muted: var(--muted, #334155);\n --srte-muted-foreground: var(--muted-foreground, #94a3b8);\n --srte-ring: var(--ring, #38bdf8);\n --srte-bg: var(--srte-canvas);\n --srte-text: var(--srte-foreground);\n --srte-text-muted: var(--srte-muted-foreground);\n --srte-border: var(--border, #334155);\n --srte-border-light: var(--srte-border);\n --srte-toolbar-bg: var(--srte-background);\n --srte-input-bg: var(--srte-background);\n --srte-input-text: var(--srte-foreground);\n --srte-input-border: var(--srte-border);\n --srte-modal-backdrop: rgba(0, 0, 0, 0.22);\n --srte-modal-backdrop-filter: blur(10px) saturate(0.9);\n --srte-modal-bg: var(--card, #1e293b);\n --srte-modal-text: var(--foreground, #e0e0e0);\n --srte-menu-bg: var(--srte-background);\n --srte-menu-text: var(--srte-foreground);\n --srte-menu-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);\n --srte-accent: #38bdf8;\n --srte-accent-bg: rgba(56, 189, 248, 0.16);\n --srte-danger: #ef4444;\n --srte-primary: #3b82f6;\n --srte-primary-pressed: var(--srte-primary);\n --srte-on-primary: #ffffff;\n --srte-cancel-bg: #333333;\n --srte-code-bg: #111827;\n --srte-code-text: #e5e7eb;\n}\n.srte-editor {\n border-radius: var(--srte-radius);\n font-family: \"IBM Plex Sans\", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n container: srte-editor / inline-size;\n}\n.srte-canonical-authority > .srte-editor[contenteditable] {\n width: 100%;\n padding: 16px 20px;\n box-sizing: border-box;\n border: 1px solid var(--srte-border);\n border-radius: 0 0 var(--srte-radius) var(--srte-radius);\n outline: none;\n background: var(--srte-canvas);\n color: var(--srte-foreground);\n caret-color: var(--srte-foreground);\n line-height: 1.6;\n}\n.srte-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 4px;\n width: 100%;\n min-height: 48px;\n padding: 8px;\n box-sizing: border-box;\n border-bottom: 1px solid var(--srte-border);\n background: var(--srte-background);\n color: var(--srte-foreground);\n position: sticky;\n top: 0;\n z-index: 10;\n}\n.srte-toolbar-group {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n min-width: 0;\n}\n.srte-toolbar-group + .srte-toolbar-group::before {\n content: \"\";\n width: 1px;\n height: 20px;\n margin: 0 5px 0 3px;\n background: var(--srte-border);\n}\n.srte-tool-button,\n.srte-toolbar select {\n height: 32px;\n min-width: 32px;\n box-sizing: border-box;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: var(--srte-foreground);\n font: 500 13px/1 \"IBM Plex Sans\", ui-sans-serif, system-ui, sans-serif;\n}\n.srte-tool-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 3px;\n padding: 0 7px;\n cursor: pointer;\n}\n.srte-toolbar select {\n max-width: 132px;\n padding: 0 26px 0 9px;\n border-color: var(--srte-border);\n background: var(--srte-input-bg);\n cursor: pointer;\n}\n.srte-tool-button:hover,\n.srte-toolbar select:hover,\n.srte-toolbar-menu[open] > .srte-menu-trigger {\n background: var(--srte-muted);\n border-color: var(--srte-border);\n}\n.srte-tool-button.srte-active,\n.srte-tool-button[aria-pressed=\"true\"] {\n /* var(--srte-primary) (#2563eb) on var(--srte-accent-bg) measured 4.43:1,\n just under WCAG AA's 4.5:1 for normal text (axe-core, Phase 11 Tier 3) -\n a dedicated, darker pressed-state color keeps the same hue family\n while clearing the threshold, without changing --srte-primary's other,\n already-compliant usages (e.g. against solid backgrounds). */\n color: var(--srte-primary-pressed);\n background: var(--srte-accent-bg);\n border-color: color-mix(in srgb, var(--srte-primary) 35%, transparent);\n}\n.srte-tool-button:focus-visible,\n.srte-toolbar select:focus-visible,\n.srte-menu-item:focus-visible {\n outline: 2px solid var(--srte-ring);\n outline-offset: 1px;\n}\n.srte-tool-button:disabled,\n.srte-menu-item:disabled,\n.srte-toolbar select:disabled {\n cursor: not-allowed;\n opacity: .4;\n}\n.srte-toolbar-menu {\n position: relative;\n}\n.srte-toolbar-menu > summary {\n list-style: none;\n}\n.srte-toolbar-menu > summary::-webkit-details-marker {\n display: none;\n}\n.srte-menu {\n position: absolute;\n top: calc(100% + 6px);\n left: 0;\n z-index: 80;\n min-width: 210px;\n padding: 4px;\n overflow: hidden;\n border: 1px solid var(--srte-border);\n border-radius: 12px;\n background: var(--srte-menu-bg);\n color: var(--srte-menu-text);\n box-shadow: var(--srte-menu-shadow);\n}\n/*\n * ToolbarDropdown's own menu panel (docs/bugs/\n * toolbar-dropdown-clipped-by-host-overflow-hidden.md): position:fixed with\n * JS-measured left/top (ToolbarPrimitives.tsx) instead of the default\n * position:absolute above, so it always escapes an ancestor host container's\n * own overflow:hidden regardless of DOM depth - MobileMoreMenu's overflow\n * menu (no data-srte-menu-fixed attribute) is unaffected and keeps its\n * existing position:absolute/right-anchored behavior.\n */\n.srte-menu[data-srte-menu-fixed=\"true\"] {\n position: fixed;\n top: 0;\n left: 0;\n}\n.srte-menu-item {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n height: 36px;\n padding: 0 8px;\n border: 0;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n font: 500 13px/1 \"IBM Plex Sans\", ui-sans-serif, system-ui, sans-serif;\n text-align: left;\n white-space: nowrap;\n}\n.srte-menu-item:hover {\n background: var(--srte-muted);\n}\n.srte-menu-check {\n margin-left: auto;\n color: var(--srte-primary);\n font-weight: 700;\n}\n.srte-menu-separator {\n height: 1px;\n margin: 4px;\n background: var(--srte-border);\n}\n.srte-mobile-more { display: none; }\n.srte-toolbar .srte-command-proxy { display: none; }\n.srte-split-control {\n display: inline-flex;\n}\n.srte-split-control > .srte-tool-button:first-child {\n border-radius: 8px 0 0 8px;\n}\n.srte-split-control > select {\n width: 27px;\n padding: 0;\n border-radius: 0 8px 8px 0;\n border-left: 0;\n appearance: none;\n -webkit-appearance: none;\n color: transparent;\n background-color: var(--srte-muted);\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: center;\n cursor: pointer;\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li {\n display: grid;\n grid-template-columns: 1.1em minmax(0, 1fr);\n column-gap: .45em;\n align-items: start;\n padding-left: 0;\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal-paren\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, decimal) \") \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal-paren\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, lower-alpha) \") \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal-paren\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal-paren\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, lower-roman) \") \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, decimal) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, lower-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-decimal\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, lower-roman) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-alpha\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, upper-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-alpha\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, lower-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-alpha\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-alpha\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, lower-roman) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-roman\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, upper-roman) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-roman\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, upper-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-roman\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-upper-roman\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, decimal) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-leading-zero\"][data-srte-list-depth=\"0\"] > li::marker {\n content: counter(list-item, decimal-leading-zero) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-leading-zero\"][data-srte-list-depth=\"1\"] > li::marker {\n content: counter(list-item, lower-alpha) \". \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-leading-zero\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-leading-zero\"][data-srte-list-depth=\"3\"] > li::marker {\n content: counter(list-item, lower-roman) \". \";\n}\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-disc\"][data-srte-list-depth=\"0\"] > li::marker {\n content: \"\u25CF \";\n}\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-disc\"][data-srte-list-depth=\"1\"] > li::marker {\n content: \"\u25CB \";\n}\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-disc\"][data-srte-list-depth=\"2\"] > li::marker,\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-disc\"][data-srte-list-depth=\"3\"] > li::marker {\n content: \"\u25A0 \";\n}\n.srte-editor [contenteditable] ol[data-srte-list-preset=\"ordered-outline\"] > li::marker {\n content: counters(list-item, \".\") \". \";\n}\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-diamond\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u2756 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-diamond\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u27A2 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-diamond\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25A0 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-square\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u25A1 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-square\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u25A3 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-square\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25AA \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u279C \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u25C6 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25CF \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-star\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u2605 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-star\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u25CB \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-star\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25A0 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow-circle\"][data-srte-list-depth=\"0\"] > li::marker { content: \"\u27A2 \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow-circle\"][data-srte-list-depth=\"1\"] > li::marker { content: \"\u25CB \"; }\n.srte-editor [contenteditable] ul[data-srte-list-preset=\"bullet-arrow-circle\"][data-srte-list-depth=\"2\"] > li::marker { content: \"\u25A0 \"; }\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check] {\n display: inline-flex;\n grid-column: 1;\n grid-row: 1;\n align-items: center;\n justify-content: center;\n width: 1.1em;\n height: 1.6em;\n min-width: 0;\n box-sizing: border-box;\n padding: 0;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: inherit;\n font: inherit;\n cursor: pointer;\n appearance: none;\n outline: none;\n margin: 0;\n line-height: 1.6;\n vertical-align: top;\n position: relative;\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check]:focus-visible {\n outline: 2px solid var(--srte-ring);\n outline-offset: 2px;\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check]::before {\n content: \"\";\n width: .9em;\n height: .9em;\n box-sizing: border-box;\n border: 1.5px solid var(--srte-muted-foreground);\n border-radius: 3px;\n background: var(--srte-canvas);\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check][data-checked=\"true\"]::before {\n border-color: var(--srte-primary);\n background: var(--srte-primary);\n box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--srte-on-primary) 25%, transparent);\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > [data-srte-check][data-checked=\"true\"]::after {\n content: \"\";\n position: absolute;\n width: .42em;\n height: .22em;\n border-left: 1.5px solid var(--srte-on-primary);\n border-bottom: 1.5px solid var(--srte-on-primary);\n transform: translateY(-.08em) rotate(-45deg);\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > :is(p,h1,h2,h3,h4,h5,h6,blockquote,pre) {\n grid-column: 2;\n min-width: 0;\n margin-top: 0;\n}\n.srte-editor [contenteditable] ul[data-srte-checklist=\"true\"] > li > :is(ul,ol) {\n grid-column: 2;\n}\n.srte-editor:focus-within {\n border-color: var(--srte-ring) !important;\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--srte-ring) 18%, transparent);\n}\n/*\n * @container (element width), not @media (viewport width): a host can embed\n * the editor at any fraction of the browser window - a split pane next to a\n * preview panel is the case that surfaced this (docs/bugs/\n * toolbar-wide-promotion-fires-on-viewport-width-not-container-width.md). A\n * plain @media breakpoint here would fire based on the *window*, wrongly\n * switching tiers for an editor instance that's actually much narrower (or\n * wider) than the window - .srte-editor already establishes its own\n * container: srte-editor / inline-size (see the .srte-editor rule above),\n * so @container always reflects this specific instance's real rendered width regardless of host\n * layout, with no @media fallback needed (container queries have full\n * support across this project's three target engines).\n */\n@container srte-editor (max-width: 639px) {\n .srte-toolbar { gap: 3px; padding: 6px; }\n .srte-tool-button, .srte-toolbar select { height: 40px; min-width: 40px; }\n .srte-toolbar-group[data-srte-priority=\"3\"],\n .srte-toolbar-menu[data-srte-priority=\"2\"] { display: none; }\n .srte-mobile-more { display: block; }\n /* left/top are no longer set here - ToolbarPrimitives.tsx now computes\n them via inline style (JS-measured, position:fixed, viewport-clamped;\n see MobileMoreMenu's own doc comment), which always wins over any\n value set here regardless of specificity or source order. Sizing/\n scroll behavior stays defined here since inline positioning doesn't\n touch those properties. */\n .srte-mobile-more .srte-menu {\n width: min(280px, calc(100vw - 16px));\n min-width: 0;\n max-height: min(70dvh, 480px);\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n -webkit-overflow-scrolling: touch;\n }\n .srte-menu-item { height: 40px; }\n}\n/*\n * The Bulleted/Numbered list buttons' split-control style picker (a native\n * <select> chevron next to the button - see .srte-split-control) needs\n * ~16px more room than the absolute minimum toolbar width supports.\n * Empirically bisected (not guessed): the toolbar's own \"no button should\n * render narrower than its content\" test only starts failing (Checklist\n * squeezed) at exactly 340-349px container width, and is clean at 350px and\n * every width above it. An earlier version of this fix reused the much\n * wider 639px mobile-tier breakpoint above to hide the select, which \"fixed\"\n * the failing test but also hid the feature across the entire ~340-639px\n * range - it was never actually needed past ~349px, and hiding it that\n * broadly is what caused a live report (\"below 680px it's not showing\n * dropdown icons at list tools\"). Scoped tightly here instead (359px, a\n * small safety margin past the measured 349px floor) so the picker is only\n * absent in the sliver of widths that actually can't fit it - the presets\n * remain reachable via the mobile \"List preset\" control regardless, once\n * .srte-mobile-more takes over at 639px anyway.\n */\n@container srte-editor (max-width: 359px) {\n .srte-split-control > select { display: none; }\n .srte-split-control > .srte-tool-button:first-child { border-radius: 8px; }\n}\n/*\n * Below 480px, the block-structure controls (Paragraph/Heading style,\n * Bulleted list, Numbered list, Checklist) drop their text label and show\n * icon-only - \"visible enough to understand\" plus the existing title/\n * aria-label as a real tooltip, not a silent accessibility loss. Scoped to\n * just these four (via data-srte-narrow-icon-only on the buttons, and a\n * dedicated class on the block-type select) rather than every always-\n * visible button, since only these were reported as needing it - Bold/\n * Italic/etc. keep their labels at every width, unchanged.\n *\n * The block-type control is a native <select> (its shown text is the\n * selected option's value, not a separate label node that can be hidden on\n * its own), so it gets the same treatment .srte-split-control's own select\n * already uses: transparent text plus a background-image icon standing in\n * for it - here, the Pilcrow glyph (already in toolbarIcons as\n * \"paragraphStyle\" but otherwise unused). The native option list itself is\n * unaffected and still shows full text when opened.\n */\n@container srte-editor (max-width: 479px) {\n .srte-tool-button[data-srte-narrow-icon-only=\"true\"] span { display: none; }\n .srte-toolbar select.srte-block-type-select {\n appearance: none;\n -webkit-appearance: none;\n color: transparent;\n width: 40px;\n min-width: 40px;\n max-width: 40px;\n padding: 0;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M13 4v16'/%3E%3Cpath d='M17 4v16'/%3E%3Cpath d='M19 4H9.5a4.5 4.5 0 0 0 0 9H13'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: center;\n }\n}\n/*\n * Wide-viewport promotion (docs/bugs/toolbar-priority-collapse-fixed-threshold-no-wide-promotion.md):\n * below this, the single 639px breakpoint above was this system's ONLY\n * threshold - every dropdown-grouped tool stayed hidden inside its dropdown\n * at every width from 640px up to and past a 2200px+ desktop, since nothing\n * ever measured or scaled with the extra room. A handful of tools frequent\n * enough to matter once there's genuinely spare room (Superscript,\n * Subscript, Text colour, Background colour, Font size, Font family, Remove\n * link, Insert formula, Special characters) get a standalone always-visible\n * ToolbarButton copy (data-srte-wide-promote, rendered directly in the\n * toolbar row) that appears past this breakpoint, while their existing\n * dropdown/mobile-menu ToolbarMenuItem copy (the exact same\n * data-srte-wide-promote attribute, on the .srte-menu-item element instead)\n * hides so the tool isn't offered twice. Below this breakpoint - including\n * all of mobile AND the 640-1439px tablet/typical-laptop band, which keeps\n * today's grouped-dropdown layout unchanged - the standalone copy stays\n * hidden and the dropdown copy is what's reachable.\n *\n * 1440px, not 1280px: Playwright's own default test viewport is exactly\n * 1280x720, and the vast majority of this suite's toolbar interactions run\n * at that default without ever calling page.setViewportSize - a 1280px\n * threshold would have flipped nearly every existing toolbar test's\n * dropdown-item locators (role=menuitem) over to CSS display:none out from\n * under them. 1440px clears that default with room to spare while still\n * comfortably covering \"wide desktop\" (the ~2264px width the original\n * report was filed against).\n *\n * @container only, no @media fallback - same reasoning as the mobile tier\n * above (docs/bugs/toolbar-wide-promotion-fires-on-viewport-width-not-container-width.md):\n * a plain @media(min-width:1440px) fired whenever the *browser window* was\n * wide, even when this editor instance was embedded in a much narrower\n * split pane - promoting 9 extra buttons into a group with no room for\n * them, which squeezed/overlapped the group's existing buttons instead of\n * wrapping cleanly.\n */\n.srte-tool-button[data-srte-wide-promote=\"true\"] { display: none; }\n@container srte-editor (min-width: 1440px) {\n .srte-tool-button[data-srte-wide-promote=\"true\"] { display: inline-flex; }\n .srte-menu-item[data-srte-wide-promote=\"true\"] { display: none; }\n}\n.srte-editor [contenteditable] blockquote {\n border-left: 4px solid var(--srte-accent);\n margin: 0.75em 0;\n padding: 0.5em 1em;\n /* --srte-muted, not the retired --srte-surface-subtle (docs/bugs/\n srte-surface-subtle-not-host-theme-aware.md): every other neutral\n surface/text token here (--srte-background, --srte-foreground,\n --srte-muted, --srte-border) is defined as var(--host-token, fallback)\n so it correctly follows a host's OWN light/dark state even if\n .srte-dark never gets applied for some host-side wiring reason -\n --srte-surface-subtle was the one exception, a bare hardcoded hex with\n no host integration, which is exactly what let a real host (dark mode,\n .srte-dark not reaching this editor instance) render this blockquote\n with a light-mode background and dark-mode text simultaneously. */\n background: var(--srte-muted);\n color: var(--srte-text);\n}\n.srte-editor [contenteditable] p,\n.srte-editor [contenteditable] h1,\n.srte-editor [contenteditable] h2,\n.srte-editor [contenteditable] h3 {\n color: inherit;\n}\n.srte-editor [contenteditable] p {\n display: block;\n margin: 0 0 0.75em;\n font-size: 1em;\n font-weight: 400;\n line-height: 1.6;\n}\n.srte-editor [contenteditable] p[data-srte-caret-boundary=\"true\"] {\n min-height: 1.6em;\n}\n.srte-editor [contenteditable] h1,\n.srte-editor [contenteditable] h2,\n.srte-editor [contenteditable] h3 {\n display: block;\n margin: 0.75em 0 0.4em;\n font-weight: 700;\n line-height: 1.25;\n}\n.srte-editor [contenteditable] h1 {\n font-size: 2em;\n}\n.srte-editor [contenteditable] h2 {\n font-size: 1.5em;\n}\n.srte-editor [contenteditable] h3 {\n font-size: 1.25em;\n}\n.srte-editor [contenteditable] > :first-child {\n margin-top: 0;\n}\n.srte-editor [contenteditable] ul {\n list-style-type: disc;\n list-style-position: outside;\n margin: 0.75em 0;\n padding-left: 1.75em;\n}\n.srte-editor [contenteditable] ol {\n list-style-type: decimal;\n list-style-position: outside;\n margin: 0.75em 0;\n padding-left: 1.75em;\n}\n.srte-editor [contenteditable] li {\n display: list-item;\n margin: 0.25em 0;\n padding-left: 0.25em;\n}\n.srte-editor [contenteditable] li::marker {\n color: currentColor;\n}\n.srte-editor [contenteditable] table {\n width: 100%;\n margin: 0.75em 0;\n border-collapse: collapse;\n}\n.srte-editor [contenteditable] th,\n.srte-editor [contenteditable] td {\n padding: 8px;\n border: 1px solid var(--srte-border);\n vertical-align: top;\n}\n.srte-editor [contenteditable] [data-smart-cell-selected=\"true\"] {\n background: color-mix(in srgb, var(--srte-primary) 12%, var(--srte-canvas));\n box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--srte-primary) 65%, transparent);\n}\n.srte-editor [contenteditable][data-smart-cell-selection-active] ::selection {\n background: transparent;\n color: inherit;\n}\n.srte-editor [contenteditable] th {\n background: var(--srte-muted);\n font-weight: 600;\n text-align: left;\n}\n.srte-editor [contenteditable] td > h1,\n.srte-editor [contenteditable] td > h2,\n.srte-editor [contenteditable] td > h3,\n.srte-editor [contenteditable] th > h1,\n.srte-editor [contenteditable] th > h2,\n.srte-editor [contenteditable] th > h3 {\n margin: 0 0 0.4em;\n overflow-wrap: anywhere;\n}\n.srte-editor [contenteditable] pre {\n display: block;\n margin: 0.75em 0;\n padding: 12px 14px;\n overflow-x: auto;\n border: 1px solid var(--srte-border);\n border-radius: 6px;\n background: var(--srte-code-bg);\n color: var(--srte-code-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", monospace;\n font-size: 0.9em;\n line-height: 1.55;\n white-space: pre-wrap;\n}\n.srte-editor [contenteditable] pre code {\n padding: 0;\n border: 0;\n background: transparent;\n color: inherit;\n font: inherit;\n}\n.srte-editor [contenteditable] code:not(pre code) {\n padding: 0.1em 0.35em;\n border-radius: 3px;\n background: var(--srte-code-bg);\n color: var(--srte-code-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", monospace;\n font-size: 0.9em;\n}\n.srte-editor [contenteditable] a,\n.srte-editor [contenteditable] a:visited {\n color: var(--srte-primary) !important;\n text-decoration: underline !important;\n text-decoration-thickness: 1px !important;\n text-underline-offset: 2px !important;\n cursor: pointer;\n}\n.srte-editor [contenteditable] a:hover {\n color: var(--srte-accent) !important;\n}\n.srte-editor [contenteditable] a:focus-visible {\n outline: 2px solid var(--srte-accent);\n outline-offset: 2px;\n}\n.srte-editor [contenteditable] sub,\n.srte-editor [contenteditable] sup {\n line-height: 0;\n}\n/*\n * A page break has no native visual meaning in a continuously-scrolling\n * web editor - its entire purpose is what happens at export/print time\n * (see atom/formats.ts's atomToHtml doc comment). This gives it a clearly\n * distinct live-editor appearance from the plain solid <hr> a horizontal\n * line renders as by default, so the two are never confused: a dashed\n * line with an explicit \"Page break\" label, per the feature's own design\n * decision.\n */\n/*\n * A block image's caption (a renderer projection placed right after the\n * <img>, see surface/renderer.ts's syncImageCaptions). Small and muted,\n * close under the image, following its alignment; max-width is set inline\n * to the image's width when known. A left/right image floats, so its\n * caption floats the same way and clears that side to stack underneath.\n */\n.srte-editor [contenteditable] [data-smart-projection=\"image-caption\"] {\n display: block;\n box-sizing: border-box;\n margin: 4px 0 0.75em;\n color: var(--srte-text-muted);\n font-size: 0.85em;\n line-height: 1.4;\n overflow-wrap: anywhere;\n cursor: pointer;\n user-select: none;\n}\n.srte-editor [contenteditable] [data-smart-projection=\"image-caption\"][data-smart-align=\"center\"] {\n margin-left: auto;\n margin-right: auto;\n text-align: center;\n}\n.srte-editor [contenteditable] [data-smart-projection=\"image-caption\"][data-smart-align=\"left\"] {\n float: left;\n clear: left;\n margin-right: 8px;\n}\n.srte-editor [contenteditable] [data-smart-projection=\"image-caption\"][data-smart-align=\"right\"] {\n float: right;\n clear: right;\n margin-left: 8px;\n text-align: right;\n}\n.srte-editor [contenteditable] [data-smart-type=\"page_break\"] {\n display: block;\n position: relative;\n height: 0;\n margin: 1.5em 0;\n border: none;\n border-top: 2px dashed var(--srte-border);\n cursor: default;\n}\n.srte-editor [contenteditable] [data-smart-type=\"page_break\"]::after {\n content: \"Page break\";\n position: absolute;\n top: -0.7em;\n left: 50%;\n transform: translateX(-50%);\n background: var(--srte-canvas);\n padding: 0 0.6em;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--srte-text-muted);\n white-space: nowrap;\n}\n";
3
3
  export declare function ensureStyleSheet(): void;
package/dist/theme.js CHANGED
@@ -701,6 +701,40 @@ export const SRTE_DEFAULT_CSS = `
701
701
  * line with an explicit "Page break" label, per the feature's own design
702
702
  * decision.
703
703
  */
704
+ /*
705
+ * A block image's caption (a renderer projection placed right after the
706
+ * <img>, see surface/renderer.ts's syncImageCaptions). Small and muted,
707
+ * close under the image, following its alignment; max-width is set inline
708
+ * to the image's width when known. A left/right image floats, so its
709
+ * caption floats the same way and clears that side to stack underneath.
710
+ */
711
+ .srte-editor [contenteditable] [data-smart-projection="image-caption"] {
712
+ display: block;
713
+ box-sizing: border-box;
714
+ margin: 4px 0 0.75em;
715
+ color: var(--srte-text-muted);
716
+ font-size: 0.85em;
717
+ line-height: 1.4;
718
+ overflow-wrap: anywhere;
719
+ cursor: pointer;
720
+ user-select: none;
721
+ }
722
+ .srte-editor [contenteditable] [data-smart-projection="image-caption"][data-smart-align="center"] {
723
+ margin-left: auto;
724
+ margin-right: auto;
725
+ text-align: center;
726
+ }
727
+ .srte-editor [contenteditable] [data-smart-projection="image-caption"][data-smart-align="left"] {
728
+ float: left;
729
+ clear: left;
730
+ margin-right: 8px;
731
+ }
732
+ .srte-editor [contenteditable] [data-smart-projection="image-caption"][data-smart-align="right"] {
733
+ float: right;
734
+ clear: right;
735
+ margin-left: 8px;
736
+ text-align: right;
737
+ }
704
738
  .srte-editor [contenteditable] [data-smart-type="page_break"] {
705
739
  display: block;
706
740
  position: relative;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartrte-react",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A powerful, feature-rich Rich Text Editor for React with support for tables, mathematical formulas (LaTeX/KaTeX), and media management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,16 +52,6 @@
52
52
  },
53
53
  "author": "Smart RTE Contributors",
54
54
  "license": "MIT",
55
- "scripts": {
56
- "build": "rm -rf dist && tsc -p tsconfig.json",
57
- "prepublishOnly": "npm run build",
58
- "dev": "pnpm build",
59
- "lint": "tsc -p tsconfig.json --noEmit",
60
- "test": "vitest run --passWithNoTests",
61
- "storybook": "storybook dev -p 6006",
62
- "build-storybook": "storybook build",
63
- "e2e": "pnpm --filter smartrte-core build && pnpm build && playwright test --pass-with-no-tests"
64
- },
65
55
  "publishConfig": {
66
56
  "access": "public"
67
57
  },
@@ -94,6 +84,15 @@
94
84
  "lucide-react": "^1.35.0",
95
85
  "mammoth": "^1.11.0",
96
86
  "pdfjs-dist": "^5.4.530",
97
- "smartrte-core": "workspace:^"
87
+ "smartrte-core": "^1.2.0"
88
+ },
89
+ "scripts": {
90
+ "build": "rm -rf dist && tsc -p tsconfig.json",
91
+ "dev": "pnpm build",
92
+ "lint": "tsc -p tsconfig.json --noEmit",
93
+ "test": "vitest run --passWithNoTests",
94
+ "storybook": "storybook dev -p 6006",
95
+ "build-storybook": "storybook build",
96
+ "e2e": "pnpm --filter smartrte-core build && pnpm build && playwright test --pass-with-no-tests"
98
97
  }
99
- }
98
+ }