@sciflow/editor-start 0.0.2 → 0.1.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/LICENSE +21 -0
- package/README.md +50 -29
- package/dist/bundle/sciflow-editor.css +1 -1
- package/dist/bundle/sciflow-editor.js +4446 -3980
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/lib/default-features.d.ts +21 -0
- package/dist/lib/default-features.d.ts.map +1 -0
- package/dist/lib/default-features.js +34 -0
- package/dist/lib/editor-element.d.ts +97 -1
- package/dist/lib/editor-element.d.ts.map +1 -1
- package/dist/lib/editor-element.js +435 -25
- package/dist/lib/format-bar.d.ts +22 -0
- package/dist/lib/format-bar.d.ts.map +1 -1
- package/dist/lib/format-bar.js +37 -1
- package/dist/lib/outline.d.ts +44 -0
- package/dist/lib/outline.d.ts.map +1 -1
- package/dist/lib/outline.js +120 -6
- package/dist/lib/range-decorations.d.ts +120 -0
- package/dist/lib/range-decorations.d.ts.map +1 -0
- package/dist/lib/range-decorations.js +131 -0
- package/dist/lib/read-only.d.ts +95 -0
- package/dist/lib/read-only.d.ts.map +1 -0
- package/dist/lib/read-only.js +123 -0
- package/dist/testing/external-widget-simulator.d.ts +217 -0
- package/dist/testing/external-widget-simulator.d.ts.map +1 -0
- package/dist/testing/external-widget-simulator.js +350 -0
- package/package.json +28 -14
- package/dist/tsconfig.lib.tsbuildinfo +0 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module read-only
|
|
3
|
+
*
|
|
4
|
+
* A generic, toggleable read-only `Feature`: vetoes every doc-changing
|
|
5
|
+
* transaction while leaving selection/scroll transactions free to pass (so
|
|
6
|
+
* browsing — clicking around, selecting text to read it — still works),
|
|
7
|
+
* disables the DOM's own `contenteditable`/caret/IME behavior, and marks the
|
|
8
|
+
* editable surface with a styling hook so consumers can render a muted look.
|
|
9
|
+
*
|
|
10
|
+
* # Mechanism
|
|
11
|
+
*
|
|
12
|
+
* - `filterTransaction: (tr, state) => !tr.docChanged || isExternalSyncTransaction(tr) || key.getState(state) !== true`
|
|
13
|
+
* — `EditorState.applyTransaction` checks this BEFORE running any plugin's
|
|
14
|
+
* `state.apply`, so a vetoed transaction never reaches the doc, the
|
|
15
|
+
* undo-history plugin, or anything else — the state comes back byte-for-
|
|
16
|
+
* byte unchanged (`newState === oldState`). Selection-only and meta-only
|
|
17
|
+
* transactions (`tr.docChanged === false` — e.g. a click, or another
|
|
18
|
+
* feature's own `dispatchMeta` update) are explicitly let through, and so
|
|
19
|
+
* is `Editor`'s own external-doc reconciliation transaction (see the
|
|
20
|
+
* "Programmatic doc replacement" section below) — only an actual user/
|
|
21
|
+
* command edit is vetoed.
|
|
22
|
+
* - `props.editable: (state) => key.getState(state) !== true` — the DOM-level
|
|
23
|
+
* backstop: disables `contenteditable`, so the browser's own caret/IME
|
|
24
|
+
* never engages in the first place. `filterTransaction` alone would still
|
|
25
|
+
* technically block an edit, but a live caret sitting in "dead" text reads
|
|
26
|
+
* as broken, not read-only.
|
|
27
|
+
* - `props.attributes: (state) => (key.getState(state) ? { 'data-readonly': 'true' } : {})`
|
|
28
|
+
* — the styling hook. No baked-in colors here (only a `data-readonly`
|
|
29
|
+
* attribute) — style the muted look with your own token-based CSS, e.g.
|
|
30
|
+
* `.sf-editable-surface .ProseMirror[data-readonly] { opacity: var(--sf-opacity-muted); }`.
|
|
31
|
+
*
|
|
32
|
+
* # Programmatic doc replacement still works while read-only
|
|
33
|
+
*
|
|
34
|
+
* "Read-only" means "the user cannot edit this document" — not "this
|
|
35
|
+
* document can never change from any source." A host that reassigns
|
|
36
|
+
* `editor.doc` (e.g. a revision viewer paging through history) drives
|
|
37
|
+
* `Editor.updateFromSync()`, which reconciles the mounted view via its own
|
|
38
|
+
* transaction (`Editor`'s internal `syncViewWithState()`), tagged with
|
|
39
|
+
* `EXTERNAL_SYNC_TRANSACTION_META`. `filterTransaction` lets THAT transaction
|
|
40
|
+
* through even while read-only, so the view keeps rendering every
|
|
41
|
+
* subsequent doc the host assigns; only a transaction WITHOUT that meta
|
|
42
|
+
* (a real edit) is vetoed. See `@sciflow/editor-core`'s
|
|
43
|
+
* `EXTERNAL_SYNC_TRANSACTION_META`/`isExternalSyncTransaction` doc for the
|
|
44
|
+
* full contract — any custom read-only-style `filterTransaction` should
|
|
45
|
+
* follow the same check, or it will "freeze" the editor's own
|
|
46
|
+
* `updateFromSync()` calls after the first doc assignment, not just block
|
|
47
|
+
* user typing.
|
|
48
|
+
*
|
|
49
|
+
* # Toggling without re-initializing the editor
|
|
50
|
+
*
|
|
51
|
+
* The read-only flag lives in PLUGIN STATE (keyed, like `range-decorations.ts`,
|
|
52
|
+
* so it survives every transaction via a plain `state.apply`), not a value
|
|
53
|
+
* captured once at feature-construction time — `filterTransaction`/`editable`/
|
|
54
|
+
* `attributes` all read the CURRENT state on every check. This means the
|
|
55
|
+
* flag genuinely toggles live, no `configureFeatures()`/re-mount needed:
|
|
56
|
+
*
|
|
57
|
+
* ```typescript
|
|
58
|
+
* import { createReadOnlyFeature } from '@sciflow/editor-start/bundle';
|
|
59
|
+
*
|
|
60
|
+
* const { feature, key } = createReadOnlyFeature('my-read-only');
|
|
61
|
+
* await editor.configureFeatures([...otherFeatures, feature]);
|
|
62
|
+
*
|
|
63
|
+
* editor.plugins.dispatchMeta(key, true); // now read-only
|
|
64
|
+
* editor.plugins.dispatchMeta(key, false); // editable again, same editor instance
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* # A caveat worth stating plainly, not silently
|
|
68
|
+
*
|
|
69
|
+
* `filterTransaction` guarantees the DOC never actually changes for a
|
|
70
|
+
* vetoed transaction, but a host's own `dispatchTransaction` handler that
|
|
71
|
+
* inspects the REJECTED transaction object directly (rather than comparing
|
|
72
|
+
* before/after state) can still be misled: `tr.docChanged` reflects what the
|
|
73
|
+
* transaction WOULD have done, not whether it was applied. A consumer
|
|
74
|
+
* wiring up its own change-tracking on a read-only editor should compare
|
|
75
|
+
* `newState !== oldState` (or `newState.doc.eq(oldState.doc)`) rather than
|
|
76
|
+
* trusting `tr.docChanged` alone. `@sciflow/editor-core`'s own
|
|
77
|
+
* `handleTransaction` does inspect `tr.docChanged` directly — this is a
|
|
78
|
+
* known, narrow gap for the (unusual) case of listening to `editor-change`
|
|
79
|
+
* on a read-only instance, not something this feature can close from a
|
|
80
|
+
* plugin alone. Not a concern for the ordinary "browse a read-view editor,
|
|
81
|
+
* nobody listens to its `editor-change`" case.
|
|
82
|
+
*/
|
|
83
|
+
import { PluginKey } from 'prosemirror-state';
|
|
84
|
+
import { type Feature } from '@sciflow/editor-core';
|
|
85
|
+
/**
|
|
86
|
+
* Build a fresh, independently-keyed read-only `Feature`. `initialReadOnly`
|
|
87
|
+
* sets the STARTING value (default `false` — editable, matching an ordinary
|
|
88
|
+
* editor's least-surprising default); flip it live afterward via
|
|
89
|
+
* `editor.plugins.dispatchMeta(key, true | false)`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function createReadOnlyFeature(name: string, initialReadOnly?: boolean): {
|
|
92
|
+
feature: Feature;
|
|
93
|
+
key: PluginKey<boolean>;
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=read-only.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-only.d.ts","sourceRoot":"","sources":["../../src/lib/read-only.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiFG;AAEH,OAAO,EAAU,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAA6B,KAAK,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAE/E;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,eAAe,UAAQ,GACtB;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,SAAS,CAAC,OAAO,CAAC,CAAA;CAAE,CAiC/C"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module read-only
|
|
3
|
+
*
|
|
4
|
+
* A generic, toggleable read-only `Feature`: vetoes every doc-changing
|
|
5
|
+
* transaction while leaving selection/scroll transactions free to pass (so
|
|
6
|
+
* browsing — clicking around, selecting text to read it — still works),
|
|
7
|
+
* disables the DOM's own `contenteditable`/caret/IME behavior, and marks the
|
|
8
|
+
* editable surface with a styling hook so consumers can render a muted look.
|
|
9
|
+
*
|
|
10
|
+
* # Mechanism
|
|
11
|
+
*
|
|
12
|
+
* - `filterTransaction: (tr, state) => !tr.docChanged || isExternalSyncTransaction(tr) || key.getState(state) !== true`
|
|
13
|
+
* — `EditorState.applyTransaction` checks this BEFORE running any plugin's
|
|
14
|
+
* `state.apply`, so a vetoed transaction never reaches the doc, the
|
|
15
|
+
* undo-history plugin, or anything else — the state comes back byte-for-
|
|
16
|
+
* byte unchanged (`newState === oldState`). Selection-only and meta-only
|
|
17
|
+
* transactions (`tr.docChanged === false` — e.g. a click, or another
|
|
18
|
+
* feature's own `dispatchMeta` update) are explicitly let through, and so
|
|
19
|
+
* is `Editor`'s own external-doc reconciliation transaction (see the
|
|
20
|
+
* "Programmatic doc replacement" section below) — only an actual user/
|
|
21
|
+
* command edit is vetoed.
|
|
22
|
+
* - `props.editable: (state) => key.getState(state) !== true` — the DOM-level
|
|
23
|
+
* backstop: disables `contenteditable`, so the browser's own caret/IME
|
|
24
|
+
* never engages in the first place. `filterTransaction` alone would still
|
|
25
|
+
* technically block an edit, but a live caret sitting in "dead" text reads
|
|
26
|
+
* as broken, not read-only.
|
|
27
|
+
* - `props.attributes: (state) => (key.getState(state) ? { 'data-readonly': 'true' } : {})`
|
|
28
|
+
* — the styling hook. No baked-in colors here (only a `data-readonly`
|
|
29
|
+
* attribute) — style the muted look with your own token-based CSS, e.g.
|
|
30
|
+
* `.sf-editable-surface .ProseMirror[data-readonly] { opacity: var(--sf-opacity-muted); }`.
|
|
31
|
+
*
|
|
32
|
+
* # Programmatic doc replacement still works while read-only
|
|
33
|
+
*
|
|
34
|
+
* "Read-only" means "the user cannot edit this document" — not "this
|
|
35
|
+
* document can never change from any source." A host that reassigns
|
|
36
|
+
* `editor.doc` (e.g. a revision viewer paging through history) drives
|
|
37
|
+
* `Editor.updateFromSync()`, which reconciles the mounted view via its own
|
|
38
|
+
* transaction (`Editor`'s internal `syncViewWithState()`), tagged with
|
|
39
|
+
* `EXTERNAL_SYNC_TRANSACTION_META`. `filterTransaction` lets THAT transaction
|
|
40
|
+
* through even while read-only, so the view keeps rendering every
|
|
41
|
+
* subsequent doc the host assigns; only a transaction WITHOUT that meta
|
|
42
|
+
* (a real edit) is vetoed. See `@sciflow/editor-core`'s
|
|
43
|
+
* `EXTERNAL_SYNC_TRANSACTION_META`/`isExternalSyncTransaction` doc for the
|
|
44
|
+
* full contract — any custom read-only-style `filterTransaction` should
|
|
45
|
+
* follow the same check, or it will "freeze" the editor's own
|
|
46
|
+
* `updateFromSync()` calls after the first doc assignment, not just block
|
|
47
|
+
* user typing.
|
|
48
|
+
*
|
|
49
|
+
* # Toggling without re-initializing the editor
|
|
50
|
+
*
|
|
51
|
+
* The read-only flag lives in PLUGIN STATE (keyed, like `range-decorations.ts`,
|
|
52
|
+
* so it survives every transaction via a plain `state.apply`), not a value
|
|
53
|
+
* captured once at feature-construction time — `filterTransaction`/`editable`/
|
|
54
|
+
* `attributes` all read the CURRENT state on every check. This means the
|
|
55
|
+
* flag genuinely toggles live, no `configureFeatures()`/re-mount needed:
|
|
56
|
+
*
|
|
57
|
+
* ```typescript
|
|
58
|
+
* import { createReadOnlyFeature } from '@sciflow/editor-start/bundle';
|
|
59
|
+
*
|
|
60
|
+
* const { feature, key } = createReadOnlyFeature('my-read-only');
|
|
61
|
+
* await editor.configureFeatures([...otherFeatures, feature]);
|
|
62
|
+
*
|
|
63
|
+
* editor.plugins.dispatchMeta(key, true); // now read-only
|
|
64
|
+
* editor.plugins.dispatchMeta(key, false); // editable again, same editor instance
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* # A caveat worth stating plainly, not silently
|
|
68
|
+
*
|
|
69
|
+
* `filterTransaction` guarantees the DOC never actually changes for a
|
|
70
|
+
* vetoed transaction, but a host's own `dispatchTransaction` handler that
|
|
71
|
+
* inspects the REJECTED transaction object directly (rather than comparing
|
|
72
|
+
* before/after state) can still be misled: `tr.docChanged` reflects what the
|
|
73
|
+
* transaction WOULD have done, not whether it was applied. A consumer
|
|
74
|
+
* wiring up its own change-tracking on a read-only editor should compare
|
|
75
|
+
* `newState !== oldState` (or `newState.doc.eq(oldState.doc)`) rather than
|
|
76
|
+
* trusting `tr.docChanged` alone. `@sciflow/editor-core`'s own
|
|
77
|
+
* `handleTransaction` does inspect `tr.docChanged` directly — this is a
|
|
78
|
+
* known, narrow gap for the (unusual) case of listening to `editor-change`
|
|
79
|
+
* on a read-only instance, not something this feature can close from a
|
|
80
|
+
* plugin alone. Not a concern for the ordinary "browse a read-view editor,
|
|
81
|
+
* nobody listens to its `editor-change`" case.
|
|
82
|
+
*/
|
|
83
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
84
|
+
import { isExternalSyncTransaction } from '@sciflow/editor-core';
|
|
85
|
+
/**
|
|
86
|
+
* Build a fresh, independently-keyed read-only `Feature`. `initialReadOnly`
|
|
87
|
+
* sets the STARTING value (default `false` — editable, matching an ordinary
|
|
88
|
+
* editor's least-surprising default); flip it live afterward via
|
|
89
|
+
* `editor.plugins.dispatchMeta(key, true | false)`.
|
|
90
|
+
*/
|
|
91
|
+
export function createReadOnlyFeature(name, initialReadOnly = false) {
|
|
92
|
+
const key = new PluginKey(name);
|
|
93
|
+
const plugin = new Plugin({
|
|
94
|
+
key,
|
|
95
|
+
state: {
|
|
96
|
+
init: () => initialReadOnly,
|
|
97
|
+
apply(tr, readOnly) {
|
|
98
|
+
const meta = tr.getMeta(key);
|
|
99
|
+
return typeof meta === 'boolean' ? meta : readOnly;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
filterTransaction(tr, state) {
|
|
103
|
+
if (!tr.docChanged)
|
|
104
|
+
return true;
|
|
105
|
+
// The editor's OWN external-doc reconciliation (Editor.updateFromSync /
|
|
106
|
+
// .applyOps, via syncViewWithState) — not a user edit. Letting it
|
|
107
|
+
// through is what makes `editor.doc = nextSnapshot` keep rendering on a
|
|
108
|
+
// read-only instance after the first assignment; see the module doc.
|
|
109
|
+
if (isExternalSyncTransaction(tr))
|
|
110
|
+
return true;
|
|
111
|
+
return key.getState(state) !== true;
|
|
112
|
+
},
|
|
113
|
+
props: {
|
|
114
|
+
editable: (state) => key.getState(state) !== true,
|
|
115
|
+
attributes: (state) => (key.getState(state) ? { 'data-readonly': 'true' } : {}),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const feature = {
|
|
119
|
+
name,
|
|
120
|
+
addPlugins: () => [plugin],
|
|
121
|
+
};
|
|
122
|
+
return { feature, key };
|
|
123
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module external-widget-simulator
|
|
3
|
+
* @internal
|
|
4
|
+
*
|
|
5
|
+
* INTERNAL TESTING UTILITY — NOT STABLE PUBLIC API.
|
|
6
|
+
* This module is exported from the `@sciflow/editor-start` package solely to
|
|
7
|
+
* enable automated tests and the interactive demo page to verify light-DOM
|
|
8
|
+
* reachability. Its API may change without a semver notice. Do not depend on
|
|
9
|
+
* it in production code.
|
|
10
|
+
*
|
|
11
|
+
* A vanilla-JS simulator that models the three key behaviours of browser
|
|
12
|
+
* proofreading and AI writing extensions (e.g. Grammarly, the LanguageTool
|
|
13
|
+
* browser add-on, DeepL Write, etc.) when they interact with a page's
|
|
14
|
+
* editable surface.
|
|
15
|
+
*
|
|
16
|
+
* Design constraints
|
|
17
|
+
* ------------------
|
|
18
|
+
* - Zero runtime dependencies — only public web APIs.
|
|
19
|
+
* - Never traverses into any ShadowRoot. Extensions operate exclusively on
|
|
20
|
+
* the flat document tree; they have no access to shadow-DOM internals.
|
|
21
|
+
* - Suitable for both automated tests (jsdom / vitest) and manual in-browser
|
|
22
|
+
* testing via the demo page.
|
|
23
|
+
*
|
|
24
|
+
* The three capabilities modelled
|
|
25
|
+
* --------------------------------
|
|
26
|
+
* 1. `discover(root?)` — walks the light DOM for editable targets exactly as a
|
|
27
|
+
* real extension would: `querySelectorAll` on [contenteditable], textarea,
|
|
28
|
+
* and text inputs, then filters to nodes whose root is the document (i.e.
|
|
29
|
+
* not behind a shadow boundary).
|
|
30
|
+
*
|
|
31
|
+
* 2. `markRange(target, from, to, options?)` — draws underline-style overlay
|
|
32
|
+
* markers over a character range inside a text node the way widgets render
|
|
33
|
+
* grammar/spell highlights. Uses Range + getClientRects() and places
|
|
34
|
+
* absolutely-positioned <span> elements in an overlay layer that is a
|
|
35
|
+
* sibling of the editable (never inside it).
|
|
36
|
+
*
|
|
37
|
+
* 3. DOM mutation operations that mimic extensions touching the editable
|
|
38
|
+
* directly, bypassing the editor's own transaction mechanism:
|
|
39
|
+
* - `injectMarker(target, from, to)` — wraps a text sub-range in a
|
|
40
|
+
* `<span data-extn-marker="1">` (how widgets tag matches for hover
|
|
41
|
+
* interactions without accepting/rejecting them yet).
|
|
42
|
+
* - `applyCorrection(target, from, to, replacement)` — replaces a text
|
|
43
|
+
* sub-range via raw Range manipulation (the "accept suggestion" action).
|
|
44
|
+
*
|
|
45
|
+
* Threat/usage model
|
|
46
|
+
* ------------------
|
|
47
|
+
* A ProseMirror contenteditable mounted inside a shadow root is INVISIBLE to
|
|
48
|
+
* extensions because they do not call `shadowRoot.querySelector`. This
|
|
49
|
+
* simulator encodes that exact distinction: `discover()` returns only light-DOM
|
|
50
|
+
* editables whose `getRootNode() === document`. The fact that a slotted
|
|
51
|
+
* element satisfies this condition while a shadow-buried element does not is
|
|
52
|
+
* the core regression guard for the light-DOM spike (Option B).
|
|
53
|
+
*
|
|
54
|
+
* jsdom limitation
|
|
55
|
+
* ----------------
|
|
56
|
+
* `getClientRects()` is not implemented in jsdom and always returns an empty
|
|
57
|
+
* DOMRectList. `markRange()` therefore cannot be meaningfully tested in the
|
|
58
|
+
* standard vitest suite — it is exercised through the manual demo page instead.
|
|
59
|
+
* `discover()`, `injectMarker()`, and `applyCorrection()` are all jsdom-safe
|
|
60
|
+
* and form the automated test surface.
|
|
61
|
+
*/
|
|
62
|
+
/** An editable element found by the simulator's discovery walk. */
|
|
63
|
+
export interface EditableTarget {
|
|
64
|
+
/** The discovered element. */
|
|
65
|
+
element: HTMLElement;
|
|
66
|
+
/**
|
|
67
|
+
* The root node of the element. For a light-DOM element this will be the
|
|
68
|
+
* `Document`; for a shadow-buried element it would be a `ShadowRoot`.
|
|
69
|
+
* The simulator's `discover()` only returns elements where this is
|
|
70
|
+
* `document` — this field is exposed so tests can assert it explicitly.
|
|
71
|
+
*/
|
|
72
|
+
rootNode: Node;
|
|
73
|
+
/** True when the element is inside the light DOM (`rootNode === document`). */
|
|
74
|
+
isLightDom: boolean;
|
|
75
|
+
}
|
|
76
|
+
/** Options for the `markRange` overlay operation. */
|
|
77
|
+
export interface MarkRangeOptions {
|
|
78
|
+
/** CSS color for the underline. Defaults to `'#ef4444'` (red). */
|
|
79
|
+
color?: string;
|
|
80
|
+
/**
|
|
81
|
+
* How many pixels below the text baseline the underline is offset.
|
|
82
|
+
* Defaults to `2`.
|
|
83
|
+
*/
|
|
84
|
+
offsetY?: number;
|
|
85
|
+
/** CSS z-index for the overlay layer. Defaults to `'9999'`. */
|
|
86
|
+
zIndex?: string;
|
|
87
|
+
}
|
|
88
|
+
/** A marker handle returned by `injectMarker`, used for cleanup. */
|
|
89
|
+
export interface InjectedMarker {
|
|
90
|
+
/** The `<span data-extn-marker="1">` wrapper element. */
|
|
91
|
+
span: HTMLSpanElement;
|
|
92
|
+
/** Removes the injected span and restores the original text layout. */
|
|
93
|
+
remove(): void;
|
|
94
|
+
}
|
|
95
|
+
/** Result of `applyCorrection`. */
|
|
96
|
+
export interface CorrectionResult {
|
|
97
|
+
/** The text that was replaced. */
|
|
98
|
+
replaced: string;
|
|
99
|
+
/** The replacement text that was inserted. */
|
|
100
|
+
inserted: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Walk the light DOM for editable targets, exactly as browser extensions do.
|
|
104
|
+
*
|
|
105
|
+
* Extensions use `document.querySelectorAll` (or equivalent TreeWalker
|
|
106
|
+
* traversals) to find `[contenteditable]`, `textarea`, and `input[type=text]`.
|
|
107
|
+
* Critically, they do NOT call `shadowRoot.querySelectorAll` or otherwise
|
|
108
|
+
* traverse into shadow trees — they only see what is in the flat document tree.
|
|
109
|
+
*
|
|
110
|
+
* This function replicates that: it queries from `root` (defaulting to
|
|
111
|
+
* `document`) and then filters the results to those whose `getRootNode()` is
|
|
112
|
+
* the passed `root`. An editable that lives inside a shadow root will NOT
|
|
113
|
+
* satisfy this condition and will NOT be returned.
|
|
114
|
+
*
|
|
115
|
+
* @param root The root to query from. Defaults to `document`.
|
|
116
|
+
* @returns An array of `EditableTarget` objects for each discovered field.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* // Asserts the editor's contenteditable is reachable from the document root:
|
|
120
|
+
* const targets = discover(document);
|
|
121
|
+
* const ce = targets.find(t => t.element.hasAttribute('contenteditable'));
|
|
122
|
+
* assert(ce?.isLightDom === true);
|
|
123
|
+
* assert(ce?.rootNode === document);
|
|
124
|
+
*/
|
|
125
|
+
export declare function discover(root?: Document | Element): EditableTarget[];
|
|
126
|
+
/**
|
|
127
|
+
* Position overlay underline markers over a character range inside `target`.
|
|
128
|
+
*
|
|
129
|
+
* Mimics how extensions draw underlines: they create a DOM Range, call
|
|
130
|
+
* `getClientRects()` to obtain the line boxes, and then absolutely-position
|
|
131
|
+
* overlay `<span>` elements in a sibling container at those coordinates.
|
|
132
|
+
* Critically, the overlay is NOT inserted inside the contenteditable — it is
|
|
133
|
+
* a sibling to avoid invalidating the editable's DOM tree from the extension's
|
|
134
|
+
* perspective.
|
|
135
|
+
*
|
|
136
|
+
* NOTE: `getClientRects()` always returns an empty list in jsdom. This
|
|
137
|
+
* function is a no-op in that environment and is intended for manual
|
|
138
|
+
* in-browser verification via the demo page. The automated test suite
|
|
139
|
+
* tests `discover`, `injectMarker`, and `applyCorrection` instead.
|
|
140
|
+
*
|
|
141
|
+
* @param target The editable element to overlay.
|
|
142
|
+
* @param from Character offset (from the start of `target.textContent`) of
|
|
143
|
+
* the range start.
|
|
144
|
+
* @param to Character offset of the range end.
|
|
145
|
+
* @param options Visual options.
|
|
146
|
+
* @returns The overlay container element, or `null` when `getClientRects`
|
|
147
|
+
* returned nothing (jsdom / hidden element).
|
|
148
|
+
*/
|
|
149
|
+
export declare function markRange(target: HTMLElement, from: number, to: number, options?: MarkRangeOptions): HTMLElement | null;
|
|
150
|
+
/**
|
|
151
|
+
* Wrap a character sub-range of `target` in a `<span data-extn-marker="1">`.
|
|
152
|
+
*
|
|
153
|
+
* This is what extensions do to tag a match for subsequent accept/reject
|
|
154
|
+
* interactions — they insert a wrapper span *inside* the contenteditable via
|
|
155
|
+
* raw DOM Range manipulation without going through the editor's transaction
|
|
156
|
+
* system.
|
|
157
|
+
*
|
|
158
|
+
* From ProseMirror's perspective this is a foreign DOM mutation. ProseMirror's
|
|
159
|
+
* DOMObserver will observe the mutation and attempt to reconcile its internal
|
|
160
|
+
* model. The mutation-tolerance tests in the spec verify that this does not
|
|
161
|
+
* corrupt the document or cause PM to throw.
|
|
162
|
+
*
|
|
163
|
+
* @param target The contenteditable element to mutate.
|
|
164
|
+
* @param from Character offset of the range start (within `target.textContent`).
|
|
165
|
+
* @param to Character offset of the range end.
|
|
166
|
+
* @returns An `InjectedMarker` handle with a `remove()` method for cleanup.
|
|
167
|
+
* Returns `null` when the range cannot be resolved.
|
|
168
|
+
*/
|
|
169
|
+
export declare function injectMarker(target: HTMLElement, from: number, to: number): InjectedMarker | null;
|
|
170
|
+
/**
|
|
171
|
+
* Replace a character sub-range of `target` with `replacement` via raw DOM.
|
|
172
|
+
*
|
|
173
|
+
* This is the "accept suggestion" operation: the extension overwrites a word
|
|
174
|
+
* by creating a Range, deleting its contents, and inserting a new text node.
|
|
175
|
+
* It does NOT fire a ProseMirror transaction.
|
|
176
|
+
*
|
|
177
|
+
* The test that calls this should follow up with a normal PM transaction (e.g.
|
|
178
|
+
* `view.dispatch(view.state.tr.insertText(...))`) to verify that ProseMirror
|
|
179
|
+
* reconciles the foreign mutation cleanly and does not produce corrupted or
|
|
180
|
+
* duplicated content.
|
|
181
|
+
*
|
|
182
|
+
* @param target The contenteditable element.
|
|
183
|
+
* @param from Character offset of the range to replace.
|
|
184
|
+
* @param to Character offset of the range end.
|
|
185
|
+
* @param replacement The text to insert in place of the matched range.
|
|
186
|
+
* @returns A `CorrectionResult` describing what was swapped, or
|
|
187
|
+
* `null` when the range cannot be resolved.
|
|
188
|
+
*/
|
|
189
|
+
export declare function applyCorrection(target: HTMLElement, from: number, to: number, replacement: string): CorrectionResult | null;
|
|
190
|
+
/** A character-offset range within an element's flat `textContent`. */
|
|
191
|
+
export interface TextRange {
|
|
192
|
+
from: number;
|
|
193
|
+
to: number;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Find all occurrences of `searchString` within `target.textContent` and
|
|
197
|
+
* return their character-offset ranges.
|
|
198
|
+
*
|
|
199
|
+
* This is the bridge between "a word I want to underline" and the `{from, to}`
|
|
200
|
+
* pair that `markRange`, `injectMarker`, and `applyCorrection` consume.
|
|
201
|
+
* Extensions compute positions exactly this way: read the full `textContent`
|
|
202
|
+
* of the editable as one flat string, locate every match, then use those
|
|
203
|
+
* offsets to build DOM Ranges.
|
|
204
|
+
*
|
|
205
|
+
* @param target The element whose `textContent` is searched.
|
|
206
|
+
* @param searchString The literal string to find (case-sensitive).
|
|
207
|
+
* @returns Array of `{from, to}` ranges, one per occurrence,
|
|
208
|
+
* in document order. Empty array when there are no matches.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* const hits = findTextOccurrences(editable, 'SciFlow');
|
|
212
|
+
* for (const { from, to } of hits) {
|
|
213
|
+
* markRange(editable, from, to, { color: '#f97316' });
|
|
214
|
+
* }
|
|
215
|
+
*/
|
|
216
|
+
export declare function findTextOccurrences(target: HTMLElement, searchString: string): TextRange[];
|
|
217
|
+
//# sourceMappingURL=external-widget-simulator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"external-widget-simulator.d.ts","sourceRoot":"","sources":["../../src/testing/external-widget-simulator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4DG;AAMH,mEAAmE;AACnE,MAAM,WAAW,cAAc;IAC7B,8BAA8B;IAC9B,OAAO,EAAE,WAAW,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,EAAE,IAAI,CAAC;IACf,+EAA+E;IAC/E,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,qDAAqD;AACrD,MAAM,WAAW,gBAAgB;IAC/B,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,oEAAoE;AACpE,MAAM,WAAW,cAAc;IAC7B,yDAAyD;IACzD,IAAI,EAAE,eAAe,CAAC;IACtB,uEAAuE;IACvE,MAAM,IAAI,IAAI,CAAC;CAChB;AAED,mCAAmC;AACnC,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAMD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,QAAQ,CAAC,IAAI,GAAE,QAAQ,GAAG,OAAkB,GAAG,cAAc,EAAE,CAwC9E;AAMD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,EACV,OAAO,GAAE,gBAAqB,GAC7B,WAAW,GAAG,IAAI,CA8CpB;AAMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,GACT,cAAc,GAAG,IAAI,CA0BvB;AAMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,EACV,WAAW,EAAE,MAAM,GAClB,gBAAgB,GAAG,IAAI,CASzB;AAMD,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,WAAW,EACnB,YAAY,EAAE,MAAM,GACnB,SAAS,EAAE,CAYb"}
|