@sciflow/editor-start 0.0.3 → 0.1.1

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.
@@ -13,7 +13,7 @@
13
13
  * - Build reactive UI that stays in sync with editor state
14
14
  */
15
15
  import { __decorate } from "tslib";
16
- import { css, html, LitElement, unsafeCSS } from 'lit';
16
+ import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
17
17
  import { customElement, property, state } from 'lit/decorators.js';
18
18
  import { ref } from 'lit/directives/ref.js';
19
19
  import { SourceField, t, LOCALE_CHANGE_EVENT } from '@sciflow/editor-core';
@@ -22,7 +22,7 @@ import { renderTexToSvg } from '@sciflow/editor-core/features/math';
22
22
  import { Fragment as PMFragment } from 'prosemirror-model';
23
23
  import { EditorState, NodeSelection } from 'prosemirror-state';
24
24
  import { EditorView } from 'prosemirror-view';
25
- import { baseKeymap, toggleMark } from 'prosemirror-commands';
25
+ import { baseKeymap, deleteSelection, toggleMark } from 'prosemirror-commands';
26
26
  import { keymap } from 'prosemirror-keymap';
27
27
  import { getCitationSourceAdapter, } from './citation-source-adapter.js';
28
28
  import { collectDocumentOutline } from './outline.js';
@@ -30,6 +30,14 @@ import { renderNodeFromUiSchema } from './selection-ui-renderer.js';
30
30
  import './selection-widget-host.js';
31
31
  import selectionEditorCss from './selection-editor.css?inline';
32
32
  import { applyThemeStylesToRoot, subscribeToSciFlowTheme } from './theme.js';
33
+ /**
34
+ * Attributes whose value is always text. Anything outside this set that looks
35
+ * like a number is coerced to one before it is written to the node, so a text
36
+ * attribute left out here silently changes type — an image address of `1200`
37
+ * or `3.14` would reach the document as a number and fail schema validation on
38
+ * export. Add a new text attribute here in the same commit that renders it.
39
+ */
40
+ const TEXT_VALUED_ATTRS = new Set(['alt', 'src']);
33
41
  /**
34
42
  * Custom element that displays and edits the selected element's attributes.
35
43
  *
@@ -717,11 +725,32 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
717
725
  return this.resolvedEditor?.commands ?? null;
718
726
  }
719
727
  /**
720
- * Render a single attribute field, decoding known encoded values for readability.
728
+ * Whether the bound editor currently accepts edits. Every path in this panel
729
+ * that writes to the document asks first, and fails closed when there is no
730
+ * view to ask.
731
+ *
732
+ * A host can turn editing off at any time
733
+ * (`view.setProps({ editable: () => false })`) and expects the whole surface
734
+ * to go quiet — a side panel that still writes would make a read-only
735
+ * document change under the reader. `view.editable` is the same signal the
736
+ * editor's own key and click handling reads, so the panel cannot drift from
737
+ * the editing surface. The guards sit on the write itself rather than only on
738
+ * the controls: the panel re-renders on selection changes, so editability can
739
+ * flip while a button from the editable render is still on screen.
721
740
  */
722
- getNodeTypeLabel(type) {
741
+ isEditorEditable() {
742
+ return this.resolvedEditor?.editorView?.editable === true;
743
+ }
744
+ /**
745
+ * The panel's own name for a node type in the active locale, or `null` when it
746
+ * has none. Cased for use inside a sentence, so callers that need a heading
747
+ * capitalise it themselves — English writes these names lower case, German
748
+ * capitalises every noun, and only the dictionary knows which.
749
+ */
750
+ getNodeTypeName(type) {
723
751
  const known = {
724
752
  figure: () => t('selectionEditor.figure'),
753
+ table: () => t('selectionEditor.table'),
725
754
  math: () => t('selectionEditor.math'),
726
755
  citation: () => t('selectionEditor.citation'),
727
756
  anchor: () => t('selectionEditor.hyperlink'),
@@ -729,9 +758,33 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
729
758
  heading: () => t('selectionEditor.heading'),
730
759
  paragraph: () => t('selectionEditor.paragraph'),
731
760
  };
732
- const label = known[type]?.() ?? type.replace(/_/g, ' ');
761
+ return known[type]?.() ?? null;
762
+ }
763
+ getNodeTypeLabel(type) {
764
+ const label = this.getNodeTypeName(type) ?? type.replace(/_/g, ' ');
733
765
  return label.replace(/^\w/, (m) => m.toUpperCase());
734
766
  }
767
+ /**
768
+ * The node this panel is currently describing, read back from the live
769
+ * document, or `null` when the panel is describing a mark or nothing at all.
770
+ */
771
+ getSelectedNode() {
772
+ const view = this.resolvedEditor?.editorView;
773
+ const position = this.elementInfo?.position;
774
+ if (!view || position == null || position < 0 || position >= view.state.doc.content.size) {
775
+ return null;
776
+ }
777
+ return view.state.doc.nodeAt(position);
778
+ }
779
+ /**
780
+ * A table is a `figure` whose first child is a `table` rather than an image,
781
+ * so the content is what tells a reader which one is selected — the node type
782
+ * is `figure` either way.
783
+ */
784
+ isTableFigure(node) {
785
+ const tableType = this.resolvedEditor?.editorView?.state.schema.nodes['table'];
786
+ return !!tableType && node?.firstChild?.type === tableType;
787
+ }
735
788
  renderAttributeField(attrName, attrValue) {
736
789
  const decodedValue = this.decodeSpecialAttribute(attrName, attrValue);
737
790
  const renderedValue = decodedValue === null || decodedValue === undefined ? '' : String(decodedValue);
@@ -852,7 +905,7 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
852
905
  if (attrName === 'source') {
853
906
  parsedValue = this.encodeSourceField(value);
854
907
  }
855
- else if (attrName === 'alt') {
908
+ else if (TEXT_VALUED_ATTRS.has(attrName)) {
856
909
  parsedValue = value;
857
910
  }
858
911
  else if (value !== '') {
@@ -870,15 +923,50 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
870
923
  this.setDraftAttr(attrName, parsedValue);
871
924
  }
872
925
  /**
873
- * Render the figure sidebar: type, id, alt text.
926
+ * Render the figure sidebar: type, image address, alt text.
927
+ *
928
+ * The image address is editable here because this panel is the only place a
929
+ * reader can change it: clicking an image selects the figure rather than
930
+ * opening a picker, so the address has to be reachable as an ordinary field.
931
+ * It is a plain text input, so a URL, a relative path or a data URI are all
932
+ * accepted; the value is written on `change` like every other draft attribute
933
+ * and committed with Apply.
934
+ *
935
+ * A table is a `figure` too, so the heading and the form's accessible name
936
+ * both come from the selected node rather than from the node type alone —
937
+ * otherwise a selected table would be titled "Figure".
874
938
  */
875
939
  renderFigureSidebar() {
876
940
  const attrs = this.getEffectiveAttrs();
877
941
  const alt = typeof attrs.alt === 'string' ? attrs.alt : '';
942
+ const src = typeof attrs.src === 'string' ? attrs.src : '';
943
+ const isTable = this.isTableFigure(this.getSelectedNode());
878
944
  return html `
879
- <div class="selection-editor-container selection-editor-figure" role="form" aria-label=${t('selectionEditor.editFigure')} @keydown=${this.handleGenericKeydown}>
945
+ <div
946
+ class="selection-editor-container selection-editor-figure"
947
+ role="form"
948
+ aria-label=${isTable ? t('selectionEditor.editTable') : t('selectionEditor.editFigure')}
949
+ @keydown=${this.handleGenericKeydown}
950
+ >
880
951
  ${this.renderSelectionControls()}
881
- <p class="selection-editor-type-header">${this.getNodeTypeLabel(this.elementInfo.type)}</p>
952
+ <p class="selection-editor-type-header">
953
+ ${isTable ? this.getNodeTypeLabel('table') : this.getNodeTypeLabel(this.elementInfo.type)}
954
+ </p>
955
+ ${isTable
956
+ ? nothing
957
+ : html `
958
+ <div class="selection-editor-field">
959
+ <label class="selection-editor-label" for="figure-src">${t('selectionEditor.imageUrl')}</label>
960
+ <input
961
+ type="text"
962
+ id="figure-src"
963
+ class="selection-editor-input"
964
+ .value=${src}
965
+ placeholder=${t('selectionEditor.urlPlaceholder')}
966
+ @change=${(e) => this.handleDraftAttrChange(e, 'src')}
967
+ />
968
+ </div>
969
+ `}
882
970
  <div class="selection-editor-field">
883
971
  <label class="selection-editor-label" for="figure-alt">${t('selectionEditor.altText')}</label>
884
972
  <input
@@ -886,7 +974,9 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
886
974
  id="figure-alt"
887
975
  class="selection-editor-input"
888
976
  .value=${alt}
889
- placeholder=${t('selectionEditor.altTextPlaceholder')}
977
+ placeholder=${isTable
978
+ ? t('selectionEditor.tableAltTextPlaceholder')
979
+ : t('selectionEditor.altTextPlaceholder')}
890
980
  @change=${(e) => this.handleDraftAttrChange(e, 'alt')}
891
981
  />
892
982
  </div>
@@ -1040,6 +1130,9 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
1040
1130
  });
1041
1131
  }
1042
1132
  applyMathDraft() {
1133
+ if (!this.isEditorEditable()) {
1134
+ return;
1135
+ }
1043
1136
  if (!this.elementInfo || this.elementInfo.type !== 'math' || this.elementInfo.position === null || !this.mathDraft) {
1044
1137
  return;
1045
1138
  }
@@ -1140,6 +1233,9 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
1140
1233
  `;
1141
1234
  }
1142
1235
  applyCitationDraft() {
1236
+ if (!this.isEditorEditable()) {
1237
+ return;
1238
+ }
1143
1239
  if (!this.elementInfo ||
1144
1240
  this.elementInfo.type !== 'citation' ||
1145
1241
  this.elementInfo.position === null ||
@@ -1233,6 +1329,9 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
1233
1329
  `;
1234
1330
  }
1235
1331
  applyCrossReferenceDraft() {
1332
+ if (!this.isEditorEditable()) {
1333
+ return;
1334
+ }
1236
1335
  if (!this.elementInfo ||
1237
1336
  this.elementInfo.type !== 'link' ||
1238
1337
  this.elementInfo.position === null ||
@@ -1309,7 +1408,7 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
1309
1408
  }
1310
1409
  applyHyperlinkDraft() {
1311
1410
  const view = this.resolvedEditor?.editorView;
1312
- if (!view || !this.hyperlinkDraft)
1411
+ if (!view || !view.editable || !this.hyperlinkDraft)
1313
1412
  return;
1314
1413
  const { state } = view;
1315
1414
  const anchorMarkType = state.schema.marks['anchor'];
@@ -1345,7 +1444,7 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
1345
1444
  }
1346
1445
  removeHyperlink() {
1347
1446
  const view = this.resolvedEditor?.editorView;
1348
- if (!view)
1447
+ if (!view || !view.editable)
1349
1448
  return;
1350
1449
  const { state } = view;
1351
1450
  const anchorMarkType = state.schema.marks['anchor'];
@@ -1396,6 +1495,9 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
1396
1495
  this.draftDirty = Boolean(this.draftAttrs && Object.keys(this.draftAttrs).length > 0);
1397
1496
  }
1398
1497
  applyDraftAttrs() {
1498
+ if (!this.isEditorEditable()) {
1499
+ return;
1500
+ }
1399
1501
  if (!this.draftAttrs || !this.draftDirty || !this.elementInfo || this.elementInfo.position === null) {
1400
1502
  return;
1401
1503
  }
@@ -1417,8 +1519,15 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
1417
1519
  revertDraftAttrs() {
1418
1520
  this.resetDraftAttrs();
1419
1521
  }
1522
+ /**
1523
+ * Apply/Revert for pending attribute edits.
1524
+ *
1525
+ * Withheld when the editor is not editable: `applyDraftAttrs` refuses to write
1526
+ * either way — that guard is the barrier — but offering an Apply that cannot
1527
+ * act reads as a broken control rather than a read-only document.
1528
+ */
1420
1529
  renderDraftActions() {
1421
- if (!this.draftDirty) {
1530
+ if (!this.draftDirty || !this.isEditorEditable()) {
1422
1531
  return null;
1423
1532
  }
1424
1533
  return html `
@@ -1441,6 +1550,16 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
1441
1550
  </div>
1442
1551
  `;
1443
1552
  }
1553
+ /**
1554
+ * Controls that act on the selection itself rather than on its attributes.
1555
+ *
1556
+ * The delete action sits here, at the top of the panel, deliberately far from
1557
+ * the Apply/Update primary at the bottom: a destructive verb never shares a
1558
+ * region with the action a user is aiming for. It is a plain `<button>` with
1559
+ * visible text, so it is keyboard reachable and its accessible name is the
1560
+ * label itself. Removal is undoable through editor history, so it commits
1561
+ * straight away rather than opening a confirmation step.
1562
+ */
1444
1563
  renderSelectionControls() {
1445
1564
  return html `
1446
1565
  <div class="selection-editor-controls">
@@ -1452,9 +1571,99 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
1452
1571
  >
1453
1572
  ${this.pinSelection ? t('selectionEditor.unpinSelection') : t('selectionEditor.pinSelection')}
1454
1573
  </button>
1574
+ ${(() => {
1575
+ const deletable = this.getDeletableNode();
1576
+ return deletable
1577
+ ? html `
1578
+ <button
1579
+ type="button"
1580
+ class="selection-editor-button selection-editor-button--secondary selection-editor-button--danger"
1581
+ @click=${this.deleteSelectedNode}
1582
+ >
1583
+ ${this.getDeleteLabel(deletable)}
1584
+ </button>
1585
+ `
1586
+ : nothing;
1587
+ })()}
1455
1588
  </div>
1456
1589
  `;
1457
1590
  }
1591
+ /**
1592
+ * The node the delete action would remove, or `null` when there is none the
1593
+ * panel may act on.
1594
+ *
1595
+ * Only a `NodeSelection` qualifies: a text range inside a caption or paragraph
1596
+ * belongs to the editing surface, not to a panel-level "delete this element"
1597
+ * verb. `deleteSelection` is probed with `dispatch` omitted, so the check
1598
+ * cannot edit the document.
1599
+ *
1600
+ * A view that is not editable never yields a target. A host can turn editing
1601
+ * off at any time (`view.setProps({ editable: () => false })`) and expects the
1602
+ * whole surface to go quiet — a side panel that still removes content would
1603
+ * make a read-only document lose it. `view.editable` is the same signal the
1604
+ * editor's own key and click handling reads, so the panel cannot drift from
1605
+ * the editing surface.
1606
+ *
1607
+ * Withheld while the panel is pinned, and that is a limitation rather than a
1608
+ * design goal. A pinned panel stops following the editor selection and
1609
+ * therefore stops re-rendering, while `elementInfo.position` is never remapped
1610
+ * through the transaction mapping — so every pinned control already acts on a
1611
+ * position that may have drifted, and the attribute fields cope only because
1612
+ * they re-check the node type at that position before writing. Removal has no
1613
+ * such recheck available: after one deletion the frozen panel would still show
1614
+ * the control, now aimed at whatever moved into that position. Making the pin
1615
+ * remap its stored position on every document change would fix this for all
1616
+ * pinned controls at once; until then the destructive one stays out.
1617
+ */
1618
+ getDeletableNode() {
1619
+ const view = this.resolvedEditor?.editorView;
1620
+ if (!view || this.pinSelection || !view.editable) {
1621
+ return null;
1622
+ }
1623
+ const state = view.state;
1624
+ if (!(state.selection instanceof NodeSelection) || !deleteSelection(state)) {
1625
+ return null;
1626
+ }
1627
+ return state.selection.node;
1628
+ }
1629
+ /**
1630
+ * Label for the delete action, naming what is about to be removed. The visible
1631
+ * text is the button's accessible name, so naming the node here keeps the two
1632
+ * identical — no `aria-label` to drift from what is on screen. Anything the
1633
+ * panel has no name for falls back to the generic wording.
1634
+ */
1635
+ getDeleteLabel(node) {
1636
+ const name = this.isTableFigure(node)
1637
+ ? t('selectionEditor.table')
1638
+ : this.getNodeTypeName(node.type.name);
1639
+ return name
1640
+ ? t('selectionEditor.deleteNamedElement').replace('{element}', name)
1641
+ : t('selectionEditor.deleteElement');
1642
+ }
1643
+ /**
1644
+ * Remove the selected node through ProseMirror's own deletion command, so the
1645
+ * change is a normal transaction and stays undoable and collab-safe. The state
1646
+ * is read from the view at call time rather than reused from render, and the
1647
+ * editability guard is repeated here: a host can switch the view to read-only
1648
+ * after the button was rendered, and the rendered panel would not know.
1649
+ */
1650
+ deleteSelectedNode() {
1651
+ const view = this.resolvedEditor?.editorView;
1652
+ if (!view || !view.editable) {
1653
+ return;
1654
+ }
1655
+ const state = view.state;
1656
+ if (!(state.selection instanceof NodeSelection)) {
1657
+ return;
1658
+ }
1659
+ if (!deleteSelection(state, (tr) => view.dispatch(tr))) {
1660
+ return;
1661
+ }
1662
+ // Focus would otherwise be orphaned on a button that just disappeared.
1663
+ view.focus();
1664
+ const selection = view.state.selection;
1665
+ this.updateElementInfo({ anchor: selection.anchor, head: selection.head });
1666
+ }
1458
1667
  togglePinSelection() {
1459
1668
  this.pinSelection = !this.pinSelection;
1460
1669
  if (!this.pinSelection && this.resolvedEditor?.editorView) {
@@ -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"}