@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.
@@ -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?.label ?? name}
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);
@@ -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;AAC9B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAKhE,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AA8EhE,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,EACtC,KAAK,GAAE,eAAe,GAAG,IAAW,GACnC,eAAe,CAoFjB;AAED;;;;;;;;;;GAUG;AACH,qBACa,qBAAsB,SAAQ,UAAU;IACnD,oFAAoF;IAEpF,MAAM,EAAE,oBAAoB,GAAG,IAAI,CAAQ;IAE3C,gEAAgE;IAEhE,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb,6EAA6E;IAE7E,GAAG,EAAE,cAAc,GAAG,IAAI,CAAQ;IAElC,+DAA+D;IAE/D,SAAS,SAAM;IAEf,mFAAmF;IAEnF,aAAa,EAAE,oBAAoB,CAAY;IAE/C,8EAA8E;IAO9E,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,CAAQ;IAE/B,8EAA8E;IAE9E,kBAAkB,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;IAGzE,OAAO,CAAC,QAAQ,CAAwB;IAGxC,OAAO,CAAC,OAAO,CAAuB;IAGtC,OAAO,CAAC,gBAAgB,CAAuB;IAE/C;;;;OAIG;IAEH,YAAY,UAAS;IAErB,OAAO,CAAC,cAAc,CAAqC;IAC3D,OAAO,CAAC,iBAAiB,CAAC,CAAgB;IAC1C,OAAO,CAAC,cAAc,CAAC,CAAgB;IACvC,OAAO,CAAC,aAAa,CAAC,CAAgB;IACtC,OAAO,CAAC,aAAa,CAA8B;IACnD,OAAO,CAAC,cAAc,CAAC,CAAgB;IACvC,OAAO,CAAC,UAAU,CAAC,CAAa;IAChC,OAAO,CAAC,kBAAkB,CAA0B;IAEpD,OAAgB,MAAM,0BAAoC;IAEjD,iBAAiB,IAAI,IAAI;IAczB,oBAAoB,IAAI,IAAI;cAWlB,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,MAAM,GAAG,MAAM,GAAG,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;cAkBxE,MAAM;IAmBzB,OAAO,CAAC,iBAAiB;IAuDzB,OAAO,CAAC,gBAAgB;IA8CxB,OAAO,CAAC,6BAA6B;IAgCrC,OAAO,CAAC,0BAA0B;IAgBlC,OAAO,CAAC,oBAAoB;IAO5B,OAAO,CAAC,kBAAkB;IAyD1B,OAAO,CAAC,sBAAsB;IA8B9B,OAAO,CAAC,aAAa;IAoBrB,OAAO,CAAC,qBAAqB;IAoC7B,OAAO,CAAC,qBAAqB;IAmB7B,OAAO,CAAC,qBAAqB;IAW7B,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,iBAAiB;IAczB,OAAO,CAAC,mBAAmB;IAS3B,OAAO,CAAC,uBAAuB;IAyB/B,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,2BAA2B;IAoBnC,OAAO,CAAC,oBAAoB;IAK5B,OAAO,CAAC,sBAAsB;CAI/B;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,qBAAqB;QAC7B,iBAAiB,EAAE,qBAAqB,CAAC;KAC1C;CACF"}
1
+ {"version":3,"file":"outline.d.ts","sourceRoot":"","sources":["../../src/lib/outline.ts"],"names":[],"mappings":"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"}
@@ -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.textContent.trim(),
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.map((child) => child?.text ?? '').join('').trim();
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="item"
355
+ part=${`item${isActive ? ' item-active' : ''}`}
301
356
  data-outline-id=${figure.id ?? ''}
302
- role="listitem"
303
- tabindex="0"
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 = this.headingKey(heading, index);
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
+ }