@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
package/dist/lib/format-bar.js
CHANGED
|
@@ -59,6 +59,16 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
59
59
|
super(...arguments);
|
|
60
60
|
/** Reference to the editor element. May be passed directly via property binding. */
|
|
61
61
|
this.editor = null;
|
|
62
|
+
/**
|
|
63
|
+
* Space- or comma-separated list of command-metadata `group` names to omit
|
|
64
|
+
* from rendering (e.g. `"align"`). Suppresses the toolbar buttons for that
|
|
65
|
+
* group without touching command registration — the commands stay
|
|
66
|
+
* schema-legal and remain reachable via `editor.commands`/`runner.available()`.
|
|
67
|
+
* Use this for structured-manuscript contexts (e.g. a JATS-based export)
|
|
68
|
+
* where a UI-exposed capability isn't representable in the export content
|
|
69
|
+
* model. See docs/pages/user-guide/web-component-api.md.
|
|
70
|
+
*/
|
|
71
|
+
this.hiddenGroups = '';
|
|
62
72
|
this.selectionVersion = 0;
|
|
63
73
|
this.styleMenuOpen = false;
|
|
64
74
|
this.insertMenuOpen = false;
|
|
@@ -109,7 +119,7 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
109
119
|
*/
|
|
110
120
|
render() {
|
|
111
121
|
const runner = this.getCommandRunner();
|
|
112
|
-
const commandMetadata = runner?.commandsMeta?.() ?? {};
|
|
122
|
+
const commandMetadata = this.filterHiddenGroups(runner?.commandsMeta?.() ?? {});
|
|
113
123
|
const commandGroups = this.getCommandGroups(commandMetadata);
|
|
114
124
|
const tableGroup = 'table';
|
|
115
125
|
const mediaGroup = 'media';
|
|
@@ -255,6 +265,13 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
255
265
|
/* --------------------------------------------------------- */
|
|
256
266
|
/* Insert dropdown (icon + chevron → popover) */
|
|
257
267
|
/* --------------------------------------------------------- */
|
|
268
|
+
/**
|
|
269
|
+
* The trigger shows the localised "Insert" label next to the `+` icon: the
|
|
270
|
+
* word only appeared on the menu items before, leaving sighted users with a
|
|
271
|
+
* bare glyph. The visible label and `aria-label` are read from the same key,
|
|
272
|
+
* so they cannot diverge — and because `aria-label` replaces a button's text
|
|
273
|
+
* content for assistive tech, the word is still announced exactly once.
|
|
274
|
+
*/
|
|
258
275
|
renderInsertDropdown(runner, metadata) {
|
|
259
276
|
const availability = runner?.available?.() ?? null;
|
|
260
277
|
// Collect block-level insert commands (media group + table insert + footnote)
|
|
@@ -275,25 +292,32 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
275
292
|
@click=${this.toggleInsertMenu}
|
|
276
293
|
>
|
|
277
294
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
|
295
|
+
<span class="insert-dropdown-label">${t('formatBar.insert')}</span>
|
|
278
296
|
<span class="dropdown-chevron">${chevronSvg}</span>
|
|
279
297
|
</button>
|
|
280
298
|
${this.insertMenuOpen
|
|
281
299
|
? html `
|
|
282
|
-
<div class="insert-dropdown-menu" role="menu">
|
|
300
|
+
<div class="insert-dropdown-menu" role="menu" aria-label=${t('formatBar.insert')}>
|
|
283
301
|
${insertCommands.map(([name, meta]) => {
|
|
284
302
|
const canExecute = availability?.[name] ? availability[name]() : true;
|
|
303
|
+
// The trigger above already says "Insert", so an item shows its
|
|
304
|
+
// short wording when the command offers one ("Figure", not
|
|
305
|
+
// "Insert figure"). The full label stays the accessible name,
|
|
306
|
+
// so nothing is lost for a screen-reader user.
|
|
307
|
+
const fullLabel = meta?.label ?? name;
|
|
285
308
|
return html `
|
|
286
309
|
<button
|
|
287
310
|
type="button"
|
|
288
311
|
role="menuitem"
|
|
289
312
|
class="insert-menu-item"
|
|
313
|
+
aria-label=${fullLabel}
|
|
290
314
|
?disabled=${!canExecute}
|
|
291
315
|
@click=${() => this.executeInsertCommand(name, runner)}
|
|
292
316
|
>
|
|
293
317
|
${meta?.icon && materialIcons[meta.icon]
|
|
294
318
|
? html `<span class="cmd-icon" aria-hidden="true">${unsafeHTML(materialIcons[meta.icon])}</span>`
|
|
295
319
|
: nothing}
|
|
296
|
-
${meta?.
|
|
320
|
+
${meta?.shortLabel ?? fullLabel}
|
|
297
321
|
</button>
|
|
298
322
|
`;
|
|
299
323
|
})}
|
|
@@ -663,6 +687,29 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
663
687
|
</button>
|
|
664
688
|
`;
|
|
665
689
|
}
|
|
690
|
+
/**
|
|
691
|
+
* Parse the `hidden-groups` attribute into a set of group names to suppress.
|
|
692
|
+
* Accepts whitespace- and/or comma-separated values (e.g. `"align table"`, `"align,table"`).
|
|
693
|
+
*/
|
|
694
|
+
get hiddenGroupSet() {
|
|
695
|
+
return new Set(this.hiddenGroups
|
|
696
|
+
.split(/[\s,]+/)
|
|
697
|
+
.map((group) => group.trim())
|
|
698
|
+
.filter(Boolean));
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Drop metadata entries whose `group` is listed in `hidden-groups` before any
|
|
702
|
+
* rendering derivation runs. This is a rendering-only filter: it does not touch
|
|
703
|
+
* command registration, so hidden commands stay schema-legal and remain reachable
|
|
704
|
+
* via `editor.commands`/`runner.available()` — only the toolbar buttons disappear.
|
|
705
|
+
*/
|
|
706
|
+
filterHiddenGroups(metadata) {
|
|
707
|
+
const hidden = this.hiddenGroupSet;
|
|
708
|
+
if (hidden.size === 0) {
|
|
709
|
+
return metadata;
|
|
710
|
+
}
|
|
711
|
+
return Object.fromEntries(Object.entries(metadata).filter(([, meta]) => !meta?.group || !hidden.has(meta.group)));
|
|
712
|
+
}
|
|
666
713
|
/**
|
|
667
714
|
* Determine which groups should be displayed and in which order based on command metadata.
|
|
668
715
|
*/
|
|
@@ -699,6 +746,9 @@ __decorate([
|
|
|
699
746
|
__decorate([
|
|
700
747
|
property({ type: String, attribute: 'for' })
|
|
701
748
|
], SciFlowFormatBarElement.prototype, "for", void 0);
|
|
749
|
+
__decorate([
|
|
750
|
+
property({ type: String, attribute: 'hidden-groups' })
|
|
751
|
+
], SciFlowFormatBarElement.prototype, "hiddenGroups", void 0);
|
|
702
752
|
__decorate([
|
|
703
753
|
state()
|
|
704
754
|
], SciFlowFormatBarElement.prototype, "selectionVersion", void 0);
|
package/dist/lib/outline.d.ts
CHANGED
|
@@ -69,6 +69,23 @@ export declare class SciFlowOutlineElement extends LitElement {
|
|
|
69
69
|
private localeListener?;
|
|
70
70
|
private themeUnsub?;
|
|
71
71
|
private themeStyleElements;
|
|
72
|
+
/**
|
|
73
|
+
* Re-attempts `for`/`editor` resolution whenever ANY `<sciflow-editor>` in the
|
|
74
|
+
* document announces readiness. `resolveEditorReference()` otherwise only runs once
|
|
75
|
+
* (`connectedCallback`) and again whenever the `for`/`editor` property itself
|
|
76
|
+
* changes — but a host that computes `for` once from a stable id (e.g. derived from
|
|
77
|
+
* a route param, not from load state) never re-sets it, so if the target editor
|
|
78
|
+
* doesn't exist yet — or exists but hasn't loaded its document yet — at the moment
|
|
79
|
+
* this element first connects, the outline was stuck empty forever even after the
|
|
80
|
+
* editor became ready. `editor-ready` is dispatched with
|
|
81
|
+
* `bubbles: true, composed: true` (see `editor-element.ts`), so listening for it on
|
|
82
|
+
* `document` reaches this element regardless of shadow-root nesting or DOM order,
|
|
83
|
+
* without requiring any change on the consumer side. `resolveEditorReference()` is
|
|
84
|
+
* idempotent — it no-ops when the resolved target hasn't changed — so listening
|
|
85
|
+
* broadly (rather than only for the specific target id) is safe even with several
|
|
86
|
+
* editor/outline pairs on one page.
|
|
87
|
+
*/
|
|
88
|
+
private documentEditorReadyListener?;
|
|
72
89
|
static styles: import("lit").CSSResult;
|
|
73
90
|
connectedCallback(): void;
|
|
74
91
|
disconnectedCallback(): void;
|
|
@@ -79,7 +96,29 @@ export declare class SciFlowOutlineElement extends LitElement {
|
|
|
79
96
|
private handleCrossReferenceDragStart;
|
|
80
97
|
private handleInsertCrossReference;
|
|
81
98
|
private handleHeadingKeydown;
|
|
99
|
+
private handleFigureKeydown;
|
|
82
100
|
private handleHeadingClick;
|
|
101
|
+
/**
|
|
102
|
+
* A figure entry was activated. Same navigation as a heading — the outline is a way of walking
|
|
103
|
+
* the document, and a figure is one of the places you walk to.
|
|
104
|
+
*
|
|
105
|
+
* Until now figure rows had no click handler at all: they rendered `role="listitem"` with no
|
|
106
|
+
* `@click` and no `@keydown`, so clicking one did nothing and keyboard users could tab to a row
|
|
107
|
+
* that could not be activated. Only headings were navigable.
|
|
108
|
+
*
|
|
109
|
+
* The position resolution below is what makes this work for a node that is not a textblock:
|
|
110
|
+
* `commands.setSelection` builds a `TextSelection.near()`, which lands on the closest text
|
|
111
|
+
* position to the figure — its caption, in practice — and scrolling that into view brings the
|
|
112
|
+
* figure with it.
|
|
113
|
+
*/
|
|
114
|
+
private handleFigureClick;
|
|
115
|
+
/**
|
|
116
|
+
* Move the editor to an outline entry, and tell listeners.
|
|
117
|
+
*
|
|
118
|
+
* One path for both entry kinds on purpose: the heading version was the only one that existed,
|
|
119
|
+
* and a second copy for figures is how the two would drift apart the first time either is fixed.
|
|
120
|
+
*/
|
|
121
|
+
private navigateToEntry;
|
|
83
122
|
private resolveEditorReference;
|
|
84
123
|
private resolveEditor;
|
|
85
124
|
private attachEditorListeners;
|
|
@@ -90,6 +129,11 @@ export declare class SciFlowOutlineElement extends LitElement {
|
|
|
90
129
|
private updateActiveHeading;
|
|
91
130
|
private findHeadingForSelection;
|
|
92
131
|
private headingKey;
|
|
132
|
+
/**
|
|
133
|
+
* The active-entry key for a figure. Namespaced, because both lists share one `activeHeadingKey`
|
|
134
|
+
* and a figure's id could otherwise collide with a heading's and highlight two rows at once.
|
|
135
|
+
*/
|
|
136
|
+
private figureKey;
|
|
93
137
|
private getHeadingSelectionPosition;
|
|
94
138
|
private extractDocFromEditor;
|
|
95
139
|
private normalizeClickBehavior;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"outline.d.ts","sourceRoot":"","sources":["../../src/lib/outline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,UAAU,EAAsB,MAAM,KAAK,CAAC;AAGhE,OAAO,KAAK,EAAE,IAAI,IAAI,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEjE,OAAO,EAKL,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"outline.d.ts","sourceRoot":"","sources":["../../src/lib/outline.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/outline.js
CHANGED
|
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
|
|
3
3
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
4
4
|
import { classMap } from 'lit/directives/class-map.js';
|
|
5
5
|
import { SourceField, t, LOCALE_CHANGE_EVENT, } from '@sciflow/editor-core';
|
|
6
|
+
import { texToHeadingText } from '@sciflow/schema-core';
|
|
6
7
|
import { setTransparentDragImage } from './drag-preview.js';
|
|
7
8
|
import outlineStyles from './outline.css?inline';
|
|
8
9
|
import { applyThemeStylesToRoot, subscribeToSciFlowTheme } from './theme.js';
|
|
@@ -46,6 +47,49 @@ const parseLevelsFromAttribute = (value) => {
|
|
|
46
47
|
* When a ProseMirror document is available, positions and node boundaries are
|
|
47
48
|
* included so navigation and selection syncing can be precise.
|
|
48
49
|
*/
|
|
50
|
+
/**
|
|
51
|
+
* Reduce a heading's content to plain text: a
|
|
52
|
+
* `math` child contributes `texToHeadingText(attrs.tex)` instead of nothing, so a heading
|
|
53
|
+
* containing math never renders empty/truncated in the outline, drag-and-drop, or the
|
|
54
|
+
* cross-reference target list.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately walks only DIRECT children (`node.forEach`), not `node.textContent` (which
|
|
57
|
+
* recurses into every descendant). That recursion is what let a `footnote` child's own text leak
|
|
58
|
+
* into the heading string — this must never happen (H8 corpus case): a footnote is content
|
|
59
|
+
* *about* the heading, not part of its title. Any other non-text child (`bookmark`, or a future
|
|
60
|
+
* addition) contributes nothing, matching `textContent`'s existing behavior for atoms with no
|
|
61
|
+
* text — only `math` gets special handling, per R1.
|
|
62
|
+
*/
|
|
63
|
+
function headingTextFromPmNode(node) {
|
|
64
|
+
let text = '';
|
|
65
|
+
node.forEach((child) => {
|
|
66
|
+
if (child.isText) {
|
|
67
|
+
text += child.text ?? '';
|
|
68
|
+
}
|
|
69
|
+
else if (child.type.name === 'math') {
|
|
70
|
+
text += texToHeadingText(typeof child.attrs?.tex === 'string' ? child.attrs.tex : '');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return text.trim();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Slow-path (doc-only JSON) counterpart to `headingTextFromPmNode` — same R1 reduction, applied to
|
|
77
|
+
* a heading's `content` array instead of a live ProseMirror node. Already excludes a `footnote`
|
|
78
|
+
* child's text by construction (a footnote node carries no top-level `.text`, only nested
|
|
79
|
+
* `.content`), matching the fast path's H8 behavior.
|
|
80
|
+
*/
|
|
81
|
+
function headingTextFromJsonChildren(content) {
|
|
82
|
+
return content
|
|
83
|
+
.map((child) => {
|
|
84
|
+
if (child?.type === 'math') {
|
|
85
|
+
const tex = typeof child?.attrs?.tex === 'string' ? child.attrs.tex : '';
|
|
86
|
+
return texToHeadingText(tex);
|
|
87
|
+
}
|
|
88
|
+
return child?.text ?? '';
|
|
89
|
+
})
|
|
90
|
+
.join('')
|
|
91
|
+
.trim();
|
|
92
|
+
}
|
|
49
93
|
function figureLabel(node) {
|
|
50
94
|
const text = node.textContent?.trim();
|
|
51
95
|
if (text)
|
|
@@ -85,7 +129,7 @@ export function collectDocumentOutline(doc, pmDoc = null) {
|
|
|
85
129
|
pmDoc.descendants((node, position) => {
|
|
86
130
|
if (node.type.name === 'heading') {
|
|
87
131
|
outline.headings.push({
|
|
88
|
-
text: node
|
|
132
|
+
text: headingTextFromPmNode(node),
|
|
89
133
|
level: node.attrs?.level ?? null,
|
|
90
134
|
id: node.attrs?.id ?? null,
|
|
91
135
|
position,
|
|
@@ -119,7 +163,7 @@ export function collectDocumentOutline(doc, pmDoc = null) {
|
|
|
119
163
|
}
|
|
120
164
|
const { type, attrs = {}, content = [] } = node;
|
|
121
165
|
if (type === 'heading') {
|
|
122
|
-
const headingText = content
|
|
166
|
+
const headingText = headingTextFromJsonChildren(content);
|
|
123
167
|
outline.headings.push({
|
|
124
168
|
text: headingText,
|
|
125
169
|
level: attrs.level ?? null,
|
|
@@ -192,6 +236,8 @@ let SciFlowOutlineElement = class SciFlowOutlineElement extends LitElement {
|
|
|
192
236
|
});
|
|
193
237
|
this.localeListener = () => this.requestUpdate();
|
|
194
238
|
document.addEventListener(LOCALE_CHANGE_EVENT, this.localeListener);
|
|
239
|
+
this.documentEditorReadyListener = () => this.resolveEditorReference();
|
|
240
|
+
document.addEventListener('editor-ready', this.documentEditorReadyListener);
|
|
195
241
|
this.resolveEditorReference();
|
|
196
242
|
}
|
|
197
243
|
disconnectedCallback() {
|
|
@@ -200,6 +246,10 @@ let SciFlowOutlineElement = class SciFlowOutlineElement extends LitElement {
|
|
|
200
246
|
document.removeEventListener(LOCALE_CHANGE_EVENT, this.localeListener);
|
|
201
247
|
this.localeListener = undefined;
|
|
202
248
|
}
|
|
249
|
+
if (this.documentEditorReadyListener) {
|
|
250
|
+
document.removeEventListener('editor-ready', this.documentEditorReadyListener);
|
|
251
|
+
this.documentEditorReadyListener = undefined;
|
|
252
|
+
}
|
|
203
253
|
this.themeUnsub?.();
|
|
204
254
|
this.themeUnsub = undefined;
|
|
205
255
|
super.disconnectedCallback();
|
|
@@ -291,17 +341,25 @@ let SciFlowOutlineElement = class SciFlowOutlineElement extends LitElement {
|
|
|
291
341
|
const id = figure.id?.trim();
|
|
292
342
|
const draggable = Boolean(id);
|
|
293
343
|
const label = figure.text?.trim() || (id ?? `${t('outline.figure')} ${index + 1}`);
|
|
344
|
+
// Same gate as a heading: navigable when clicks are enabled and the entry knows where it is.
|
|
345
|
+
const clickable = this.clickBehavior !== 'none' && figure.position !== null && figure.position !== undefined;
|
|
346
|
+
const key = this.figureKey(figure, index);
|
|
347
|
+
const isActive = this.activeHeadingKey === key;
|
|
294
348
|
return html `
|
|
295
349
|
<li
|
|
296
350
|
class=${classMap({
|
|
297
351
|
'outline-item': true,
|
|
298
352
|
'outline-item--figure': true,
|
|
353
|
+
'outline-item--active': isActive,
|
|
299
354
|
})}
|
|
300
|
-
part
|
|
355
|
+
part=${`item${isActive ? ' item-active' : ''}`}
|
|
301
356
|
data-outline-id=${figure.id ?? ''}
|
|
302
|
-
role
|
|
303
|
-
tabindex
|
|
357
|
+
role=${clickable ? 'button' : 'listitem'}
|
|
358
|
+
tabindex=${clickable ? 0 : -1}
|
|
359
|
+
aria-current=${isActive ? 'true' : 'false'}
|
|
304
360
|
draggable=${draggable ? 'true' : 'false'}
|
|
361
|
+
@click=${clickable ? () => this.handleFigureClick(figure, index) : undefined}
|
|
362
|
+
@keydown=${clickable ? (event) => this.handleFigureKeydown(event, figure, index) : undefined}
|
|
305
363
|
@dragstart=${draggable && id ? (e) => this.handleCrossReferenceDragStart(e, 'figure', id, label) : undefined}
|
|
306
364
|
>
|
|
307
365
|
<span class="outline-level outline-level--figure" aria-hidden="true">Fig</span>
|
|
@@ -376,7 +434,45 @@ let SciFlowOutlineElement = class SciFlowOutlineElement extends LitElement {
|
|
|
376
434
|
this.handleHeadingClick(heading, index);
|
|
377
435
|
}
|
|
378
436
|
}
|
|
437
|
+
handleFigureKeydown(event, figure, index) {
|
|
438
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
439
|
+
event.preventDefault();
|
|
440
|
+
this.handleFigureClick(figure, index);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
379
443
|
handleHeadingClick(heading, index) {
|
|
444
|
+
this.navigateToEntry(heading, this.headingKey(heading, index), { kind: 'heading', heading });
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* A figure entry was activated. Same navigation as a heading — the outline is a way of walking
|
|
448
|
+
* the document, and a figure is one of the places you walk to.
|
|
449
|
+
*
|
|
450
|
+
* Until now figure rows had no click handler at all: they rendered `role="listitem"` with no
|
|
451
|
+
* `@click` and no `@keydown`, so clicking one did nothing and keyboard users could tab to a row
|
|
452
|
+
* that could not be activated. Only headings were navigable.
|
|
453
|
+
*
|
|
454
|
+
* The position resolution below is what makes this work for a node that is not a textblock:
|
|
455
|
+
* `commands.setSelection` builds a `TextSelection.near()`, which lands on the closest text
|
|
456
|
+
* position to the figure — its caption, in practice — and scrolling that into view brings the
|
|
457
|
+
* figure with it.
|
|
458
|
+
*/
|
|
459
|
+
handleFigureClick(figure, index) {
|
|
460
|
+
const asEntry = {
|
|
461
|
+
text: figure.text,
|
|
462
|
+
level: null,
|
|
463
|
+
id: figure.id,
|
|
464
|
+
position: figure.position,
|
|
465
|
+
end: figure.end,
|
|
466
|
+
};
|
|
467
|
+
this.navigateToEntry(asEntry, this.figureKey(figure, index), { kind: 'figure', figure });
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Move the editor to an outline entry, and tell listeners.
|
|
471
|
+
*
|
|
472
|
+
* One path for both entry kinds on purpose: the heading version was the only one that existed,
|
|
473
|
+
* and a second copy for figures is how the two would drift apart the first time either is fixed.
|
|
474
|
+
*/
|
|
475
|
+
navigateToEntry(heading, activeKey, origin) {
|
|
380
476
|
if (this.clickBehavior === 'none') {
|
|
381
477
|
return;
|
|
382
478
|
}
|
|
@@ -414,10 +510,15 @@ let SciFlowOutlineElement = class SciFlowOutlineElement extends LitElement {
|
|
|
414
510
|
}
|
|
415
511
|
}
|
|
416
512
|
}
|
|
417
|
-
this.activeHeadingKey =
|
|
513
|
+
this.activeHeadingKey = activeKey;
|
|
418
514
|
this.dispatchEvent(new CustomEvent('sciflow-outline-navigate', {
|
|
419
515
|
detail: {
|
|
516
|
+
// `heading` is kept on every event, whatever the kind, so existing listeners are
|
|
517
|
+
// untouched; `kind` and `figure` are additive. For a figure, `heading` carries the same
|
|
518
|
+
// text/id/position in the shape listeners already read.
|
|
420
519
|
heading,
|
|
520
|
+
kind: origin.kind,
|
|
521
|
+
...(origin.kind === 'figure' ? { figure: origin.figure } : {}),
|
|
421
522
|
behavior: this.clickBehavior,
|
|
422
523
|
selectionPosition: selectionPosition ?? heading.position,
|
|
423
524
|
},
|
|
@@ -579,6 +680,19 @@ let SciFlowOutlineElement = class SciFlowOutlineElement extends LitElement {
|
|
|
579
680
|
}
|
|
580
681
|
return `idx-${index}`;
|
|
581
682
|
}
|
|
683
|
+
/**
|
|
684
|
+
* The active-entry key for a figure. Namespaced, because both lists share one `activeHeadingKey`
|
|
685
|
+
* and a figure's id could otherwise collide with a heading's and highlight two rows at once.
|
|
686
|
+
*/
|
|
687
|
+
figureKey(figure, index = this.figures.indexOf(figure)) {
|
|
688
|
+
if (figure.id) {
|
|
689
|
+
return `figure:${figure.id}`;
|
|
690
|
+
}
|
|
691
|
+
if (figure.position !== null && figure.position !== undefined) {
|
|
692
|
+
return `figure:pos-${figure.position}`;
|
|
693
|
+
}
|
|
694
|
+
return `figure:idx-${index}`;
|
|
695
|
+
}
|
|
582
696
|
getHeadingSelectionPosition(position, editor) {
|
|
583
697
|
if (!Number.isFinite(position)) {
|
|
584
698
|
return null;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module range-decorations
|
|
3
|
+
*
|
|
4
|
+
* A generic, data-driven ProseMirror decorations Feature factory. Renders a
|
|
5
|
+
* caller-supplied list of `{ from, to?, class? }` ranges as native
|
|
6
|
+
* `Decoration.inline` (a real `[from, to)` span) or `Decoration.widget` (a
|
|
7
|
+
* zero-width point marker, when `to` is omitted or equal to `from`) — no
|
|
8
|
+
* highlighting/diff semantics live here, only the mechanism.
|
|
9
|
+
*
|
|
10
|
+
* # Why this exists — the dual-package hazard, for decorations specifically
|
|
11
|
+
*
|
|
12
|
+
* `Feature.addPlugins()` (`docs/pages/user-guide/customization/adding-plugins.md`)
|
|
13
|
+
* is the documented, supported way to add a plain ProseMirror `Plugin` to the
|
|
14
|
+
* editor from a HOST APPLICATION — and that part works even when the plugin is
|
|
15
|
+
* built with the app's OWN `prosemirror-state` copy (a plain object shape is
|
|
16
|
+
* all `Editor.create()` requires; confirmed live: the plugin registers in
|
|
17
|
+
* `view.state.plugins`, `PluginKey.getState()` resolves it correctly).
|
|
18
|
+
*
|
|
19
|
+
* Decorations are a DIFFERENT story. `@sciflow/editor-start/bundle` inlines
|
|
20
|
+
* `prosemirror-view` (`external: []` in `vite.config.ts`) — a host app's own
|
|
21
|
+
* `prosemirror-view` copy (from ITS `node_modules`) is a genuinely different
|
|
22
|
+
* module instance. A single app-authored decorating plugin renders fine in
|
|
23
|
+
* isolation, but the moment a SECOND decoration source coexists (any other
|
|
24
|
+
* feature/plugin that also implements `props.decorations` — realistic, not a
|
|
25
|
+
* corner case), `prosemirror-view`'s internal `DecorationGroup.from()` merges
|
|
26
|
+
* all sources and branches on `instanceof DecorationSet`: a `DecorationSet`
|
|
27
|
+
* built from the app's own module fails that check, falls through to reading
|
|
28
|
+
* `.members` on it (a property only `DecorationGroup` has, not `DecorationSet`
|
|
29
|
+
* — this is `undefined`), and the resulting merged group contains `undefined`
|
|
30
|
+
* entries. The very next render crashes inside the bundle's own
|
|
31
|
+
* `DecorationGroup.locals()` (`Cannot read properties of undefined (reading
|
|
32
|
+
* 'localsInner')`), taking the whole `editor.mount()` down with it — not a
|
|
33
|
+
* silent no-op. Verified live before writing this fix (not assumed): a
|
|
34
|
+
* single app-side decorating plugin renders correctly; two simultaneous
|
|
35
|
+
* app-side decorating plugins reproduce the crash above, byte for byte.
|
|
36
|
+
*
|
|
37
|
+
* The fix: `Decoration`/`Decoration Set` must be built with the BUNDLE's OWN
|
|
38
|
+
* `prosemirror-view` copy. This factory does exactly that — entirely inside
|
|
39
|
+
* `@sciflow/editor-start` — and hands the host app back only an opaque
|
|
40
|
+
* `PluginKey` and a `Feature`; the app never imports `prosemirror-state` or
|
|
41
|
+
* `prosemirror-view` itself for this, and only ever exchanges PLAIN DATA
|
|
42
|
+
* (`RangeDecorationSpec[]`, positions + a CSS class string) across the
|
|
43
|
+
* package boundary via the already-documented `editor.plugins.dispatchMeta()`
|
|
44
|
+
* API (`adding-plugins.md` § "Accessing Plugin State").
|
|
45
|
+
*
|
|
46
|
+
* # Usage
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* import { createRangeDecorationsFeature } from '@sciflow/editor-start/bundle';
|
|
50
|
+
*
|
|
51
|
+
* const { feature, key } = createRangeDecorationsFeature('my-diff-decorations');
|
|
52
|
+
* await editor.configureFeatures([...otherFeatures, feature]);
|
|
53
|
+
*
|
|
54
|
+
* // Whenever the ranges to highlight change:
|
|
55
|
+
* editor.plugins.dispatchMeta(key, [
|
|
56
|
+
* { from: 12, to: 40, class: 'my-added' },
|
|
57
|
+
* { from: 40, class: 'my-removed' }, // no `to` — a point marker
|
|
58
|
+
* ]);
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* Style decoration classes via global CSS scoped to `.sf-editable-surface`
|
|
62
|
+
* (see `adding-plugins.md` § "Styling Plugin Decorations") — the SAME
|
|
63
|
+
* mechanism `ghostCursorFeature` already relies on, not a new one.
|
|
64
|
+
*/
|
|
65
|
+
import { PluginKey } from 'prosemirror-state';
|
|
66
|
+
import type { Feature } from '@sciflow/editor-core';
|
|
67
|
+
/** One decorated range, in the CURRENT doc's position space. `to` omitted (or
|
|
68
|
+
* equal to `from`) renders a zero-width point marker (`Decoration.widget`)
|
|
69
|
+
* instead of a highlighted span — e.g. content that no longer exists at this
|
|
70
|
+
* position in the doc, so there's nothing to highlight, only a point to
|
|
71
|
+
* mark. `class` is applied to the decoration's DOM node (the widget span,
|
|
72
|
+
* or the inline span's own class list) — style it via global CSS, never
|
|
73
|
+
* inline styles, per the package's styling convention. */
|
|
74
|
+
export interface RangeDecorationSpec {
|
|
75
|
+
from: number;
|
|
76
|
+
to?: number;
|
|
77
|
+
class?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Rendered as the point-marker widget's visible `textContent` (only
|
|
80
|
+
* applies to the `Decoration.widget` branch above — a real `[from, to)`
|
|
81
|
+
* span has its own content already and ignores this field). Absent (the
|
|
82
|
+
* default) or an empty string reproduces today's behaviour byte-for-byte:
|
|
83
|
+
* an empty `aria-hidden="true"` marker span with no visible content (e.g.
|
|
84
|
+
* a thin caret-style tick).
|
|
85
|
+
*
|
|
86
|
+
* When a non-empty string is given, the widget instead renders it inline —
|
|
87
|
+
* e.g. a change-tracking host's deleted-run text, kept visible/struck-through at the
|
|
88
|
+
* point of deletion — and the marker is made readable to assistive
|
|
89
|
+
* technology instead of hidden from it (content a sighted user can still
|
|
90
|
+
* see should be announced too): `aria-hidden` is dropped and
|
|
91
|
+
* `aria-label="Deleted text: <text>"` is set instead, so AT gets context
|
|
92
|
+
* rather than just the bare string sitting mid-sentence. The widget also
|
|
93
|
+
* gets `data-with-text="true"`, a package-owned styling hook that lets one
|
|
94
|
+
* consumer `class` render the empty-marker and text-bearing cases
|
|
95
|
+
* differently (e.g. `.my-removed[data-with-text] { padding: 0 2px; }`)
|
|
96
|
+
* without computing two different `class` strings upstream.
|
|
97
|
+
*
|
|
98
|
+
* This factory renders whatever string it's given, verbatim, with no
|
|
99
|
+
* length cap or ellipsis — truncation policy (if any) is the CALLER's,
|
|
100
|
+
* decided before the range reaches `dispatchMeta`.
|
|
101
|
+
*/
|
|
102
|
+
text?: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Build a fresh, independently-keyed range-decorations `Feature`. Call once
|
|
106
|
+
* per logical decoration layer (a second call with the same `name` still
|
|
107
|
+
* works — `PluginKey` de-dupes same-named keys internally — but a distinct
|
|
108
|
+
* `name` per layer keeps `dispatchMeta` targeting unambiguous when a host
|
|
109
|
+
* uses more than one).
|
|
110
|
+
*
|
|
111
|
+
* Returns the `Feature` to pass into `configureFeatures()`/`features`, and
|
|
112
|
+
* the `PluginKey` to read (`editor.plugins.getState(key)`) or update
|
|
113
|
+
* (`editor.plugins.dispatchMeta(key, ranges)`) the live range list — both
|
|
114
|
+
* already-public, already-documented `<sciflow-editor>` APIs.
|
|
115
|
+
*/
|
|
116
|
+
export declare function createRangeDecorationsFeature(name: string): {
|
|
117
|
+
feature: Feature;
|
|
118
|
+
key: PluginKey<RangeDecorationSpec[]>;
|
|
119
|
+
};
|
|
120
|
+
//# sourceMappingURL=range-decorations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"range-decorations.d.ts","sourceRoot":"","sources":["../../src/lib/range-decorations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AAEH,OAAO,EAAU,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAEpD;;;;;;2DAM2D;AAC3D,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAkBD;;;;;;;;;;;GAWG;AACH,wBAAgB,6BAA6B,CAC3C,IAAI,EAAE,MAAM,GACX;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,SAAS,CAAC,mBAAmB,EAAE,CAAC,CAAA;CAAE,CAmC7D"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module range-decorations
|
|
3
|
+
*
|
|
4
|
+
* A generic, data-driven ProseMirror decorations Feature factory. Renders a
|
|
5
|
+
* caller-supplied list of `{ from, to?, class? }` ranges as native
|
|
6
|
+
* `Decoration.inline` (a real `[from, to)` span) or `Decoration.widget` (a
|
|
7
|
+
* zero-width point marker, when `to` is omitted or equal to `from`) — no
|
|
8
|
+
* highlighting/diff semantics live here, only the mechanism.
|
|
9
|
+
*
|
|
10
|
+
* # Why this exists — the dual-package hazard, for decorations specifically
|
|
11
|
+
*
|
|
12
|
+
* `Feature.addPlugins()` (`docs/pages/user-guide/customization/adding-plugins.md`)
|
|
13
|
+
* is the documented, supported way to add a plain ProseMirror `Plugin` to the
|
|
14
|
+
* editor from a HOST APPLICATION — and that part works even when the plugin is
|
|
15
|
+
* built with the app's OWN `prosemirror-state` copy (a plain object shape is
|
|
16
|
+
* all `Editor.create()` requires; confirmed live: the plugin registers in
|
|
17
|
+
* `view.state.plugins`, `PluginKey.getState()` resolves it correctly).
|
|
18
|
+
*
|
|
19
|
+
* Decorations are a DIFFERENT story. `@sciflow/editor-start/bundle` inlines
|
|
20
|
+
* `prosemirror-view` (`external: []` in `vite.config.ts`) — a host app's own
|
|
21
|
+
* `prosemirror-view` copy (from ITS `node_modules`) is a genuinely different
|
|
22
|
+
* module instance. A single app-authored decorating plugin renders fine in
|
|
23
|
+
* isolation, but the moment a SECOND decoration source coexists (any other
|
|
24
|
+
* feature/plugin that also implements `props.decorations` — realistic, not a
|
|
25
|
+
* corner case), `prosemirror-view`'s internal `DecorationGroup.from()` merges
|
|
26
|
+
* all sources and branches on `instanceof DecorationSet`: a `DecorationSet`
|
|
27
|
+
* built from the app's own module fails that check, falls through to reading
|
|
28
|
+
* `.members` on it (a property only `DecorationGroup` has, not `DecorationSet`
|
|
29
|
+
* — this is `undefined`), and the resulting merged group contains `undefined`
|
|
30
|
+
* entries. The very next render crashes inside the bundle's own
|
|
31
|
+
* `DecorationGroup.locals()` (`Cannot read properties of undefined (reading
|
|
32
|
+
* 'localsInner')`), taking the whole `editor.mount()` down with it — not a
|
|
33
|
+
* silent no-op. Verified live before writing this fix (not assumed): a
|
|
34
|
+
* single app-side decorating plugin renders correctly; two simultaneous
|
|
35
|
+
* app-side decorating plugins reproduce the crash above, byte for byte.
|
|
36
|
+
*
|
|
37
|
+
* The fix: `Decoration`/`Decoration Set` must be built with the BUNDLE's OWN
|
|
38
|
+
* `prosemirror-view` copy. This factory does exactly that — entirely inside
|
|
39
|
+
* `@sciflow/editor-start` — and hands the host app back only an opaque
|
|
40
|
+
* `PluginKey` and a `Feature`; the app never imports `prosemirror-state` or
|
|
41
|
+
* `prosemirror-view` itself for this, and only ever exchanges PLAIN DATA
|
|
42
|
+
* (`RangeDecorationSpec[]`, positions + a CSS class string) across the
|
|
43
|
+
* package boundary via the already-documented `editor.plugins.dispatchMeta()`
|
|
44
|
+
* API (`adding-plugins.md` § "Accessing Plugin State").
|
|
45
|
+
*
|
|
46
|
+
* # Usage
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* import { createRangeDecorationsFeature } from '@sciflow/editor-start/bundle';
|
|
50
|
+
*
|
|
51
|
+
* const { feature, key } = createRangeDecorationsFeature('my-diff-decorations');
|
|
52
|
+
* await editor.configureFeatures([...otherFeatures, feature]);
|
|
53
|
+
*
|
|
54
|
+
* // Whenever the ranges to highlight change:
|
|
55
|
+
* editor.plugins.dispatchMeta(key, [
|
|
56
|
+
* { from: 12, to: 40, class: 'my-added' },
|
|
57
|
+
* { from: 40, class: 'my-removed' }, // no `to` — a point marker
|
|
58
|
+
* ]);
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* Style decoration classes via global CSS scoped to `.sf-editable-surface`
|
|
62
|
+
* (see `adding-plugins.md` § "Styling Plugin Decorations") — the SAME
|
|
63
|
+
* mechanism `ghostCursorFeature` already relies on, not a new one.
|
|
64
|
+
*/
|
|
65
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
66
|
+
import { Decoration, DecorationSet } from 'prosemirror-view';
|
|
67
|
+
function toDecoration(spec) {
|
|
68
|
+
if (spec.to !== undefined && spec.to > spec.from) {
|
|
69
|
+
return Decoration.inline(spec.from, spec.to, spec.class ? { class: spec.class } : {});
|
|
70
|
+
}
|
|
71
|
+
const el = document.createElement('span');
|
|
72
|
+
if (spec.class)
|
|
73
|
+
el.className = spec.class;
|
|
74
|
+
if (spec.text) {
|
|
75
|
+
el.textContent = spec.text;
|
|
76
|
+
el.setAttribute('data-with-text', 'true');
|
|
77
|
+
el.setAttribute('aria-label', `Deleted text: ${spec.text}`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
el.setAttribute('aria-hidden', 'true');
|
|
81
|
+
}
|
|
82
|
+
return Decoration.widget(spec.from, el, { side: 0 });
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build a fresh, independently-keyed range-decorations `Feature`. Call once
|
|
86
|
+
* per logical decoration layer (a second call with the same `name` still
|
|
87
|
+
* works — `PluginKey` de-dupes same-named keys internally — but a distinct
|
|
88
|
+
* `name` per layer keeps `dispatchMeta` targeting unambiguous when a host
|
|
89
|
+
* uses more than one).
|
|
90
|
+
*
|
|
91
|
+
* Returns the `Feature` to pass into `configureFeatures()`/`features`, and
|
|
92
|
+
* the `PluginKey` to read (`editor.plugins.getState(key)`) or update
|
|
93
|
+
* (`editor.plugins.dispatchMeta(key, ranges)`) the live range list — both
|
|
94
|
+
* already-public, already-documented `<sciflow-editor>` APIs.
|
|
95
|
+
*/
|
|
96
|
+
export function createRangeDecorationsFeature(name) {
|
|
97
|
+
const key = new PluginKey(name);
|
|
98
|
+
const plugin = new Plugin({
|
|
99
|
+
key,
|
|
100
|
+
state: {
|
|
101
|
+
init: () => [],
|
|
102
|
+
apply(tr, ranges) {
|
|
103
|
+
const meta = tr.getMeta(key);
|
|
104
|
+
if (meta !== undefined)
|
|
105
|
+
return meta;
|
|
106
|
+
if (!tr.docChanged || ranges.length === 0)
|
|
107
|
+
return ranges;
|
|
108
|
+
// Remap stored positions through every transaction rather than
|
|
109
|
+
// letting them drift from the doc they describe.
|
|
110
|
+
return ranges.map((r) => ({
|
|
111
|
+
...r,
|
|
112
|
+
from: tr.mapping.map(r.from),
|
|
113
|
+
to: r.to !== undefined ? tr.mapping.map(r.to) : undefined,
|
|
114
|
+
}));
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
props: {
|
|
118
|
+
decorations(state) {
|
|
119
|
+
const ranges = key.getState(state);
|
|
120
|
+
if (!ranges || ranges.length === 0)
|
|
121
|
+
return DecorationSet.empty;
|
|
122
|
+
return DecorationSet.create(state.doc, ranges.map(toDecoration));
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
const feature = {
|
|
127
|
+
name,
|
|
128
|
+
addPlugins: () => [plugin],
|
|
129
|
+
};
|
|
130
|
+
return { feature, key };
|
|
131
|
+
}
|