@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
@@ -16,8 +16,18 @@ import { __decorate } from "tslib";
16
16
  import { css, html, LitElement, unsafeCSS } from 'lit';
17
17
  import { customElement, property, state } from 'lit/decorators.js';
18
18
  import { ref } from 'lit/directives/ref.js';
19
- import { SourceField } from '@sciflow/editor-core';
19
+ import { SourceField, t, LOCALE_CHANGE_EVENT } from '@sciflow/editor-core';
20
+ import { getMarkRange } from '@sciflow/editor-core/commands';
21
+ import { renderTexToSvg } from '@sciflow/editor-core/features/math';
22
+ import { Fragment as PMFragment } from 'prosemirror-model';
23
+ import { EditorState, NodeSelection } from 'prosemirror-state';
24
+ import { EditorView } from 'prosemirror-view';
25
+ import { baseKeymap, toggleMark } from 'prosemirror-commands';
26
+ import { keymap } from 'prosemirror-keymap';
20
27
  import { getCitationSourceAdapter, } from './citation-source-adapter.js';
28
+ import { collectDocumentOutline } from './outline.js';
29
+ import { renderNodeFromUiSchema } from './selection-ui-renderer.js';
30
+ import './selection-widget-host.js';
21
31
  import selectionEditorCss from './selection-editor.css?inline';
22
32
  import { applyThemeStylesToRoot, subscribeToSciFlowTheme } from './theme.js';
23
33
  /**
@@ -37,13 +47,37 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
37
47
  /** Custom adapter for editing citation source. When unset, uses the global adapter. */
38
48
  this.citationSourceAdapter = null;
39
49
  this.elementInfo = null;
50
+ /** Draft values for math sidebar (tex, style, label). Synced from node attrs when math is selected. */
51
+ this.mathDraft = null;
52
+ /** Last preview result for math (error/warnings). SVG is rendered into mathPreviewContainer. */
53
+ this.mathPreviewResult = null;
54
+ /** Draft values for citation sidebar (content + source). Applied only on Update. */
55
+ this.citationDraft = null;
56
+ /** Draft values for cross-reference sidebar (display text + target href). */
57
+ this.crossReferenceDraft = null;
58
+ /** Draft values for hyperlink (anchor mark) sidebar. */
59
+ this.hyperlinkDraft = null;
60
+ this.draftAttrs = null;
61
+ this.draftDirty = false;
62
+ this.pinSelection = false;
63
+ this.showAllAttrs = false;
40
64
  this.resolvedEditor = null;
41
65
  this.themeStyleElements = [];
42
66
  this.citationSourceContainer = null;
67
+ this.mathPreviewContainer = null;
68
+ this.mathPreviewDebounce = null;
69
+ this.citationContentEditorContainer = null;
70
+ this.citationContentView = null;
71
+ this.citationContentEditorPosition = null;
72
+ this.selectionDebounce = null;
73
+ this.outlineTargetOptionsCacheDoc = null;
74
+ this.outlineTargetOptionsCache = [];
43
75
  }
44
76
  static { this.styles = css `${unsafeCSS(selectionEditorCss)}`; }
45
77
  connectedCallback() {
46
78
  super.connectedCallback();
79
+ this.localeListener = () => this.requestUpdate();
80
+ document.addEventListener(LOCALE_CHANGE_EVENT, this.localeListener);
47
81
  this.themeUnsub = subscribeToSciFlowTheme((cssTexts) => {
48
82
  this.themeStyleElements = applyThemeStylesToRoot(this.renderRoot, this.themeStyleElements, cssTexts);
49
83
  });
@@ -51,7 +85,16 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
51
85
  }
52
86
  disconnectedCallback() {
53
87
  super.disconnectedCallback();
88
+ this.destroyCitationContentView();
54
89
  this.detachEditorListeners();
90
+ if (this.selectionDebounce) {
91
+ clearTimeout(this.selectionDebounce);
92
+ this.selectionDebounce = null;
93
+ }
94
+ if (this.localeListener) {
95
+ document.removeEventListener(LOCALE_CHANGE_EVENT, this.localeListener);
96
+ this.localeListener = undefined;
97
+ }
55
98
  this.themeUnsub?.();
56
99
  this.themeUnsub = undefined;
57
100
  }
@@ -60,92 +103,216 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
60
103
  if (changedProperties.has('editor') || changedProperties.has('for')) {
61
104
  this.resolveEditorReference();
62
105
  }
63
- if (changedProperties.has('elementInfo') &&
64
- this.citationSourceContainer &&
65
- this.elementInfo?.type === 'citation' &&
66
- this.elementInfo.attrs?.source !== undefined) {
67
- const rawSource = this.elementInfo.attrs.source;
68
- const items = typeof rawSource === 'string'
69
- ? SourceField.fromString(rawSource)
70
- : Array.isArray(rawSource)
71
- ? rawSource
72
- : [];
73
- const adapter = this.citationSourceAdapter ?? getCitationSourceAdapter();
74
- const context = {
75
- applySource: (encoded) => {
76
- this.updateNodeAttribute('source', encoded);
77
- return true;
78
- },
79
- };
80
- if (adapter.update) {
81
- adapter.update(this.citationSourceContainer, items, context);
106
+ if (changedProperties.has('elementInfo')) {
107
+ this.showAllAttrs = false;
108
+ if (this.elementInfo?.type === 'math') {
109
+ const attrs = this.elementInfo.attrs;
110
+ this.mathDraft = {
111
+ tex: attrs?.tex ?? '',
112
+ style: attrs?.style === 'display' ? 'display' : 'inline',
113
+ label: attrs?.label ?? '',
114
+ };
115
+ this.scheduleMathPreview();
116
+ this.citationDraft = null;
117
+ this.crossReferenceDraft = null;
118
+ this.resetDraftAttrs();
119
+ }
120
+ else if (this.elementInfo?.type === 'citation') {
121
+ const attrs = this.elementInfo.attrs;
122
+ const rawSource = attrs?.source;
123
+ const sourceEncoded = typeof rawSource === 'string' ? rawSource : SourceField.toString(rawSource ?? []) ?? null;
124
+ const content = this.getCitationNodeTextContent();
125
+ this.citationDraft = {
126
+ content: content ?? '',
127
+ sourceEncoded,
128
+ };
129
+ this.crossReferenceDraft = null;
130
+ this.mathDraft = null;
131
+ this.mathPreviewResult = null;
132
+ this.resetDraftAttrs();
133
+ if (this.mathPreviewDebounce) {
134
+ clearTimeout(this.mathPreviewDebounce);
135
+ this.mathPreviewDebounce = null;
136
+ }
137
+ }
138
+ else if (this.elementInfo?.type === 'link') {
139
+ const attrs = this.elementInfo.attrs;
140
+ const href = typeof attrs?.href === 'string' ? attrs.href : '';
141
+ this.crossReferenceDraft = {
142
+ content: this.getLinkNodeTextContent() ?? '',
143
+ href,
144
+ };
145
+ this.mathDraft = null;
146
+ this.mathPreviewResult = null;
147
+ this.citationDraft = null;
148
+ this.hyperlinkDraft = null;
149
+ this.resetDraftAttrs();
150
+ this.destroyCitationContentView();
151
+ if (this.mathPreviewDebounce) {
152
+ clearTimeout(this.mathPreviewDebounce);
153
+ this.mathPreviewDebounce = null;
154
+ }
155
+ }
156
+ else if (this.elementInfo?.type === 'anchor') {
157
+ const attrs = this.elementInfo.attrs;
158
+ this.hyperlinkDraft = {
159
+ href: typeof attrs?.href === 'string' ? attrs.href : '',
160
+ };
161
+ this.mathDraft = null;
162
+ this.mathPreviewResult = null;
163
+ this.citationDraft = null;
164
+ this.crossReferenceDraft = null;
165
+ this.resetDraftAttrs();
166
+ this.destroyCitationContentView();
167
+ if (this.mathPreviewDebounce) {
168
+ clearTimeout(this.mathPreviewDebounce);
169
+ this.mathPreviewDebounce = null;
170
+ }
171
+ }
172
+ else {
173
+ this.mathDraft = null;
174
+ this.mathPreviewResult = null;
175
+ this.citationDraft = null;
176
+ this.crossReferenceDraft = null;
177
+ this.hyperlinkDraft = null;
178
+ this.resetDraftAttrs();
179
+ this.destroyCitationContentView();
180
+ if (this.mathPreviewDebounce) {
181
+ clearTimeout(this.mathPreviewDebounce);
182
+ this.mathPreviewDebounce = null;
183
+ }
184
+ }
185
+ }
186
+ if (changedProperties.has('elementInfo') ||
187
+ changedProperties.has('citationDraft')) {
188
+ if (this.citationSourceContainer &&
189
+ this.elementInfo?.type === 'citation' &&
190
+ this.citationDraft) {
191
+ const items = SourceField.fromString(this.citationDraft.sourceEncoded ?? '') ?? [];
192
+ const adapter = this.citationSourceAdapter ?? getCitationSourceAdapter();
193
+ const refs = this.resolvedEditor?.references ?? [];
194
+ const context = {
195
+ onSourceChange: (encoded) => {
196
+ if (this.citationDraft) {
197
+ this.citationDraft = { ...this.citationDraft, sourceEncoded: encoded };
198
+ }
199
+ },
200
+ getReferences: () => refs.map((r) => ({ id: r.id, rawReference: r.rawReference })),
201
+ };
202
+ if (adapter.update) {
203
+ adapter.update(this.citationSourceContainer, items, context);
204
+ }
82
205
  }
83
206
  }
84
207
  }
85
208
  render() {
86
209
  if (!this.elementInfo || !this.elementInfo.type) {
87
210
  return html `
88
- <div class="selection-editor-container">
211
+ <div class="selection-editor-container" @keydown=${this.handleGenericKeydown}>
89
212
  <p class="selection-editor-placeholder">
90
- No element selected. Click or select an element in the editor.
213
+ ${t('selectionEditor.noElement')}
91
214
  </p>
92
215
  </div>
93
216
  `;
94
217
  }
95
- return html `
96
- <div class="selection-editor-container">
97
- <!-- Type display -->
98
- <div class="selection-editor-field">
99
- <span class="selection-editor-label">Type:</span>
100
- <code class="selection-editor-value">${this.elementInfo.type}</code>
218
+ if (this.elementInfo.type === 'math' && this.mathDraft) {
219
+ return this.renderMathSidebar();
220
+ }
221
+ if (this.elementInfo.type === 'citation' && this.citationDraft) {
222
+ return this.renderCitationSidebar();
223
+ }
224
+ if (this.elementInfo.type === 'link' && this.crossReferenceDraft) {
225
+ return this.renderCrossReferenceSidebar();
226
+ }
227
+ if (this.elementInfo.type === 'anchor' && this.hyperlinkDraft) {
228
+ return this.renderHyperlinkSidebar();
229
+ }
230
+ if (this.elementInfo.type === 'figure') {
231
+ return this.renderFigureSidebar();
232
+ }
233
+ const uiSchema = this.resolvedEditor?.getSelectionUiSchema?.();
234
+ const nodeFragment = uiSchema?.properties?.nodes?.[this.elementInfo.type];
235
+ const useSchemaDriven = !!nodeFragment;
236
+ if (useSchemaDriven && nodeFragment) {
237
+ const attrs = this.getEffectiveAttrs();
238
+ return html `
239
+ <div class="selection-editor-container">
240
+ ${this.renderSelectionControls()}
241
+ <p class="selection-editor-type-header">${this.getNodeTypeLabel(this.elementInfo.type)}</p>
242
+ ${renderNodeFromUiSchema(nodeFragment, {
243
+ attrs,
244
+ onAttrChange: (attr, value) => this.setDraftAttr(attr, value),
245
+ showHidden: this.showAllAttrs,
246
+ getOptionsSource: (id) => {
247
+ if (id === 'references') {
248
+ const refs = this.resolvedEditor?.references ?? [];
249
+ return refs.map((r) => ({ id: r.id, label: r.rawReference ?? r.id }));
250
+ }
251
+ if (id === 'outlineTargets') {
252
+ return this.getOutlineTargetOptions();
253
+ }
254
+ return [];
255
+ },
256
+ })}
257
+ ${this.renderDraftActions()}
258
+ <button
259
+ class="selection-editor-expand-btn"
260
+ @click=${() => { this.showAllAttrs = !this.showAllAttrs; }}
261
+ >${this.showAllAttrs ? t('selectionEditor.collapseAttributes') : t('selectionEditor.expandAttributes')}</button>
101
262
  </div>
263
+ `;
264
+ }
265
+ return html `
266
+ <div class="selection-editor-container" @keydown=${this.handleGenericKeydown}>
267
+ ${this.renderSelectionControls()}
102
268
 
103
- <!-- ID display (read-only) -->
104
- ${this.elementInfo.id
105
- ? html `
106
- <div class="selection-editor-field">
107
- <span class="selection-editor-label">ID:</span>
108
- <code class="selection-editor-value">${this.elementInfo.id}</code>
109
- </div>
110
- `
111
- : ''}
269
+ <!-- ELEMENT section -->
270
+ <div class="selection-editor-section">
271
+ <span class="selection-editor-section-label">${t('selectionEditor.sectionElement')}</span>
272
+ <p class="selection-editor-type-header">${this.getNodeTypeLabel(this.elementInfo.type)}</p>
273
+ </div>
112
274
 
113
- <!-- Attributes (always expanded) -->
275
+ <!-- FORMATTING / Attributes -->
114
276
  ${this.elementInfo.attrs && Object.keys(this.elementInfo.attrs).length > 0
115
- ? html `
116
- <div class="selection-editor-field">
117
- <span class="selection-editor-label">Attributes:</span>
118
- <div class="selection-editor-attrs">
119
- ${this.elementInfo.type === 'citation' &&
120
- this.elementInfo.attrs?.source !== undefined
121
- ? html `
122
- <div class="selection-editor-attr-field">
123
- <span class="selection-editor-label">source:</span>
124
- <div
125
- class="citation-source-adapter-root"
126
- ${ref((el) => {
127
- if (el) {
128
- this.citationSourceContainer = el;
129
- this.mountCitationSourceAdapter(el);
130
- }
131
- else if (this.citationSourceContainer) {
132
- this.unmountCitationSourceAdapter(this.citationSourceContainer);
133
- this.citationSourceContainer = null;
134
- }
135
- })}
136
- ></div>
137
- </div>
138
- `
139
- : ''}
140
- ${Object.entries(this.elementInfo.attrs)
141
- .filter(([key]) => key !== 'id' &&
142
- key !== 'data' &&
143
- !(this.elementInfo.type === 'citation' && key === 'source'))
144
- .map(([attrName, attrValue]) => this.renderAttributeField(attrName, attrValue))}
145
- </div>
146
- </div>
147
- `
277
+ ? (() => {
278
+ const attrs = this.getEffectiveAttrs();
279
+ const filtered = Object.entries(attrs).filter(([key]) => key !== 'id' &&
280
+ key !== 'data' &&
281
+ !(this.elementInfo.type === 'citation' && key === 'source'));
282
+ // Split into primary (formatting) attributes and the rest
283
+ const primaryKeys = ['text-align', 'level', 'type'];
284
+ const primary = filtered.filter(([k]) => primaryKeys.includes(k));
285
+ const rest = filtered.filter(([k]) => !primaryKeys.includes(k));
286
+ return html `
287
+ ${primary.length > 0
288
+ ? html `
289
+ <div class="selection-editor-section">
290
+ <span class="selection-editor-section-label">${t('selectionEditor.sectionFormatting')}</span>
291
+ <div class="selection-editor-attrs">
292
+ ${primary.map(([attrName, attrValue]) => this.renderAttributeField(attrName, attrValue))}
293
+ </div>
294
+ </div>
295
+ `
296
+ : ''}
297
+ ${rest.length > 0 || primary.length === 0
298
+ ? html `
299
+ <details class="selection-editor-details">
300
+ <summary class="selection-editor-details-summary">
301
+ <span class="selection-editor-section-label">${t('selectionEditor.sectionAttributes')}</span>
302
+ <svg class="selection-editor-details-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
303
+ </summary>
304
+ <div class="selection-editor-details-body">
305
+ ${rest.length > 0
306
+ ? rest.map(([attrName, attrValue]) => this.renderAttributeField(attrName, attrValue))
307
+ : html `<span class="selection-editor-placeholder">${t('selectionEditor.noElement')}</span>`}
308
+ </div>
309
+ </details>
310
+ `
311
+ : ''}
312
+ `;
313
+ })()
148
314
  : ''}
315
+ ${this.renderDraftActions()}
149
316
  </div>
150
317
  `;
151
318
  }
@@ -165,13 +332,13 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
165
332
  }
166
333
  this.selectionListener = (event) => {
167
334
  const selection = event.detail;
168
- this.updateElementInfo(selection);
335
+ this.scheduleSelectionUpdate(selection);
169
336
  };
170
337
  this.readyListener = () => {
171
338
  // Re-evaluate selection when editor becomes ready
172
339
  if (this.resolvedEditor?.editorView) {
173
340
  const selection = this.resolvedEditor.editorView.state.selection;
174
- this.updateElementInfo({
341
+ this.scheduleSelectionUpdate({
175
342
  anchor: selection.anchor,
176
343
  head: selection.head,
177
344
  });
@@ -182,7 +349,7 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
182
349
  // Initial evaluation
183
350
  if (next.editorView) {
184
351
  const selection = next.editorView.state.selection;
185
- this.updateElementInfo({
352
+ this.scheduleSelectionUpdate({
186
353
  anchor: selection.anchor,
187
354
  head: selection.head,
188
355
  });
@@ -220,6 +387,18 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
220
387
  this.readyListener = undefined;
221
388
  this.resolvedEditor = null;
222
389
  }
390
+ scheduleSelectionUpdate(selection) {
391
+ if (this.pinSelection) {
392
+ return;
393
+ }
394
+ if (this.selectionDebounce) {
395
+ clearTimeout(this.selectionDebounce);
396
+ }
397
+ this.selectionDebounce = setTimeout(() => {
398
+ this.selectionDebounce = null;
399
+ this.updateElementInfo(selection);
400
+ }, 100);
401
+ }
223
402
  /**
224
403
  * Extract element information from the current selection.
225
404
  */
@@ -229,32 +408,92 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
229
408
  this.elementInfo = null;
230
409
  return;
231
410
  }
232
- let elementInfo = null;
233
- let nodePosition = null;
234
- const anchor = selection?.anchor ?? 0;
235
- // Try to get the node at the anchor position
236
- const nodeAtPos = view.state.doc.nodeAt(anchor);
237
- if (nodeAtPos && !nodeAtPos.isText) {
238
- // Node directly at position (e.g., block nodes like heading, paragraph)
239
- nodePosition = anchor;
240
- elementInfo = {
241
- type: nodeAtPos.type.name,
242
- id: nodeAtPos.attrs?.id ?? null,
243
- attrs: nodeAtPos.attrs ?? null,
244
- position: nodePosition,
411
+ const sel = view.state.selection;
412
+ if (sel instanceof NodeSelection && sel.node.type.name === 'math') {
413
+ const attrs = sel.node.attrs;
414
+ this.elementInfo = {
415
+ type: 'math',
416
+ id: attrs?.id ?? null,
417
+ attrs: { ...attrs },
418
+ position: sel.from,
245
419
  };
420
+ return;
246
421
  }
247
- else {
248
- // If no block node at position, resolve the position and find the parent block node
249
- const $pos = view.state.doc.resolve(anchor);
250
- // Walk up the node tree to find the nearest block-level node
422
+ if (sel instanceof NodeSelection && sel.node.type.name === 'citation') {
423
+ const attrs = sel.node.attrs;
424
+ this.elementInfo = {
425
+ type: 'citation',
426
+ id: attrs?.id ?? null,
427
+ attrs: { ...attrs },
428
+ position: sel.from,
429
+ };
430
+ return;
431
+ }
432
+ if (sel instanceof NodeSelection && sel.node.type.name === 'figure') {
433
+ const attrs = sel.node.attrs;
434
+ this.elementInfo = {
435
+ type: 'figure',
436
+ id: attrs?.id ?? null,
437
+ attrs: { ...attrs },
438
+ position: sel.from,
439
+ };
440
+ return;
441
+ }
442
+ if (sel instanceof NodeSelection && sel.node.type.name === 'link') {
443
+ const attrs = sel.node.attrs;
444
+ this.elementInfo = {
445
+ type: 'link',
446
+ id: attrs?.id ?? null,
447
+ attrs: { ...attrs },
448
+ position: sel.from,
449
+ };
450
+ return;
451
+ }
452
+ // Check for anchor mark (hyperlink) at cursor or in selection
453
+ const anchorMarkType = view.state.schema.marks['anchor'];
454
+ if (anchorMarkType) {
455
+ const { from, to, empty } = sel;
456
+ let anchorMark = null;
457
+ if (empty) {
458
+ const marks = view.state.storedMarks ?? sel.$from.marks();
459
+ anchorMark = marks.find((m) => m.type === anchorMarkType) ?? null;
460
+ if (!anchorMark) {
461
+ // Also check the character before the cursor
462
+ const marksBefore = sel.$from.marks();
463
+ anchorMark = marksBefore.find((m) => m.type === anchorMarkType) ?? null;
464
+ }
465
+ }
466
+ else {
467
+ view.state.doc.nodesBetween(from, to, (node) => {
468
+ if (!anchorMark && node.isText) {
469
+ anchorMark = node.marks.find((m) => m.type === anchorMarkType) ?? null;
470
+ }
471
+ return !anchorMark;
472
+ });
473
+ }
474
+ if (anchorMark) {
475
+ this.elementInfo = {
476
+ type: 'anchor',
477
+ id: null,
478
+ attrs: { ...anchorMark.attrs },
479
+ position: null,
480
+ };
481
+ return;
482
+ }
483
+ }
484
+ let elementInfo = null;
485
+ let nodePosition;
486
+ const anchor = selection?.anchor ?? 0;
487
+ const $pos = view.state.doc.resolve(anchor);
488
+ const citationType = view.state.schema.nodes.citation;
489
+ // Prefer citation (inline) when cursor is inside one
490
+ if (citationType) {
251
491
  for (let depth = $pos.depth; depth > 0; depth--) {
252
492
  const node = $pos.node(depth);
253
- // Check if this is a block-level node (not doc, not inline)
254
- if (node.type !== view.state.schema.topNodeType && node.type.spec.group === 'block') {
493
+ if (node.type === citationType) {
255
494
  nodePosition = $pos.before(depth);
256
495
  elementInfo = {
257
- type: node.type.name,
496
+ type: 'citation',
258
497
  id: node.attrs?.id ?? null,
259
498
  attrs: node.attrs ?? null,
260
499
  position: nodePosition,
@@ -262,19 +501,217 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
262
501
  break;
263
502
  }
264
503
  }
265
- // Fallback: if we didn't find a block node, use the immediate parent (excluding doc)
266
- if (!elementInfo && $pos.parent && $pos.parent.type !== view.state.schema.topNodeType) {
267
- nodePosition = $pos.before($pos.depth);
504
+ }
505
+ // Prefer figure when cursor is inside one (e.g. in caption)
506
+ if (!elementInfo) {
507
+ const figureType = view.state.schema.nodes.figure;
508
+ if (figureType) {
509
+ for (let depth = $pos.depth; depth > 0; depth--) {
510
+ const node = $pos.node(depth);
511
+ if (node.type === figureType) {
512
+ nodePosition = $pos.before(depth);
513
+ elementInfo = {
514
+ type: 'figure',
515
+ id: node.attrs?.id ?? null,
516
+ attrs: node.attrs ?? null,
517
+ position: nodePosition,
518
+ };
519
+ break;
520
+ }
521
+ }
522
+ }
523
+ }
524
+ // Prefer link (cross-reference) when cursor is inside one
525
+ if (!elementInfo) {
526
+ const linkType = view.state.schema.nodes.link;
527
+ if (linkType) {
528
+ for (let depth = $pos.depth; depth > 0; depth--) {
529
+ const node = $pos.node(depth);
530
+ if (node.type === linkType) {
531
+ nodePosition = $pos.before(depth);
532
+ elementInfo = {
533
+ type: 'link',
534
+ id: node.attrs?.id ?? null,
535
+ attrs: node.attrs ?? null,
536
+ position: nodePosition,
537
+ };
538
+ break;
539
+ }
540
+ }
541
+ }
542
+ }
543
+ if (!elementInfo) {
544
+ const nodeAtPos = view.state.doc.nodeAt(anchor);
545
+ if (nodeAtPos && !nodeAtPos.isText) {
546
+ nodePosition = anchor;
268
547
  elementInfo = {
269
- type: $pos.parent.type.name,
270
- id: $pos.parent.attrs?.id ?? null,
271
- attrs: $pos.parent.attrs ?? null,
548
+ type: nodeAtPos.type.name,
549
+ id: nodeAtPos.attrs?.id ?? null,
550
+ attrs: nodeAtPos.attrs ?? null,
272
551
  position: nodePosition,
273
552
  };
274
553
  }
554
+ else {
555
+ for (let depth = $pos.depth; depth > 0; depth--) {
556
+ const node = $pos.node(depth);
557
+ if (node.type !== view.state.schema.topNodeType && node.type.spec.group === 'block') {
558
+ nodePosition = $pos.before(depth);
559
+ elementInfo = {
560
+ type: node.type.name,
561
+ id: node.attrs?.id ?? null,
562
+ attrs: node.attrs ?? null,
563
+ position: nodePosition,
564
+ };
565
+ break;
566
+ }
567
+ }
568
+ if (!elementInfo && $pos.parent && $pos.parent.type !== view.state.schema.topNodeType) {
569
+ nodePosition = $pos.before($pos.depth);
570
+ elementInfo = {
571
+ type: $pos.parent.type.name,
572
+ id: $pos.parent.attrs?.id ?? null,
573
+ attrs: $pos.parent.attrs ?? null,
574
+ position: nodePosition,
575
+ };
576
+ }
577
+ }
275
578
  }
276
579
  this.elementInfo = elementInfo;
277
580
  }
581
+ /** Get the selected citation node's text content from the main document (for draft init). */
582
+ getCitationNodeTextContent() {
583
+ const node = this.getCitationNode();
584
+ return node?.textContent ?? '';
585
+ }
586
+ /** Get the selected citation node's content as a Fragment (same schema as citation allows). */
587
+ getCitationNodeContentFragment() {
588
+ const node = this.getCitationNode();
589
+ return node?.content ?? PMFragment.empty;
590
+ }
591
+ /** Get the citation node at the current selection, or null. */
592
+ getCitationNode() {
593
+ const view = this.resolvedEditor?.editorView;
594
+ if (!view || !this.elementInfo || this.elementInfo.type !== 'citation' || this.elementInfo.position == null) {
595
+ return null;
596
+ }
597
+ const doc = view.state.doc;
598
+ const pos = this.elementInfo.position;
599
+ const nodeAt = doc.nodeAt(pos);
600
+ const citationType = view.state.schema.nodes.citation;
601
+ if (nodeAt?.type === citationType) {
602
+ return nodeAt;
603
+ }
604
+ const $pos = doc.resolve(pos);
605
+ for (let d = $pos.depth; d > 0; d--) {
606
+ const node = $pos.node(d);
607
+ if (node.type === citationType) {
608
+ return node;
609
+ }
610
+ }
611
+ return null;
612
+ }
613
+ /** Get the selected cross-reference link node's text content from the main document. */
614
+ getLinkNodeTextContent() {
615
+ const node = this.getLinkNode();
616
+ return node?.textContent ?? '';
617
+ }
618
+ /** Get the selected cross-reference link node at current selection, or null. */
619
+ getLinkNode() {
620
+ const view = this.resolvedEditor?.editorView;
621
+ if (!view || !this.elementInfo || this.elementInfo.type !== 'link' || this.elementInfo.position == null) {
622
+ return null;
623
+ }
624
+ const doc = view.state.doc;
625
+ const pos = this.elementInfo.position;
626
+ const nodeAt = doc.nodeAt(pos);
627
+ const linkType = view.state.schema.nodes.link;
628
+ if (nodeAt?.type === linkType) {
629
+ return nodeAt;
630
+ }
631
+ const $pos = doc.resolve(pos);
632
+ for (let d = $pos.depth; d > 0; d--) {
633
+ const node = $pos.node(d);
634
+ if (node.type === linkType) {
635
+ return node;
636
+ }
637
+ }
638
+ return null;
639
+ }
640
+ /** Build outline target options for link href dropdown (headings and figures with id). */
641
+ getOutlineTargetOptions() {
642
+ const view = this.resolvedEditor?.editorView;
643
+ const pmDoc = view?.state?.doc ?? null;
644
+ if (!pmDoc) {
645
+ this.outlineTargetOptionsCacheDoc = null;
646
+ this.outlineTargetOptionsCache = [];
647
+ return [];
648
+ }
649
+ if (this.outlineTargetOptionsCacheDoc === pmDoc) {
650
+ return this.outlineTargetOptionsCache;
651
+ }
652
+ const outline = collectDocumentOutline(null, pmDoc);
653
+ const options = [];
654
+ for (const h of outline.headings) {
655
+ const id = h.id?.trim();
656
+ if (!id)
657
+ continue;
658
+ const level = h.level != null ? `H${h.level}` : 'Heading';
659
+ const text = h.text?.trim() || id;
660
+ options.push({ id: `#${id}`, label: `${level}: ${text}` });
661
+ }
662
+ for (const f of outline.figures) {
663
+ const id = f.id?.trim();
664
+ if (!id)
665
+ continue;
666
+ const text = f.text?.trim() || id;
667
+ options.push({ id: `#${id}`, label: `${t('outline.figure')}: ${text}` });
668
+ }
669
+ this.outlineTargetOptionsCacheDoc = pmDoc;
670
+ this.outlineTargetOptionsCache = options;
671
+ return options;
672
+ }
673
+ destroyCitationContentView() {
674
+ if (this.citationContentView) {
675
+ this.citationContentView.destroy();
676
+ this.citationContentView = null;
677
+ }
678
+ this.citationContentEditorContainer = null;
679
+ this.citationContentEditorPosition = null;
680
+ }
681
+ mountCitationContentEditor(container) {
682
+ const view = this.resolvedEditor?.editorView;
683
+ if (!view || !this.elementInfo || this.elementInfo.type !== 'citation' || this.elementInfo.position == null) {
684
+ return;
685
+ }
686
+ const schema = view.state.schema;
687
+ const paragraph = schema.nodes.paragraph;
688
+ const docNode = schema.nodes.doc;
689
+ if (!paragraph || !docNode) {
690
+ return;
691
+ }
692
+ const pos = this.elementInfo.position;
693
+ if (this.citationContentView && this.citationContentEditorPosition === pos && this.citationContentEditorContainer === container) {
694
+ return;
695
+ }
696
+ this.destroyCitationContentView();
697
+ this.citationContentEditorContainer = container;
698
+ this.citationContentEditorPosition = pos;
699
+ const initialContent = this.getCitationNodeContentFragment();
700
+ const paragraphNode = paragraph.create(null, initialContent);
701
+ const stateDoc = docNode.create(null, [paragraphNode]);
702
+ const strong = schema.marks.strong;
703
+ const em = schema.marks.em;
704
+ const plugins = [
705
+ keymap(baseKeymap),
706
+ ...(strong ? [keymap({ 'Mod-b': toggleMark(strong) })] : []),
707
+ ...(em ? [keymap({ 'Mod-i': toggleMark(em) })] : []),
708
+ ];
709
+ const state = EditorState.create({ doc: stateDoc, schema, plugins });
710
+ this.citationContentView = new EditorView(container, {
711
+ state,
712
+ editable: () => true,
713
+ });
714
+ }
278
715
  /** Convenience getter mirroring `editor.getCommands()`. */
279
716
  getCommandRunner() {
280
717
  return this.resolvedEditor?.commands ?? null;
@@ -282,28 +719,71 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
282
719
  /**
283
720
  * Render a single attribute field, decoding known encoded values for readability.
284
721
  */
722
+ getNodeTypeLabel(type) {
723
+ const known = {
724
+ figure: () => t('selectionEditor.figure'),
725
+ math: () => t('selectionEditor.math'),
726
+ citation: () => t('selectionEditor.citation'),
727
+ anchor: () => t('selectionEditor.hyperlink'),
728
+ link: () => t('selectionEditor.crossReference'),
729
+ heading: () => t('selectionEditor.heading'),
730
+ paragraph: () => t('selectionEditor.paragraph'),
731
+ };
732
+ const label = known[type]?.() ?? type.replace(/_/g, ' ');
733
+ return label.replace(/^\w/, (m) => m.toUpperCase());
734
+ }
285
735
  renderAttributeField(attrName, attrValue) {
286
736
  const decodedValue = this.decodeSpecialAttribute(attrName, attrValue);
287
737
  const renderedValue = decodedValue === null || decodedValue === undefined ? '' : String(decodedValue);
738
+ const friendlyLabel = this.getAttributeLabel(attrName);
739
+ if (attrName === 'level' && this.elementInfo?.type === 'heading') {
740
+ const level = Math.min(6, Math.max(1, parseInt(renderedValue, 10) || 1));
741
+ return html `
742
+ <div class="selection-editor-attr-field">
743
+ <label
744
+ class="selection-editor-attr-label"
745
+ for="selection-editor-attr-${attrName}"
746
+ >
747
+ ${friendlyLabel}
748
+ </label>
749
+ <select
750
+ id="selection-editor-attr-${attrName}"
751
+ class="selection-editor-input selection-editor-select"
752
+ .value=${String(level)}
753
+ @change=${(e) => this.handleDraftAttrChange(e, attrName)}
754
+ >
755
+ ${[1, 2, 3, 4, 5, 6].map((n) => html `<option value="${n}">${n}</option>`)}
756
+ </select>
757
+ </div>
758
+ `;
759
+ }
288
760
  return html `
289
761
  <div class="selection-editor-attr-field">
290
762
  <label
291
763
  class="selection-editor-attr-label"
292
764
  for="selection-editor-attr-${attrName}"
293
765
  >
294
- ${attrName}:
766
+ ${friendlyLabel}
295
767
  </label>
296
768
  <input
297
769
  type="text"
298
770
  id="selection-editor-attr-${attrName}"
299
771
  class="selection-editor-input"
300
772
  .value=${renderedValue}
301
- placeholder="No value"
302
- @change=${(e) => this.handleAttrChange(e, attrName)}
773
+ placeholder=${t('selectionEditor.defaultValue')}
774
+ @change=${(e) => this.handleDraftAttrChange(e, attrName)}
303
775
  />
304
776
  </div>
305
777
  `;
306
778
  }
779
+ /**
780
+ * Convert a raw attribute name like 'text-align' to a human-readable label.
781
+ */
782
+ getAttributeLabel(attrName) {
783
+ return attrName
784
+ .replace(/[-_]/g, ' ')
785
+ .replace(/^\w/, (m) => m.toUpperCase());
786
+ }
307
787
  /**
308
788
  * Decode known encoded attributes for more readable display/editing.
309
789
  */
@@ -325,25 +805,33 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
325
805
  }
326
806
  /**
327
807
  * Mount the citation source adapter UI when a citation node is selected.
808
+ * Uses draft context (onSourceChange) so changes apply only on Update.
328
809
  */
329
810
  mountCitationSourceAdapter(container) {
330
811
  if (!this.elementInfo || this.elementInfo.type !== 'citation') {
331
812
  return;
332
813
  }
333
- const rawSource = this.elementInfo.attrs?.source;
334
- const items = typeof rawSource === 'string'
335
- ? SourceField.fromString(rawSource)
336
- : Array.isArray(rawSource)
337
- ? rawSource
338
- : [];
814
+ const items = this.citationDraft != null
815
+ ? (SourceField.fromString(this.citationDraft.sourceEncoded ?? '') ?? [])
816
+ : (() => {
817
+ const rawSource = this.elementInfo.attrs?.source;
818
+ return typeof rawSource === 'string'
819
+ ? SourceField.fromString(rawSource)
820
+ : Array.isArray(rawSource)
821
+ ? rawSource
822
+ : [];
823
+ })();
339
824
  const adapter = this.citationSourceAdapter ?? getCitationSourceAdapter();
825
+ const refs = this.resolvedEditor?.references ?? [];
340
826
  const context = {
341
- applySource: (encoded) => {
342
- this.updateNodeAttribute('source', encoded);
343
- return true;
827
+ onSourceChange: (encoded) => {
828
+ if (this.citationDraft) {
829
+ this.citationDraft = { ...this.citationDraft, sourceEncoded: encoded };
830
+ }
344
831
  },
832
+ getReferences: () => refs.map((r) => ({ id: r.id, rawReference: r.rawReference })),
345
833
  };
346
- adapter.render(container, items, context);
834
+ adapter.render(container, items.length > 0 ? items : [{ id: '' }], context);
347
835
  }
348
836
  /**
349
837
  * Unmount the citation source adapter and clean up.
@@ -357,16 +845,16 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
357
845
  container.innerHTML = '';
358
846
  }
359
847
  }
360
- /**
361
- * Handle attribute field changes.
362
- */
363
- handleAttrChange(event, attrName) {
848
+ handleDraftAttrChange(event, attrName) {
364
849
  const input = event.target;
365
850
  const value = input.value.trim();
366
851
  let parsedValue = value;
367
852
  if (attrName === 'source') {
368
853
  parsedValue = this.encodeSourceField(value);
369
854
  }
855
+ else if (attrName === 'alt') {
856
+ parsedValue = value;
857
+ }
370
858
  else if (value !== '') {
371
859
  const numValue = parseFloat(value);
372
860
  if (!isNaN(numValue) && isFinite(numValue)) {
@@ -379,49 +867,612 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
379
867
  else {
380
868
  parsedValue = null;
381
869
  }
382
- this.updateNodeAttribute(attrName, parsedValue);
870
+ this.setDraftAttr(attrName, parsedValue);
871
+ }
872
+ /**
873
+ * Render the figure sidebar: type, id, alt text.
874
+ */
875
+ renderFigureSidebar() {
876
+ const attrs = this.getEffectiveAttrs();
877
+ const alt = typeof attrs.alt === 'string' ? attrs.alt : '';
878
+ return html `
879
+ <div class="selection-editor-container selection-editor-figure" role="form" aria-label=${t('selectionEditor.editFigure')} @keydown=${this.handleGenericKeydown}>
880
+ ${this.renderSelectionControls()}
881
+ <p class="selection-editor-type-header">${this.getNodeTypeLabel(this.elementInfo.type)}</p>
882
+ <div class="selection-editor-field">
883
+ <label class="selection-editor-label" for="figure-alt">${t('selectionEditor.altText')}</label>
884
+ <input
885
+ type="text"
886
+ id="figure-alt"
887
+ class="selection-editor-input"
888
+ .value=${alt}
889
+ placeholder=${t('selectionEditor.altTextPlaceholder')}
890
+ @change=${(e) => this.handleDraftAttrChange(e, 'alt')}
891
+ />
892
+ </div>
893
+ ${this.renderDraftActions()}
894
+ </div>
895
+ `;
383
896
  }
384
897
  /**
385
- * Update node attributes using the command API.
386
- * Preserves the 'data' attribute if it exists, even though it's not displayed.
898
+ * Render the math equation sidebar: TeX source, style, label, preview, status, Apply/Cancel.
387
899
  */
388
- updateNodeAttribute(attrName, attrValue) {
389
- if (!this.elementInfo || this.elementInfo.position === null) {
390
- console.warn('[sciflow-selection-editor] Cannot update: no element info or position');
900
+ renderMathSidebar() {
901
+ const d = this.mathDraft;
902
+ const previewErr = this.mathPreviewResult?.error;
903
+ const previewWarnings = this.mathPreviewResult?.warnings ?? [];
904
+ return html `
905
+ <div
906
+ class="selection-editor-container selection-editor-math"
907
+ role="form"
908
+ aria-label=${t('selectionEditor.editEquation')}
909
+ @keydown=${this.handleMathKeydown}
910
+ >
911
+ ${this.renderSelectionControls()}
912
+ <p class="selection-editor-type-header">${this.getNodeTypeLabel(this.elementInfo.type)}</p>
913
+ <div class="selection-editor-field">
914
+ <label class="selection-editor-label" for="math-tex">${t('selectionEditor.tex')}</label>
915
+ <textarea
916
+ id="math-tex"
917
+ class="selection-editor-textarea"
918
+ .value=${d.tex}
919
+ placeholder=${t('selectionEditor.texPlaceholder')}
920
+ rows="4"
921
+ aria-describedby=${previewErr ? 'math-preview-error' : previewWarnings.length ? 'math-preview-warnings' : undefined}
922
+ @input=${(e) => {
923
+ const t = e.target.value;
924
+ this.mathDraft = { ...this.mathDraft, tex: t };
925
+ this.scheduleMathPreview();
926
+ }}
927
+ ></textarea>
928
+ </div>
929
+ <div class="selection-editor-field">
930
+ <label class="selection-editor-label" for="math-style">${t('selectionEditor.style')}</label>
931
+ <select
932
+ id="math-style"
933
+ class="selection-editor-input"
934
+ .value=${d.style}
935
+ @change=${(e) => {
936
+ this.mathDraft = { ...this.mathDraft, style: e.target.value };
937
+ this.scheduleMathPreview();
938
+ }}
939
+ >
940
+ <option value="inline">${t('selectionEditor.inline')}</option>
941
+ <option value="display">${t('selectionEditor.display')}</option>
942
+ </select>
943
+ </div>
944
+ <div class="selection-editor-field">
945
+ <label class="selection-editor-label" for="math-label">${t('selectionEditor.labelOptional')}</label>
946
+ <input
947
+ id="math-label"
948
+ type="text"
949
+ class="selection-editor-input"
950
+ .value=${d.label}
951
+ placeholder=${t('selectionEditor.labelPlaceholder')}
952
+ @input=${(e) => {
953
+ this.mathDraft = { ...this.mathDraft, label: e.target.value };
954
+ }}
955
+ />
956
+ </div>
957
+ <div class="selection-editor-field">
958
+ <span class="selection-editor-label">${t('selectionEditor.preview')}</span>
959
+ <div
960
+ class="selection-editor-math-preview"
961
+ ${ref((el) => {
962
+ this.mathPreviewContainer = el ?? null;
963
+ if (el) {
964
+ this.runMathPreview();
965
+ }
966
+ })}
967
+ role="img"
968
+ aria-label=${t('selectionEditor.equationPreview')}
969
+ ></div>
970
+ ${previewErr
971
+ ? html `<p id="math-preview-error" class="selection-editor-math-error" role="alert">${previewErr}</p>`
972
+ : ''}
973
+ ${previewWarnings.length
974
+ ? html `
975
+ <p id="math-preview-warnings" class="selection-editor-math-warnings" role="status">
976
+ ${previewWarnings.join(' ')}
977
+ </p>
978
+ `
979
+ : ''}
980
+ </div>
981
+ <div class="selection-editor-math-actions">
982
+ <button
983
+ type="button"
984
+ class="selection-editor-button selection-editor-button--primary"
985
+ @click=${this.applyMathDraft}
986
+ >
987
+ ${t('selectionEditor.apply')}
988
+ </button>
989
+ <span class="selection-editor-math-hint">${t('selectionEditor.mathHint')}</span>
990
+ </div>
991
+ <p class="selection-editor-math-docs">
992
+ <a
993
+ href="https://docs.mathjax.org/en/v4.0"
994
+ target="_blank"
995
+ rel="noopener noreferrer"
996
+ class="selection-editor-math-docs-link"
997
+ >${t('selectionEditor.mathDocsLink')}</a>
998
+ </p>
999
+ </div>
1000
+ `;
1001
+ }
1002
+ scheduleMathPreview() {
1003
+ if (this.mathPreviewDebounce) {
1004
+ clearTimeout(this.mathPreviewDebounce);
1005
+ }
1006
+ this.mathPreviewDebounce = setTimeout(() => {
1007
+ this.mathPreviewDebounce = null;
1008
+ this.runMathPreview();
1009
+ }, 300);
1010
+ }
1011
+ runMathPreview() {
1012
+ if (!this.mathDraft || !this.mathPreviewContainer) {
1013
+ return;
1014
+ }
1015
+ const tex = this.mathDraft.tex.trim() || '\\phantom{x}';
1016
+ const display = this.mathDraft.style === 'display';
1017
+ renderTexToSvg(tex, { display, metricsNode: this.mathPreviewContainer }).then((result) => {
1018
+ if (!this.mathPreviewContainer) {
1019
+ return;
1020
+ }
1021
+ this.mathPreviewContainer.innerHTML = '';
1022
+ if (result.svg) {
1023
+ this.mathPreviewContainer.appendChild(result.svg);
1024
+ this.mathPreviewResult = result.warnings?.length ? { warnings: result.warnings } : null;
1025
+ }
1026
+ else {
1027
+ this.mathPreviewResult = {
1028
+ ...(result.error ? { error: result.error } : {}),
1029
+ ...(result.warnings?.length ? { warnings: result.warnings } : {}),
1030
+ };
1031
+ if (result.unavailable && result.error) {
1032
+ const p = document.createElement('p');
1033
+ p.className = 'selection-editor-math-error';
1034
+ p.setAttribute('role', 'alert');
1035
+ p.textContent = result.error;
1036
+ this.mathPreviewContainer.appendChild(p);
1037
+ }
1038
+ }
1039
+ this.requestUpdate();
1040
+ });
1041
+ }
1042
+ applyMathDraft() {
1043
+ if (!this.elementInfo || this.elementInfo.type !== 'math' || this.elementInfo.position === null || !this.mathDraft) {
391
1044
  return;
392
1045
  }
393
1046
  const runner = this.getCommandRunner();
394
- if (!runner) {
395
- console.warn('[sciflow-selection-editor] Cannot update: no command runner');
1047
+ const setNodeAttrs = runner?.commands?.setNodeAttrs;
1048
+ if (!setNodeAttrs) {
396
1049
  return;
397
1050
  }
398
- try {
399
- // Prepare the updated attributes object
400
- // The setNodeAttrs command merges with existing attributes, so 'data' will be preserved
401
- const updatedAttrs = {
402
- [attrName]: attrValue,
1051
+ const ok = setNodeAttrs(this.elementInfo.position, {
1052
+ tex: this.mathDraft.tex,
1053
+ style: this.mathDraft.style,
1054
+ label: this.mathDraft.label || undefined,
1055
+ });
1056
+ if (ok && this.resolvedEditor?.editorView) {
1057
+ const sel = this.resolvedEditor.editorView.state.selection;
1058
+ this.updateElementInfo({ anchor: sel.anchor, head: sel.head });
1059
+ }
1060
+ }
1061
+ cancelMathDraft() {
1062
+ if (this.elementInfo?.type === 'math' && this.elementInfo.attrs) {
1063
+ const attrs = this.elementInfo.attrs;
1064
+ this.mathDraft = {
1065
+ tex: attrs.tex ?? '',
1066
+ style: attrs.style === 'display' ? 'display' : 'inline',
1067
+ label: attrs.label ?? '',
403
1068
  };
404
- // Use the editor command API to update node attributes
405
- // The command implementation merges with existing attrs, preserving 'data' and other attributes
406
- const setNodeAttrsCommand = runner.commands.setNodeAttrs;
407
- if (!setNodeAttrsCommand) {
408
- console.warn('[sciflow-selection-editor] setNodeAttrs command not available');
409
- return;
1069
+ this.scheduleMathPreview();
1070
+ this.mathPreviewResult = null;
1071
+ }
1072
+ }
1073
+ handleMathKeydown(e) {
1074
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1075
+ e.preventDefault();
1076
+ this.applyMathDraft();
1077
+ }
1078
+ else if (e.key === 'Escape') {
1079
+ e.preventDefault();
1080
+ this.cancelMathDraft();
1081
+ }
1082
+ }
1083
+ /**
1084
+ * Render the citation sidebar: content editor, source fields, Update button.
1085
+ */
1086
+ renderCitationSidebar() {
1087
+ return html `
1088
+ <div
1089
+ class="selection-editor-container selection-editor-citation"
1090
+ role="form"
1091
+ aria-label=${t('selectionEditor.editCitation')}
1092
+ @keydown=${this.handleCitationKeydown}
1093
+ >
1094
+ ${this.renderSelectionControls()}
1095
+ <p class="selection-editor-type-header">${this.getNodeTypeLabel(this.elementInfo.type)}</p>
1096
+ <div class="selection-editor-field">
1097
+ <label class="selection-editor-label" for="citation-content">${t('selectionEditor.inTextCitation')}</label>
1098
+ <div
1099
+ id="citation-content"
1100
+ class="selection-editor-input selection-editor-citation-content selection-editor-citation-pm"
1101
+ role="textbox"
1102
+ aria-label=${t('selectionEditor.inTextCitationAriaLabel')}
1103
+ ${ref((el) => {
1104
+ if (el) {
1105
+ this.mountCitationContentEditor(el);
410
1106
  }
411
- const success = setNodeAttrsCommand(this.elementInfo.position, updatedAttrs);
412
- if (success && this.resolvedEditor?.editorView) {
413
- const selection = this.resolvedEditor.editorView.state.selection;
414
- this.updateElementInfo({
415
- anchor: selection.anchor,
416
- head: selection.head,
417
- });
1107
+ else {
1108
+ this.destroyCitationContentView();
1109
+ }
1110
+ })}
1111
+ ></div>
1112
+ </div>
1113
+ <div class="selection-editor-attr-field">
1114
+ <span class="selection-editor-label">${t('selectionEditor.semanticReference')}</span>
1115
+ <div
1116
+ class="citation-source-adapter-root"
1117
+ ${ref((el) => {
1118
+ if (el) {
1119
+ this.citationSourceContainer = el;
1120
+ this.mountCitationSourceAdapter(el);
418
1121
  }
419
- else if (!success) {
420
- console.warn('[sciflow-selection-editor] Failed to update node attribute');
1122
+ else if (this.citationSourceContainer) {
1123
+ this.unmountCitationSourceAdapter(this.citationSourceContainer);
1124
+ this.citationSourceContainer = null;
421
1125
  }
1126
+ })}
1127
+ ></div>
1128
+ </div>
1129
+ <div class="selection-editor-citation-actions">
1130
+ <button
1131
+ type="button"
1132
+ class="selection-editor-button selection-editor-button--primary"
1133
+ @click=${this.applyCitationDraft}
1134
+ >
1135
+ ${t('selectionEditor.update')}
1136
+ </button>
1137
+ <span class="selection-editor-citation-hint">${t('selectionEditor.updateHint')}</span>
1138
+ </div>
1139
+ </div>
1140
+ `;
1141
+ }
1142
+ applyCitationDraft() {
1143
+ if (!this.elementInfo ||
1144
+ this.elementInfo.type !== 'citation' ||
1145
+ this.elementInfo.position === null ||
1146
+ !this.citationDraft) {
1147
+ return;
1148
+ }
1149
+ const runner = this.getCommandRunner();
1150
+ const updateCitationNode = runner?.commands?.updateCitationNode;
1151
+ if (!updateCitationNode) {
1152
+ console.warn('[sciflow-selection-editor] updateCitationNode command not available');
1153
+ return;
1154
+ }
1155
+ const content = this.citationContentView?.state?.doc?.firstChild?.content ?? this.citationDraft.content;
1156
+ const ok = updateCitationNode(this.elementInfo.position, {
1157
+ attrs: { source: this.citationDraft.sourceEncoded },
1158
+ content,
1159
+ });
1160
+ if (ok && this.resolvedEditor?.editorView) {
1161
+ const sel = this.resolvedEditor.editorView.state.selection;
1162
+ this.updateElementInfo({ anchor: sel.anchor, head: sel.head });
1163
+ }
1164
+ }
1165
+ handleCitationKeydown(e) {
1166
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1167
+ e.preventDefault();
1168
+ this.applyCitationDraft();
1169
+ }
1170
+ }
1171
+ /**
1172
+ * Render the cross-reference sidebar: display text + target, Update button.
1173
+ */
1174
+ renderCrossReferenceSidebar() {
1175
+ const d = this.crossReferenceDraft;
1176
+ const options = this.getOutlineTargetOptions();
1177
+ const hasCurrentTarget = options.some((o) => o.id === d.href);
1178
+ return html `
1179
+ <div
1180
+ class="selection-editor-container selection-editor-citation"
1181
+ role="form"
1182
+ aria-label=${t('selectionEditor.editCrossReference')}
1183
+ @keydown=${this.handleCrossReferenceKeydown}
1184
+ >
1185
+ ${this.renderSelectionControls()}
1186
+ <p class="selection-editor-type-header">${this.getNodeTypeLabel(this.elementInfo.type)}</p>
1187
+ <div class="selection-editor-field">
1188
+ <label class="selection-editor-label" for="crossref-content">${t('selectionEditor.displayText')}</label>
1189
+ <input
1190
+ id="crossref-content"
1191
+ type="text"
1192
+ class="selection-editor-input"
1193
+ .value=${d.content}
1194
+ placeholder=${t('selectionEditor.displayTextPlaceholder')}
1195
+ @input=${(e) => {
1196
+ this.crossReferenceDraft = {
1197
+ ...this.crossReferenceDraft,
1198
+ content: e.target.value,
1199
+ };
1200
+ }}
1201
+ />
1202
+ </div>
1203
+ <div class="selection-editor-field">
1204
+ <label class="selection-editor-label" for="crossref-target">${t('selectionEditor.target')}</label>
1205
+ <select
1206
+ id="crossref-target"
1207
+ class="selection-editor-input selection-editor-select"
1208
+ .value=${d.href}
1209
+ @change=${(e) => {
1210
+ this.crossReferenceDraft = {
1211
+ ...this.crossReferenceDraft,
1212
+ href: e.target.value,
1213
+ };
1214
+ }}
1215
+ >
1216
+ ${!hasCurrentTarget && d.href
1217
+ ? html `<option value="${d.href}">${d.href}</option>`
1218
+ : ''}
1219
+ ${options.map((option) => html `<option value="${option.id}">${option.label ?? option.id}</option>`)}
1220
+ </select>
1221
+ </div>
1222
+ <div class="selection-editor-citation-actions">
1223
+ <button
1224
+ type="button"
1225
+ class="selection-editor-button selection-editor-button--primary"
1226
+ @click=${this.applyCrossReferenceDraft}
1227
+ >
1228
+ ${t('selectionEditor.update')}
1229
+ </button>
1230
+ <span class="selection-editor-citation-hint">${t('selectionEditor.updateHint')}</span>
1231
+ </div>
1232
+ </div>
1233
+ `;
1234
+ }
1235
+ applyCrossReferenceDraft() {
1236
+ if (!this.elementInfo ||
1237
+ this.elementInfo.type !== 'link' ||
1238
+ this.elementInfo.position === null ||
1239
+ !this.crossReferenceDraft) {
1240
+ return;
1241
+ }
1242
+ const runner = this.getCommandRunner();
1243
+ const updateCrossReferenceNode = runner?.commands?.updateCrossReferenceNode;
1244
+ if (!updateCrossReferenceNode) {
1245
+ console.warn('[sciflow-selection-editor] updateCrossReferenceNode command not available');
1246
+ return;
1247
+ }
1248
+ const ok = updateCrossReferenceNode(this.elementInfo.position, {
1249
+ attrs: { href: this.crossReferenceDraft.href },
1250
+ content: this.crossReferenceDraft.content,
1251
+ });
1252
+ if (ok && this.resolvedEditor?.editorView) {
1253
+ const sel = this.resolvedEditor.editorView.state.selection;
1254
+ this.updateElementInfo({ anchor: sel.anchor, head: sel.head });
422
1255
  }
423
- catch (error) {
424
- console.error('[sciflow-selection-editor] Failed to update node attribute:', error);
1256
+ }
1257
+ handleCrossReferenceKeydown(e) {
1258
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1259
+ e.preventDefault();
1260
+ this.applyCrossReferenceDraft();
1261
+ }
1262
+ }
1263
+ /**
1264
+ * Render the hyperlink (anchor mark) sidebar: URL input, Apply and Remove buttons.
1265
+ */
1266
+ renderHyperlinkSidebar() {
1267
+ const d = this.hyperlinkDraft;
1268
+ return html `
1269
+ <div
1270
+ class="selection-editor-container selection-editor-citation"
1271
+ role="form"
1272
+ aria-label=${t('selectionEditor.editHyperlink')}
1273
+ @keydown=${this.handleHyperlinkKeydown}
1274
+ >
1275
+ ${this.renderSelectionControls()}
1276
+ <p class="selection-editor-type-header">${this.getNodeTypeLabel('anchor')}</p>
1277
+ <div class="selection-editor-field">
1278
+ <label class="selection-editor-label" for="hyperlink-href">${t('selectionEditor.url')}</label>
1279
+ <input
1280
+ id="hyperlink-href"
1281
+ type="url"
1282
+ class="selection-editor-input"
1283
+ .value=${d.href}
1284
+ placeholder=${t('selectionEditor.urlPlaceholder')}
1285
+ @input=${(e) => {
1286
+ this.hyperlinkDraft = { href: e.target.value };
1287
+ }}
1288
+ />
1289
+ </div>
1290
+ <div class="selection-editor-citation-actions">
1291
+ <button
1292
+ type="button"
1293
+ class="selection-editor-button selection-editor-button--primary"
1294
+ @click=${this.applyHyperlinkDraft}
1295
+ >
1296
+ ${t('selectionEditor.update')}
1297
+ </button>
1298
+ <button
1299
+ type="button"
1300
+ class="selection-editor-button"
1301
+ @click=${this.removeHyperlink}
1302
+ >
1303
+ ${t('selectionEditor.removeLink')}
1304
+ </button>
1305
+ <span class="selection-editor-citation-hint">${t('selectionEditor.updateHint')}</span>
1306
+ </div>
1307
+ </div>
1308
+ `;
1309
+ }
1310
+ applyHyperlinkDraft() {
1311
+ const view = this.resolvedEditor?.editorView;
1312
+ if (!view || !this.hyperlinkDraft)
1313
+ return;
1314
+ const { state } = view;
1315
+ const anchorMarkType = state.schema.marks['anchor'];
1316
+ if (!anchorMarkType)
1317
+ return;
1318
+ const newMark = anchorMarkType.create({ href: this.hyperlinkDraft.href, title: null, id: null });
1319
+ // Find a document position known to be inside the anchor mark so that
1320
+ // getMarkRange can resolve the full extent, regardless of where the
1321
+ // selection boundaries fall relative to the marked text.
1322
+ const { from, to, empty } = state.selection;
1323
+ let posInsideMark = null;
1324
+ if (!empty) {
1325
+ state.doc.nodesBetween(from, to, (node, pos) => {
1326
+ if (posInsideMark === null && node.isText && node.marks.some((m) => m.type === anchorMarkType)) {
1327
+ posInsideMark = pos + 1; // step into the text node
1328
+ }
1329
+ return posInsideMark === null;
1330
+ });
1331
+ }
1332
+ const resolvedPos = posInsideMark !== null
1333
+ ? state.doc.resolve(posInsideMark)
1334
+ : state.selection.$from;
1335
+ const range = getMarkRange(resolvedPos, anchorMarkType, null);
1336
+ if (!range) {
1337
+ // Mark exists only in storedMarks; update it in place.
1338
+ view.dispatch(state.tr.addStoredMark(newMark));
1339
+ return;
1340
+ }
1341
+ const tr = state.tr
1342
+ .removeMark(range.from, range.to, anchorMarkType)
1343
+ .addMark(range.from, range.to, newMark);
1344
+ view.dispatch(tr);
1345
+ }
1346
+ removeHyperlink() {
1347
+ const view = this.resolvedEditor?.editorView;
1348
+ if (!view)
1349
+ return;
1350
+ const { state } = view;
1351
+ const anchorMarkType = state.schema.marks['anchor'];
1352
+ if (!anchorMarkType)
1353
+ return;
1354
+ const { from, to, empty } = state.selection;
1355
+ if (empty) {
1356
+ const range = getMarkRange(state.selection.$from, anchorMarkType, null);
1357
+ if (!range) {
1358
+ // Mark exists only in storedMarks; clear it.
1359
+ view.dispatch(state.tr.removeStoredMark(anchorMarkType));
1360
+ return;
1361
+ }
1362
+ view.dispatch(state.tr.removeMark(range.from, range.to, anchorMarkType));
1363
+ }
1364
+ else {
1365
+ view.dispatch(state.tr.removeMark(from, to, anchorMarkType));
1366
+ }
1367
+ }
1368
+ handleHyperlinkKeydown(e) {
1369
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1370
+ e.preventDefault();
1371
+ this.applyHyperlinkDraft();
1372
+ }
1373
+ }
1374
+ resetDraftAttrs() {
1375
+ this.draftAttrs = null;
1376
+ this.draftDirty = false;
1377
+ }
1378
+ getEffectiveAttrs() {
1379
+ const base = (this.elementInfo?.attrs ?? {});
1380
+ if (!this.draftAttrs || Object.keys(this.draftAttrs).length === 0) {
1381
+ return base;
1382
+ }
1383
+ return { ...base, ...this.draftAttrs };
1384
+ }
1385
+ setDraftAttr(attrName, value) {
1386
+ const currentAttrs = (this.elementInfo?.attrs ?? {});
1387
+ const previous = currentAttrs[attrName] ?? null;
1388
+ const nextDraft = { ...(this.draftAttrs ?? {}) };
1389
+ if (previous === value) {
1390
+ delete nextDraft[attrName];
1391
+ }
1392
+ else {
1393
+ nextDraft[attrName] = value;
1394
+ }
1395
+ this.draftAttrs = Object.keys(nextDraft).length > 0 ? nextDraft : null;
1396
+ this.draftDirty = Boolean(this.draftAttrs && Object.keys(this.draftAttrs).length > 0);
1397
+ }
1398
+ applyDraftAttrs() {
1399
+ if (!this.draftAttrs || !this.draftDirty || !this.elementInfo || this.elementInfo.position === null) {
1400
+ return;
1401
+ }
1402
+ const runner = this.getCommandRunner();
1403
+ const setNodeAttrsCommand = runner?.commands?.setNodeAttrs;
1404
+ if (!setNodeAttrsCommand) {
1405
+ return;
1406
+ }
1407
+ const success = setNodeAttrsCommand(this.elementInfo.position, this.draftAttrs);
1408
+ if (success && this.resolvedEditor?.editorView) {
1409
+ const selection = this.resolvedEditor.editorView.state.selection;
1410
+ this.resetDraftAttrs();
1411
+ this.updateElementInfo({
1412
+ anchor: selection.anchor,
1413
+ head: selection.head,
1414
+ });
1415
+ }
1416
+ }
1417
+ revertDraftAttrs() {
1418
+ this.resetDraftAttrs();
1419
+ }
1420
+ renderDraftActions() {
1421
+ if (!this.draftDirty) {
1422
+ return null;
1423
+ }
1424
+ return html `
1425
+ <div class="selection-editor-actions">
1426
+ <button
1427
+ type="button"
1428
+ class="selection-editor-button selection-editor-button--primary"
1429
+ @click=${this.applyDraftAttrs}
1430
+ >
1431
+ ${t('selectionEditor.apply')}
1432
+ </button>
1433
+ <button
1434
+ type="button"
1435
+ class="selection-editor-button"
1436
+ @click=${this.revertDraftAttrs}
1437
+ >
1438
+ ${t('selectionEditor.revert')}
1439
+ </button>
1440
+ <span class="selection-editor-hint">${t('selectionEditor.draftHint')}</span>
1441
+ </div>
1442
+ `;
1443
+ }
1444
+ renderSelectionControls() {
1445
+ return html `
1446
+ <div class="selection-editor-controls">
1447
+ <button
1448
+ type="button"
1449
+ class="selection-editor-button selection-editor-button--secondary"
1450
+ @click=${this.togglePinSelection}
1451
+ aria-pressed=${this.pinSelection ? 'true' : 'false'}
1452
+ >
1453
+ ${this.pinSelection ? t('selectionEditor.unpinSelection') : t('selectionEditor.pinSelection')}
1454
+ </button>
1455
+ </div>
1456
+ `;
1457
+ }
1458
+ togglePinSelection() {
1459
+ this.pinSelection = !this.pinSelection;
1460
+ if (!this.pinSelection && this.resolvedEditor?.editorView) {
1461
+ const selection = this.resolvedEditor.editorView.state.selection;
1462
+ this.scheduleSelectionUpdate({
1463
+ anchor: selection.anchor,
1464
+ head: selection.head,
1465
+ });
1466
+ }
1467
+ }
1468
+ handleGenericKeydown(e) {
1469
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1470
+ e.preventDefault();
1471
+ this.applyDraftAttrs();
1472
+ }
1473
+ else if (e.key === 'Escape') {
1474
+ e.preventDefault();
1475
+ this.revertDraftAttrs();
425
1476
  }
426
1477
  }
427
1478
  /**
@@ -461,6 +1512,33 @@ __decorate([
461
1512
  __decorate([
462
1513
  state()
463
1514
  ], SciFlowSelectionEditorElement.prototype, "elementInfo", void 0);
1515
+ __decorate([
1516
+ state()
1517
+ ], SciFlowSelectionEditorElement.prototype, "mathDraft", void 0);
1518
+ __decorate([
1519
+ state()
1520
+ ], SciFlowSelectionEditorElement.prototype, "mathPreviewResult", void 0);
1521
+ __decorate([
1522
+ state()
1523
+ ], SciFlowSelectionEditorElement.prototype, "citationDraft", void 0);
1524
+ __decorate([
1525
+ state()
1526
+ ], SciFlowSelectionEditorElement.prototype, "crossReferenceDraft", void 0);
1527
+ __decorate([
1528
+ state()
1529
+ ], SciFlowSelectionEditorElement.prototype, "hyperlinkDraft", void 0);
1530
+ __decorate([
1531
+ state()
1532
+ ], SciFlowSelectionEditorElement.prototype, "draftAttrs", void 0);
1533
+ __decorate([
1534
+ state()
1535
+ ], SciFlowSelectionEditorElement.prototype, "draftDirty", void 0);
1536
+ __decorate([
1537
+ state()
1538
+ ], SciFlowSelectionEditorElement.prototype, "pinSelection", void 0);
1539
+ __decorate([
1540
+ state()
1541
+ ], SciFlowSelectionEditorElement.prototype, "showAllAttrs", void 0);
464
1542
  SciFlowSelectionEditorElement = __decorate([
465
1543
  customElement('sciflow-selection-editor')
466
1544
  ], SciFlowSelectionEditorElement);