@sciflow/editor-start 0.0.1-beta → 0.0.2

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.
Files changed (57) hide show
  1. package/README.md +83 -2
  2. package/THIRD_PARTY_LICENSES +9 -0
  3. package/bin/extract-translations.mjs +54 -0
  4. package/dist/bundle/sciflow-editor.css +1 -1
  5. package/dist/bundle/sciflow-editor.js +10590 -15324
  6. package/dist/index.d.ts +7 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -3
  9. package/dist/lib/citation-source-adapter.d.ts +22 -2
  10. package/dist/lib/citation-source-adapter.d.ts.map +1 -1
  11. package/dist/lib/citation-source-adapter.js +104 -48
  12. package/dist/lib/drag-preview.d.ts +13 -0
  13. package/dist/lib/drag-preview.d.ts.map +1 -0
  14. package/dist/lib/drag-preview.js +43 -0
  15. package/dist/lib/editor-element.d.ts +30 -4
  16. package/dist/lib/editor-element.d.ts.map +1 -1
  17. package/dist/lib/editor-element.js +131 -30
  18. package/dist/lib/format-bar.d.ts +26 -2
  19. package/dist/lib/format-bar.d.ts.map +1 -1
  20. package/dist/lib/format-bar.js +369 -56
  21. package/dist/lib/ghost-cursor.d.ts +10 -0
  22. package/dist/lib/ghost-cursor.d.ts.map +1 -0
  23. package/dist/lib/ghost-cursor.js +59 -0
  24. package/dist/lib/icons-generated.d.ts +14 -0
  25. package/dist/lib/icons-generated.d.ts.map +1 -0
  26. package/dist/lib/icons-generated.js +60 -0
  27. package/dist/lib/outline.d.ts +18 -10
  28. package/dist/lib/outline.d.ts.map +1 -1
  29. package/dist/lib/outline.js +184 -28
  30. package/dist/lib/reference-list.d.ts +10 -2
  31. package/dist/lib/reference-list.d.ts.map +1 -1
  32. package/dist/lib/reference-list.js +47 -9
  33. package/dist/lib/selection-editor.d.ts +83 -5
  34. package/dist/lib/selection-editor.d.ts.map +1 -1
  35. package/dist/lib/selection-editor.js +1225 -147
  36. package/dist/lib/selection-transform-registry.d.ts +16 -0
  37. package/dist/lib/selection-transform-registry.d.ts.map +1 -0
  38. package/dist/lib/selection-transform-registry.js +24 -0
  39. package/dist/lib/selection-ui-renderer.d.ts +22 -0
  40. package/dist/lib/selection-ui-renderer.d.ts.map +1 -0
  41. package/dist/lib/selection-ui-renderer.js +244 -0
  42. package/dist/lib/selection-widget-host.d.ts +67 -0
  43. package/dist/lib/selection-widget-host.d.ts.map +1 -0
  44. package/dist/lib/selection-widget-host.js +151 -0
  45. package/dist/lib/selection-widget-registry.d.ts +16 -0
  46. package/dist/lib/selection-widget-registry.d.ts.map +1 -0
  47. package/dist/lib/selection-widget-registry.js +30 -0
  48. package/dist/lib/test-fixtures/soak-documents.d.ts +13 -0
  49. package/dist/lib/test-fixtures/soak-documents.d.ts.map +1 -0
  50. package/dist/lib/test-fixtures/soak-documents.js +69 -0
  51. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  52. package/package.json +22 -13
  53. package/dist/demo/demo.js +0 -56
  54. package/dist/demo/index.html +0 -169
  55. package/dist/lib/citation-drop.d.ts +0 -25
  56. package/dist/lib/citation-drop.d.ts.map +0 -1
  57. package/dist/lib/citation-drop.js +0 -53
@@ -17,17 +17,31 @@ import { __decorate } from "tslib";
17
17
  *
18
18
  * The component code below is annotated to map those steps to specific hooks.
19
19
  */
20
- import { css, html, LitElement, unsafeCSS } from 'lit';
20
+ import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
21
21
  import { customElement, property, state } from 'lit/decorators.js';
22
22
  import { classMap } from 'lit/directives/class-map.js';
23
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
24
+ import { t, LOCALE_CHANGE_EVENT } from '@sciflow/editor-core';
25
+ import { materialIcons } from './icons-generated.js';
23
26
  import formatBarCss from './format-bar.css?inline';
24
27
  import { applyThemeStylesToRoot, subscribeToSciFlowTheme } from './theme.js';
28
+ /* Inline SVG icons for each style option (16×16). */
29
+ const styleIcons = {
30
+ paragraph: html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><rect x="2" y="3" width="12" height="1.5" rx=".75"/><rect x="2" y="7" width="10" height="1.5" rx=".75"/><rect x="2" y="11" width="8" height="1.5" rx=".75"/></svg>`,
31
+ 'heading-1': html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><text x="1" y="13" font-size="13" font-weight="700" font-family="system-ui,sans-serif">H<tspan font-size="10">1</tspan></text></svg>`,
32
+ 'heading-2': html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><text x="1" y="13" font-size="13" font-weight="700" font-family="system-ui,sans-serif">H<tspan font-size="10">2</tspan></text></svg>`,
33
+ 'heading-3': html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><text x="1" y="13" font-size="13" font-weight="700" font-family="system-ui,sans-serif">H<tspan font-size="10">3</tspan></text></svg>`,
34
+ blockquote: html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M3.5 4C2.67 4 2 4.67 2 5.5v2C2 8.33 2.67 9 3.5 9H5l-1 3h1.6l1-3h.4V5.5C7 4.67 6.33 4 5.5 4h-2zm6 0C8.67 4 8 4.67 8 5.5v2C8 8.33 8.67 9 9.5 9H11l-1 3h1.6l1-3h.4V5.5c0-.83-.67-1.5-1.5-1.5h-2z"/></svg>`,
35
+ };
25
36
  const STYLE_OPTIONS = [
26
- { label: 'Paragraph', value: 'paragraph' },
27
- { label: 'Heading 1', value: 'heading-1', level: 1 },
28
- { label: 'Heading 2', value: 'heading-2', level: 2 },
29
- { label: 'Heading 3', value: 'heading-3', level: 3 },
37
+ { get label() { return t('formatBar.styleParagraph'); }, value: 'paragraph', cssClass: 'style-option--paragraph' },
38
+ { get label() { return t('formatBar.styleHeading1'); }, value: 'heading-1', level: 1, cssClass: 'style-option--h1' },
39
+ { get label() { return t('formatBar.styleHeading2'); }, value: 'heading-2', level: 2, cssClass: 'style-option--h2' },
40
+ { get label() { return t('formatBar.styleHeading3'); }, value: 'heading-3', level: 3, cssClass: 'style-option--h3' },
41
+ { get label() { return t('cmd.toggleBlockquote.label'); }, value: 'blockquote', cssClass: 'style-option--blockquote', command: 'toggleBlockquote' },
30
42
  ];
43
+ /** SVG chevron shared by dropdown triggers. */
44
+ const chevronSvg = html `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>`;
31
45
  /**
32
46
  * Custom element that renders the demo formatting toolbar.
33
47
  *
@@ -46,6 +60,8 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
46
60
  /** Reference to the editor element. May be passed directly via property binding. */
47
61
  this.editor = null;
48
62
  this.selectionVersion = 0;
63
+ this.styleMenuOpen = false;
64
+ this.insertMenuOpen = false;
49
65
  this.resolvedEditor = null;
50
66
  this.themeStyleElements = [];
51
67
  }
@@ -53,14 +69,27 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
53
69
  /** Subscribe to editor events once the toolbar attaches to the DOM. */
54
70
  connectedCallback() {
55
71
  super.connectedCallback();
72
+ if (!this.hasAttribute('role')) {
73
+ this.setAttribute('role', 'toolbar');
74
+ }
75
+ if (!this.hasAttribute('aria-label')) {
76
+ this.setAttribute('aria-label', t('formatBar.toolbarAriaLabel'));
77
+ }
56
78
  this.themeUnsub = subscribeToSciFlowTheme((cssTexts) => {
57
79
  this.themeStyleElements = applyThemeStylesToRoot(this.renderRoot, this.themeStyleElements, cssTexts);
58
80
  });
81
+ this.localeListener = () => this.requestUpdate();
82
+ document.addEventListener(LOCALE_CHANGE_EVENT, this.localeListener);
59
83
  this.resolveEditorReference();
60
84
  }
61
85
  /** Remove listeners when the toolbar is detached. */
62
86
  disconnectedCallback() {
63
87
  this.detachEditorListeners();
88
+ this.removeOutsideClickHandler();
89
+ if (this.localeListener) {
90
+ document.removeEventListener(LOCALE_CHANGE_EVENT, this.localeListener);
91
+ this.localeListener = undefined;
92
+ }
64
93
  this.themeUnsub?.();
65
94
  this.themeUnsub = undefined;
66
95
  super.disconnectedCallback();
@@ -80,47 +109,290 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
80
109
  */
81
110
  render() {
82
111
  const runner = this.getCommandRunner();
83
- const availableCommands = runner?.available?.() ?? null;
84
- const headingCommandsAvailable = typeof availableCommands?.setHeading === 'function' || typeof availableCommands?.setParagraph === 'function';
85
- const activeStyle = this.getActiveBlockStyle();
86
- const selectDisabled = !runner || !headingCommandsAvailable;
87
112
  const commandMetadata = runner?.commandsMeta?.() ?? {};
88
113
  const commandGroups = this.getCommandGroups(commandMetadata);
89
114
  const tableGroup = 'table';
90
- const primaryGroups = commandGroups.filter((group) => group !== tableGroup);
115
+ const mediaGroup = 'media';
116
+ const insertGroup = 'insert';
117
+ const blockGroup = 'block';
118
+ const primaryGroups = commandGroups.filter((group) => group !== tableGroup && group !== mediaGroup && group !== insertGroup && group !== blockGroup);
91
119
  const tableGroups = commandGroups.filter((group) => group === tableGroup);
120
+ const selectionInTable = this.isSelectionInTable();
92
121
  return html `
93
- <select
94
- class="style-select"
95
- aria-label="Text style"
96
- .value=${activeStyle}
97
- ?disabled=${selectDisabled}
98
- @change=${this.handleStyleChange}
99
- >
100
- ${STYLE_OPTIONS.map((option) => html `
101
- <option
102
- value=${option.value}
103
- ?selected=${option.value === activeStyle}
104
- ?disabled=${!this.canUseStyleOption(option, availableCommands)}
105
- >
106
- ${option.label}
107
- </option>
108
- `)}
109
- </select>
110
- <div class="command-rows">
111
- ${primaryGroups.length
112
- ? html `<div class="command-row">
113
- ${primaryGroups.map((group) => this.renderCommandGroup(runner, commandMetadata, group))}
114
- </div>`
115
- : null}
116
- ${tableGroups.length
117
- ? html `<div class="command-row table-row">
118
- ${tableGroups.map((group) => this.renderCommandGroup(runner, commandMetadata, group))}
119
- </div>`
120
- : null}
122
+ <div class="command-group">
123
+ ${this.renderStyleDropdown(runner)}
124
+ ${this.renderInsertDropdown(runner, commandMetadata)}
125
+ </div>
126
+ ${primaryGroups.map((group) => html `
127
+ <div class="command-group">
128
+ ${this.renderCommandGroup(runner, commandMetadata, group)}
129
+ </div>
130
+ `)}
131
+ <div class="command-group">
132
+ ${this.renderFixedCommandButton(runner, 'undo', 'undo', t('formatBar.undo'), t('formatBar.undoDescription'))}
133
+ ${this.renderFixedCommandButton(runner, 'redo', 'redo', t('formatBar.redo'), t('formatBar.redoDescription'))}
134
+ </div>
135
+ ${tableGroups.length && selectionInTable
136
+ ? html `<div class="table-row" role="toolbar" aria-label=${t('formatBar.tableAriaLabel')}>
137
+ <span class="table-row-label">${t('formatBar.tableLabel')}</span>
138
+ ${this.renderTableSubGroups(runner, commandMetadata)}
139
+ </div>`
140
+ : nothing}
141
+ `;
142
+ }
143
+ /* --------------------------------------------------------- */
144
+ /* Style dropdown (Paragraph / Heading 1–3) */
145
+ /* --------------------------------------------------------- */
146
+ renderStyleDropdown(runner) {
147
+ const availableCommands = runner?.available?.() ?? null;
148
+ const headingCommandsAvailable = typeof availableCommands?.setHeading === 'function' || typeof availableCommands?.setParagraph === 'function';
149
+ const activeStyle = this.getActiveBlockStyle();
150
+ const selectDisabled = !runner || !headingCommandsAvailable;
151
+ const activeLabel = STYLE_OPTIONS.find((o) => o.value === activeStyle)?.label ?? t('formatBar.styleParagraph');
152
+ return html `
153
+ <div class="style-dropdown">
154
+ <button
155
+ type="button"
156
+ class="style-dropdown-trigger"
157
+ aria-label=${t('formatBar.textStyleAriaLabel')}
158
+ aria-haspopup="listbox"
159
+ aria-expanded=${this.styleMenuOpen ? 'true' : 'false'}
160
+ ?disabled=${selectDisabled}
161
+ @click=${this.toggleStyleMenu}
162
+ >
163
+ ${activeLabel}
164
+ <span class="dropdown-chevron">${chevronSvg}</span>
165
+ </button>
166
+ ${this.styleMenuOpen
167
+ ? html `
168
+ <div class="style-dropdown-menu" role="listbox" aria-label=${t('formatBar.textStyleAriaLabel')}>
169
+ ${STYLE_OPTIONS.map((option) => {
170
+ const isActive = option.command
171
+ ? this.isBlockquoteActive()
172
+ : option.value === activeStyle;
173
+ const canUse = option.command
174
+ ? this.canUseCommand(option.command, runner)
175
+ : this.canUseStyleOption(option, availableCommands);
176
+ return html `
177
+ <button
178
+ type="button"
179
+ role="option"
180
+ class=${classMap({
181
+ 'style-option': true,
182
+ [option.cssClass]: true,
183
+ active: isActive,
184
+ })}
185
+ aria-selected=${isActive ? 'true' : 'false'}
186
+ ?disabled=${!canUse}
187
+ @click=${() => this.selectStyle(option, runner)}
188
+ >
189
+ <span class="style-option-icon">${styleIcons[option.value]}</span>
190
+ ${option.label}
191
+ </button>
192
+ `;
193
+ })}
194
+ </div>
195
+ `
196
+ : nothing}
197
+ </div>
198
+ `;
199
+ }
200
+ toggleStyleMenu(e) {
201
+ e.stopPropagation();
202
+ this.insertMenuOpen = false;
203
+ this.styleMenuOpen = !this.styleMenuOpen;
204
+ if (this.styleMenuOpen) {
205
+ this.installOutsideClickHandler();
206
+ }
207
+ else {
208
+ this.removeOutsideClickHandler();
209
+ }
210
+ }
211
+ selectStyle(option, runner) {
212
+ this.styleMenuOpen = false;
213
+ this.removeOutsideClickHandler();
214
+ if (option.command) {
215
+ const command = runner?.commands?.[option.command];
216
+ if (typeof command === 'function') {
217
+ const result = command();
218
+ if (result) {
219
+ this.selectionVersion++;
220
+ this.requestUpdate();
221
+ }
222
+ }
223
+ }
224
+ else if (option.value === 'paragraph') {
225
+ this.handleSetParagraph();
226
+ }
227
+ else if (option.level) {
228
+ this.handleSetHeading(option.level);
229
+ }
230
+ }
231
+ isBlockquoteActive() {
232
+ const state = this.resolvedEditor?.editorView?.state;
233
+ if (!state)
234
+ return false;
235
+ const bqType = state.schema.nodes.blockquote;
236
+ if (!bqType)
237
+ return false;
238
+ const { from, to } = state.selection;
239
+ let found = false;
240
+ state.doc.nodesBetween(from, to, (node) => {
241
+ if (node.type === bqType) {
242
+ found = true;
243
+ return false;
244
+ }
245
+ return true;
246
+ });
247
+ return found;
248
+ }
249
+ canUseCommand(commandName, runner) {
250
+ const availability = runner?.available?.();
251
+ if (availability?.[commandName])
252
+ return availability[commandName]();
253
+ return typeof runner?.commands?.[commandName] === 'function';
254
+ }
255
+ /* --------------------------------------------------------- */
256
+ /* Insert dropdown (icon + chevron → popover) */
257
+ /* --------------------------------------------------------- */
258
+ renderInsertDropdown(runner, metadata) {
259
+ const availability = runner?.available?.() ?? null;
260
+ // Collect block-level insert commands (media group + table insert + footnote)
261
+ const insertCommands = Object.entries(metadata)
262
+ .filter(([name, meta]) => meta && (meta.group === 'media' || name === 'insertNativeTableFigure' || name === 'insertFootnote'))
263
+ .sort(([, a], [, b]) => (a?.order ?? 0) - (b?.order ?? 0));
264
+ if (insertCommands.length === 0) {
265
+ return null;
266
+ }
267
+ return html `
268
+ <div class="insert-dropdown">
269
+ <button
270
+ type="button"
271
+ class="insert-dropdown-trigger"
272
+ aria-label=${t('formatBar.insert')}
273
+ aria-haspopup="menu"
274
+ aria-expanded=${this.insertMenuOpen ? 'true' : 'false'}
275
+ @click=${this.toggleInsertMenu}
276
+ >
277
+ <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>
278
+ <span class="dropdown-chevron">${chevronSvg}</span>
279
+ </button>
280
+ ${this.insertMenuOpen
281
+ ? html `
282
+ <div class="insert-dropdown-menu" role="menu">
283
+ ${insertCommands.map(([name, meta]) => {
284
+ const canExecute = availability?.[name] ? availability[name]() : true;
285
+ return html `
286
+ <button
287
+ type="button"
288
+ role="menuitem"
289
+ class="insert-menu-item"
290
+ ?disabled=${!canExecute}
291
+ @click=${() => this.executeInsertCommand(name, runner)}
292
+ >
293
+ ${meta?.icon && materialIcons[meta.icon]
294
+ ? html `<span class="cmd-icon" aria-hidden="true">${unsafeHTML(materialIcons[meta.icon])}</span>`
295
+ : nothing}
296
+ ${meta?.label ?? name}
297
+ </button>
298
+ `;
299
+ })}
300
+ </div>
301
+ `
302
+ : nothing}
121
303
  </div>
122
304
  `;
123
305
  }
306
+ toggleInsertMenu(e) {
307
+ e.stopPropagation();
308
+ this.styleMenuOpen = false;
309
+ this.insertMenuOpen = !this.insertMenuOpen;
310
+ if (this.insertMenuOpen) {
311
+ this.installOutsideClickHandler();
312
+ }
313
+ else {
314
+ this.removeOutsideClickHandler();
315
+ }
316
+ }
317
+ executeInsertCommand(commandName, runner) {
318
+ this.insertMenuOpen = false;
319
+ this.removeOutsideClickHandler();
320
+ if (!runner)
321
+ return;
322
+ const command = runner.commands?.[commandName];
323
+ if (typeof command === 'function') {
324
+ const result = command();
325
+ if (result) {
326
+ this.selectionVersion++;
327
+ this.requestUpdate();
328
+ }
329
+ }
330
+ }
331
+ /* --------------------------------------------------------- */
332
+ /* Shared dropdown close-on-outside-click */
333
+ /* --------------------------------------------------------- */
334
+ installOutsideClickHandler() {
335
+ this.removeOutsideClickHandler();
336
+ this.outsideClickHandler = (e) => {
337
+ const path = e.composedPath();
338
+ if (!path.includes(this)) {
339
+ this.styleMenuOpen = false;
340
+ this.insertMenuOpen = false;
341
+ this.removeOutsideClickHandler();
342
+ }
343
+ };
344
+ // Use capture + next frame so the current click doesn't immediately close.
345
+ requestAnimationFrame(() => {
346
+ if (this.outsideClickHandler) {
347
+ document.addEventListener('click', this.outsideClickHandler, true);
348
+ }
349
+ });
350
+ }
351
+ removeOutsideClickHandler() {
352
+ if (this.outsideClickHandler) {
353
+ document.removeEventListener('click', this.outsideClickHandler, true);
354
+ this.outsideClickHandler = undefined;
355
+ }
356
+ }
357
+ /* --------------------------------------------------------- */
358
+ /* Fixed command buttons (undo / redo) */
359
+ /* --------------------------------------------------------- */
360
+ /**
361
+ * Render fixed toolbar commands that are not driven by metadata groups.
362
+ */
363
+ renderFixedCommandButton(runner, commandName, icon, label, description) {
364
+ const command = runner?.commands?.[commandName];
365
+ if (typeof command !== 'function') {
366
+ return null;
367
+ }
368
+ const availability = runner?.available?.();
369
+ const canExecute = availability?.[commandName] ? availability[commandName]() : true;
370
+ const handleClick = () => {
371
+ if (!canExecute) {
372
+ return;
373
+ }
374
+ const result = command();
375
+ if (result) {
376
+ this.selectionVersion++;
377
+ this.requestUpdate();
378
+ }
379
+ };
380
+ return html `
381
+ <button
382
+ type="button"
383
+ aria-label=${label}
384
+ title=${description}
385
+ ?disabled=${!canExecute}
386
+ data-tooltip=${description}
387
+ @click=${handleClick}
388
+ >
389
+ <span class="cmd-icon" aria-hidden="true">${unsafeHTML(materialIcons[icon] ?? '')}</span>
390
+ </button>
391
+ `;
392
+ }
393
+ /* --------------------------------------------------------- */
394
+ /* Editor resolution & event wiring */
395
+ /* --------------------------------------------------------- */
124
396
  /**
125
397
  * Find the target editor element and attach listeners for selection changes.
126
398
  * This mirrors the wiring a framework component would perform in `useEffect`.
@@ -186,20 +458,9 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
186
458
  getCommandRunner() {
187
459
  return this.resolvedEditor?.commands ?? null;
188
460
  }
189
- /** Interpret the block-style dropdown and trigger the relevant commands. */
190
- handleStyleChange(event) {
191
- const select = event.target;
192
- const value = select.value;
193
- if (value === 'paragraph') {
194
- this.handleSetParagraph();
195
- return;
196
- }
197
- const parsed = Number.parseInt(value.split('-')[1] ?? '', 10);
198
- if (!Number.isInteger(parsed)) {
199
- return;
200
- }
201
- this.handleSetHeading(parsed);
202
- }
461
+ /* --------------------------------------------------------- */
462
+ /* Heading / paragraph commands */
463
+ /* --------------------------------------------------------- */
203
464
  /** Apply a specific heading level after ensuring the editor can handle it. */
204
465
  handleSetHeading(level) {
205
466
  const runner = this.getCommandRunner();
@@ -306,13 +567,59 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
306
567
  }
307
568
  return commands.setHeading?.(level) ?? false;
308
569
  }
570
+ /** True if the current selection is inside a table (cell or header). */
571
+ isSelectionInTable() {
572
+ const state = this.resolvedEditor?.editorView?.state;
573
+ if (!state) {
574
+ return false;
575
+ }
576
+ const { table, table_cell, table_header } = state.schema.nodes;
577
+ if (!table || !table_cell || !table_header) {
578
+ return false;
579
+ }
580
+ const { $from } = state.selection;
581
+ for (let d = $from.depth; d > 0; d--) {
582
+ const node = $from.node(d);
583
+ if (node.type === table || node.type === table_cell || node.type === table_header) {
584
+ return true;
585
+ }
586
+ }
587
+ return false;
588
+ }
589
+ /**
590
+ * Render table commands split into logical sub-groups (columns, rows,
591
+ * merge/split, header toggles, navigation/delete) separated visually.
592
+ */
593
+ renderTableSubGroups(runner, metadata) {
594
+ const subGroups = [
595
+ ['insertNativeTableFigure'],
596
+ ['addColumnBefore', 'addColumnAfter', 'deleteColumn'],
597
+ ['addRowBefore', 'addRowAfter', 'deleteRow'],
598
+ ['mergeCells', 'splitCell'],
599
+ ['toggleHeaderRow', 'toggleHeaderColumn'],
600
+ ['goToNextCell', 'deleteTable'],
601
+ ];
602
+ return subGroups.map((names) => {
603
+ const buttons = this.renderCommandGroup(runner, metadata, 'table', names);
604
+ return html `<div class="command-group">${buttons}</div>`;
605
+ });
606
+ }
309
607
  /**
310
608
  * Render a set of command buttons that share the same metadata group.
311
609
  * Groups are sorted by each command's declared order so feature authors control layout.
610
+ * When onlyCommandNames is set, only those commands in the group are rendered.
312
611
  */
313
- renderCommandGroup(runner, metadata, group) {
612
+ renderCommandGroup(runner, metadata, group, onlyCommandNames) {
314
613
  return Object.entries(metadata)
315
- .filter(([, meta]) => meta?.group === group && (meta.icon || meta.label))
614
+ .filter(([name, meta]) => {
615
+ if (meta?.group !== group || !(meta.icon || meta.label)) {
616
+ return false;
617
+ }
618
+ if (onlyCommandNames !== undefined && !onlyCommandNames.includes(name)) {
619
+ return false;
620
+ }
621
+ return true;
622
+ })
316
623
  .sort(([, a], [, b]) => (a?.order ?? 0) - (b?.order ?? 0))
317
624
  .map(([name, meta]) => this.renderCommandButton(name, meta, runner));
318
625
  }
@@ -345,13 +652,13 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
345
652
  class=${classMap({ active: isActive })}
346
653
  aria-label=${meta.label ?? name}
347
654
  title=${meta.description ?? meta.label ?? name}
348
- ?aria-pressed=${meta.isActive ? (isActive ? 'true' : 'false') : undefined}
655
+ aria-pressed=${meta.isActive ? (isActive ? 'true' : 'false') : undefined}
349
656
  ?disabled=${!canExecute}
350
657
  data-tooltip=${meta.description ?? meta.label ?? name}
351
658
  @click=${handleClick}
352
659
  >
353
660
  ${meta.icon
354
- ? html `<span class="material-symbols-rounded" aria-hidden="true">${meta.icon}</span>`
661
+ ? html `<span class="cmd-icon" aria-hidden="true">${unsafeHTML(materialIcons[meta.icon] ?? '')}</span>`
355
662
  : meta.label ?? name}
356
663
  </button>
357
664
  `;
@@ -395,6 +702,12 @@ __decorate([
395
702
  __decorate([
396
703
  state()
397
704
  ], SciFlowFormatBarElement.prototype, "selectionVersion", void 0);
705
+ __decorate([
706
+ state()
707
+ ], SciFlowFormatBarElement.prototype, "styleMenuOpen", void 0);
708
+ __decorate([
709
+ state()
710
+ ], SciFlowFormatBarElement.prototype, "insertMenuOpen", void 0);
398
711
  SciFlowFormatBarElement = __decorate([
399
712
  customElement('sciflow-formatbar')
400
713
  ], SciFlowFormatBarElement);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @module ghost-cursor
3
+ *
4
+ * ProseMirror feature that keeps the cursor position visible when the editor
5
+ * loses focus. A thin blue bar is rendered at the last cursor position so
6
+ * the user always knows where the next insertion (e.g. Cite, Insert ref) will land.
7
+ */
8
+ import type { Feature } from '@sciflow/editor-core';
9
+ export declare const ghostCursorFeature: Feature;
10
+ //# sourceMappingURL=ghost-cursor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ghost-cursor.d.ts","sourceRoot":"","sources":["../../src/lib/ghost-cursor.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAsDpD,eAAO,MAAM,kBAAkB,EAAE,OAKhC,CAAC"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @module ghost-cursor
3
+ *
4
+ * ProseMirror feature that keeps the cursor position visible when the editor
5
+ * loses focus. A thin blue bar is rendered at the last cursor position so
6
+ * the user always knows where the next insertion (e.g. Cite, Insert ref) will land.
7
+ */
8
+ import { Plugin, PluginKey } from 'prosemirror-state';
9
+ import { Decoration, DecorationSet } from 'prosemirror-view';
10
+ const ghostCursorKey = new PluginKey('ghostCursor');
11
+ const ghostCursorPlugin = new Plugin({
12
+ key: ghostCursorKey,
13
+ state: {
14
+ init: () => true,
15
+ apply(tr, focused) {
16
+ const next = tr.getMeta(ghostCursorKey);
17
+ return typeof next === 'boolean' ? next : focused;
18
+ },
19
+ },
20
+ props: {
21
+ handleDOMEvents: {
22
+ blur(view) {
23
+ view.dispatch(view.state.tr.setMeta(ghostCursorKey, false));
24
+ return false;
25
+ },
26
+ focus(view) {
27
+ view.dispatch(view.state.tr.setMeta(ghostCursorKey, true));
28
+ return false;
29
+ },
30
+ },
31
+ decorations(state) {
32
+ const focused = ghostCursorKey.getState(state);
33
+ if (focused !== false)
34
+ return DecorationSet.empty;
35
+ const { selection } = state;
36
+ // For a range selection, highlight the selected text instead of showing a caret
37
+ if (!selection.empty) {
38
+ return DecorationSet.create(state.doc, [
39
+ Decoration.inline(selection.from, selection.to, {
40
+ class: 'prosemirror-ghost-selection',
41
+ }),
42
+ ]);
43
+ }
44
+ // For a collapsed cursor, insert a widget caret marker
45
+ const el = document.createElement('span');
46
+ el.className = 'prosemirror-ghost-cursor';
47
+ el.setAttribute('aria-hidden', 'true');
48
+ return DecorationSet.create(state.doc, [
49
+ Decoration.widget(selection.head, el, { side: 0, key: 'ghost-cursor' }),
50
+ ]);
51
+ },
52
+ },
53
+ });
54
+ export const ghostCursorFeature = {
55
+ name: 'ghost-cursor',
56
+ addPlugins() {
57
+ return [ghostCursorPlugin];
58
+ },
59
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Auto-generated – do not edit.
3
+ * Run `node scripts/generate-icons.mjs` to regenerate.
4
+ *
5
+ * Icons: Material Symbols Rounded, weight 400, unfilled.
6
+ * Source: @material-symbols/svg-400 (Apache License 2.0, Copyright Google LLC)
7
+ * https://github.com/google/material-design-icons
8
+ */
9
+ /**
10
+ * Map of icon names (as used in CommandMetadata.icon) → inline SVG markup.
11
+ * Only the icons actually used by the editor are included (~29 icons).
12
+ */
13
+ export declare const materialIcons: Record<string, string>;
14
+ //# sourceMappingURL=icons-generated.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"icons-generated.d.ts","sourceRoot":"","sources":["../../src/lib/icons-generated.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAWH;;;GAGG;AACH,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAwChD,CAAC"}