@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.
- package/LICENSE +21 -0
- package/README.md +50 -29
- package/dist/bundle/sciflow-editor.css +1 -1
- package/dist/bundle/sciflow-editor.js +4020 -3326
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- 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 +29 -0
- package/dist/lib/format-bar.d.ts.map +1 -1
- package/dist/lib/format-bar.js +53 -3
- 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/lib/selection-editor.d.ts +103 -2
- package/dist/lib/selection-editor.d.ts.map +1 -1
- package/dist/lib/selection-editor.js +222 -13
- 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 +25 -13
- package/dist/lib/test-fixtures/soak-documents.d.ts +0 -13
- package/dist/lib/test-fixtures/soak-documents.d.ts.map +0 -1
- package/dist/lib/test-fixtures/soak-documents.js +0 -69
- package/dist/tsconfig.lib.tsbuildinfo +0 -1
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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"
|
|
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=${
|
|
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"}
|