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 +15 -0
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/dist/canonicalEditorRuntime.d.ts +60 -1
- package/dist/canonicalEditorRuntime.js +106 -17
- package/dist/components/CanonicalAuthorityEditor.d.ts +8 -0
- package/dist/components/CanonicalAuthorityEditor.js +52 -75
- package/dist/components/MediaDetailsPopover.d.ts +7 -1
- package/dist/components/MediaDetailsPopover.js +11 -2
- package/dist/index.d.ts +2 -1
- package/dist/insertion.d.ts +22 -0
- package/dist/insertion.js +95 -0
- package/dist/theme.d.ts +1 -1
- package/dist/theme.js +34 -0
- package/package.json +12 -13
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
1134
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
+
}
|