@sciflow/editor-start 0.0.1-beta → 0.0.1-beta2

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 (43) hide show
  1. package/README.md +34 -0
  2. package/dist/bundle/sciflow-editor.css +1 -1
  3. package/dist/bundle/sciflow-editor.js +7643 -5555
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/lib/citation-source-adapter.d.ts +22 -2
  8. package/dist/lib/citation-source-adapter.d.ts.map +1 -1
  9. package/dist/lib/citation-source-adapter.js +104 -48
  10. package/dist/lib/drag-preview.d.ts +13 -0
  11. package/dist/lib/drag-preview.d.ts.map +1 -0
  12. package/dist/lib/drag-preview.js +36 -0
  13. package/dist/lib/editor-element.d.ts +16 -1
  14. package/dist/lib/editor-element.d.ts.map +1 -1
  15. package/dist/lib/editor-element.js +107 -30
  16. package/dist/lib/format-bar.d.ts +7 -0
  17. package/dist/lib/format-bar.d.ts.map +1 -1
  18. package/dist/lib/format-bar.js +79 -10
  19. package/dist/lib/outline.d.ts +10 -5
  20. package/dist/lib/outline.d.ts.map +1 -1
  21. package/dist/lib/outline.js +112 -1
  22. package/dist/lib/reference-list.d.ts.map +1 -1
  23. package/dist/lib/reference-list.js +2 -0
  24. package/dist/lib/selection-editor.d.ts +67 -5
  25. package/dist/lib/selection-editor.d.ts.map +1 -1
  26. package/dist/lib/selection-editor.js +1060 -125
  27. package/dist/lib/selection-transform-registry.d.ts +16 -0
  28. package/dist/lib/selection-transform-registry.d.ts.map +1 -0
  29. package/dist/lib/selection-transform-registry.js +24 -0
  30. package/dist/lib/selection-ui-renderer.d.ts +21 -0
  31. package/dist/lib/selection-ui-renderer.d.ts.map +1 -0
  32. package/dist/lib/selection-ui-renderer.js +244 -0
  33. package/dist/lib/selection-widget-host.d.ts +67 -0
  34. package/dist/lib/selection-widget-host.d.ts.map +1 -0
  35. package/dist/lib/selection-widget-host.js +151 -0
  36. package/dist/lib/selection-widget-registry.d.ts +16 -0
  37. package/dist/lib/selection-widget-registry.d.ts.map +1 -0
  38. package/dist/lib/selection-widget-registry.js +30 -0
  39. package/dist/lib/test-fixtures/soak-documents.d.ts +13 -0
  40. package/dist/lib/test-fixtures/soak-documents.d.ts.map +1 -0
  41. package/dist/lib/test-fixtures/soak-documents.js +69 -0
  42. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  43. package/package.json +9 -5
@@ -17,7 +17,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
19
  import { SourceField } from '@sciflow/editor-core';
20
+ import { renderTexToSvg } from '@sciflow/editor-core/features/math';
21
+ import { Fragment as PMFragment } from 'prosemirror-model';
22
+ import { EditorState, NodeSelection } from 'prosemirror-state';
23
+ import { EditorView } from 'prosemirror-view';
24
+ import { baseKeymap, toggleMark } from 'prosemirror-commands';
25
+ import { keymap } from 'prosemirror-keymap';
20
26
  import { getCitationSourceAdapter, } from './citation-source-adapter.js';
27
+ import { collectDocumentOutline } from './outline.js';
28
+ import { renderNodeFromUiSchema } from './selection-ui-renderer.js';
29
+ import './selection-widget-host.js';
21
30
  import selectionEditorCss from './selection-editor.css?inline';
22
31
  import { applyThemeStylesToRoot, subscribeToSciFlowTheme } from './theme.js';
23
32
  /**
@@ -37,9 +46,28 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
37
46
  /** Custom adapter for editing citation source. When unset, uses the global adapter. */
38
47
  this.citationSourceAdapter = null;
39
48
  this.elementInfo = null;
49
+ /** Draft values for math sidebar (tex, style, label). Synced from node attrs when math is selected. */
50
+ this.mathDraft = null;
51
+ /** Last preview result for math (error/warnings). SVG is rendered into mathPreviewContainer. */
52
+ this.mathPreviewResult = null;
53
+ /** Draft values for citation sidebar (content + source). Applied only on Update. */
54
+ this.citationDraft = null;
55
+ /** Draft values for cross-reference sidebar (display text + target href). */
56
+ this.crossReferenceDraft = null;
57
+ this.draftAttrs = null;
58
+ this.draftDirty = false;
59
+ this.pinSelection = false;
40
60
  this.resolvedEditor = null;
41
61
  this.themeStyleElements = [];
42
62
  this.citationSourceContainer = null;
63
+ this.mathPreviewContainer = null;
64
+ this.mathPreviewDebounce = null;
65
+ this.citationContentEditorContainer = null;
66
+ this.citationContentView = null;
67
+ this.citationContentEditorPosition = null;
68
+ this.selectionDebounce = null;
69
+ this.outlineTargetOptionsCacheDoc = null;
70
+ this.outlineTargetOptionsCache = [];
43
71
  }
44
72
  static { this.styles = css `${unsafeCSS(selectionEditorCss)}`; }
45
73
  connectedCallback() {
@@ -51,7 +79,12 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
51
79
  }
52
80
  disconnectedCallback() {
53
81
  super.disconnectedCallback();
82
+ this.destroyCitationContentView();
54
83
  this.detachEditorListeners();
84
+ if (this.selectionDebounce) {
85
+ clearTimeout(this.selectionDebounce);
86
+ this.selectionDebounce = null;
87
+ }
55
88
  this.themeUnsub?.();
56
89
  this.themeUnsub = undefined;
57
90
  }
@@ -60,40 +93,154 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
60
93
  if (changedProperties.has('editor') || changedProperties.has('for')) {
61
94
  this.resolveEditorReference();
62
95
  }
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);
96
+ if (changedProperties.has('elementInfo')) {
97
+ if (this.elementInfo?.type === 'math') {
98
+ const attrs = this.elementInfo.attrs;
99
+ this.mathDraft = {
100
+ tex: attrs?.tex ?? '',
101
+ style: attrs?.style === 'display' ? 'display' : 'inline',
102
+ label: attrs?.label ?? '',
103
+ };
104
+ this.scheduleMathPreview();
105
+ this.citationDraft = null;
106
+ this.crossReferenceDraft = null;
107
+ this.resetDraftAttrs();
108
+ }
109
+ else if (this.elementInfo?.type === 'citation') {
110
+ const attrs = this.elementInfo.attrs;
111
+ const rawSource = attrs?.source;
112
+ const sourceEncoded = typeof rawSource === 'string' ? rawSource : SourceField.toString(rawSource ?? []) ?? null;
113
+ const content = this.getCitationNodeTextContent();
114
+ this.citationDraft = {
115
+ content: content ?? '',
116
+ sourceEncoded,
117
+ };
118
+ this.crossReferenceDraft = null;
119
+ this.mathDraft = null;
120
+ this.mathPreviewResult = null;
121
+ this.resetDraftAttrs();
122
+ if (this.mathPreviewDebounce) {
123
+ clearTimeout(this.mathPreviewDebounce);
124
+ this.mathPreviewDebounce = null;
125
+ }
126
+ }
127
+ else if (this.elementInfo?.type === 'link') {
128
+ const attrs = this.elementInfo.attrs;
129
+ const href = typeof attrs?.href === 'string' ? attrs.href : '';
130
+ this.crossReferenceDraft = {
131
+ content: this.getLinkNodeTextContent() ?? '',
132
+ href,
133
+ };
134
+ this.mathDraft = null;
135
+ this.mathPreviewResult = null;
136
+ this.citationDraft = null;
137
+ this.resetDraftAttrs();
138
+ this.destroyCitationContentView();
139
+ if (this.mathPreviewDebounce) {
140
+ clearTimeout(this.mathPreviewDebounce);
141
+ this.mathPreviewDebounce = null;
142
+ }
143
+ }
144
+ else {
145
+ this.mathDraft = null;
146
+ this.mathPreviewResult = null;
147
+ this.citationDraft = null;
148
+ this.crossReferenceDraft = null;
149
+ this.resetDraftAttrs();
150
+ this.destroyCitationContentView();
151
+ if (this.mathPreviewDebounce) {
152
+ clearTimeout(this.mathPreviewDebounce);
153
+ this.mathPreviewDebounce = null;
154
+ }
155
+ }
156
+ }
157
+ if (changedProperties.has('elementInfo') ||
158
+ changedProperties.has('citationDraft')) {
159
+ if (this.citationSourceContainer &&
160
+ this.elementInfo?.type === 'citation' &&
161
+ this.citationDraft) {
162
+ const items = SourceField.fromString(this.citationDraft.sourceEncoded ?? '') ?? [];
163
+ const adapter = this.citationSourceAdapter ?? getCitationSourceAdapter();
164
+ const refs = this.resolvedEditor?.references ?? [];
165
+ const context = {
166
+ onSourceChange: (encoded) => {
167
+ if (this.citationDraft) {
168
+ this.citationDraft = { ...this.citationDraft, sourceEncoded: encoded };
169
+ }
170
+ },
171
+ getReferences: () => refs.map((r) => ({ id: r.id, rawCitation: r.rawCitation })),
172
+ };
173
+ if (adapter.update) {
174
+ adapter.update(this.citationSourceContainer, items, context);
175
+ }
82
176
  }
83
177
  }
84
178
  }
85
179
  render() {
86
180
  if (!this.elementInfo || !this.elementInfo.type) {
87
181
  return html `
88
- <div class="selection-editor-container">
182
+ <div class="selection-editor-container" @keydown=${this.handleGenericKeydown}>
89
183
  <p class="selection-editor-placeholder">
90
184
  No element selected. Click or select an element in the editor.
91
185
  </p>
92
186
  </div>
187
+ `;
188
+ }
189
+ if (this.elementInfo.type === 'math' && this.mathDraft) {
190
+ return this.renderMathSidebar();
191
+ }
192
+ if (this.elementInfo.type === 'citation' && this.citationDraft) {
193
+ return this.renderCitationSidebar();
194
+ }
195
+ if (this.elementInfo.type === 'link' && this.crossReferenceDraft) {
196
+ return this.renderCrossReferenceSidebar();
197
+ }
198
+ if (this.elementInfo.type === 'figure') {
199
+ return this.renderFigureSidebar();
200
+ }
201
+ const uiSchema = this.resolvedEditor?.getSelectionUiSchema?.();
202
+ const nodeFragment = uiSchema?.properties?.nodes?.[this.elementInfo.type];
203
+ const useSchemaDriven = nodeFragment &&
204
+ (this.elementInfo.type === 'heading' ||
205
+ this.elementInfo.type === 'figure');
206
+ if (useSchemaDriven && nodeFragment) {
207
+ const attrs = this.getEffectiveAttrs();
208
+ return html `
209
+ <div class="selection-editor-container">
210
+ ${this.renderSelectionControls()}
211
+ <div class="selection-editor-field">
212
+ <span class="selection-editor-label">Type:</span>
213
+ <code class="selection-editor-value">${this.elementInfo.type}</code>
214
+ </div>
215
+ ${this.elementInfo.id
216
+ ? html `
217
+ <div class="selection-editor-field">
218
+ <span class="selection-editor-label">ID:</span>
219
+ <code class="selection-editor-value">${this.elementInfo.id}</code>
220
+ </div>
221
+ `
222
+ : ''}
223
+ ${renderNodeFromUiSchema(nodeFragment, {
224
+ attrs,
225
+ onAttrChange: (attr, value) => this.setDraftAttr(attr, value),
226
+ getOptionsSource: (id) => {
227
+ if (id === 'references') {
228
+ const refs = this.resolvedEditor?.references ?? [];
229
+ return refs.map((r) => ({ id: r.id, label: r.rawCitation ?? r.id }));
230
+ }
231
+ if (id === 'outlineTargets') {
232
+ return this.getOutlineTargetOptions();
233
+ }
234
+ return [];
235
+ },
236
+ })}
237
+ ${this.renderDraftActions()}
238
+ </div>
93
239
  `;
94
240
  }
95
241
  return html `
96
- <div class="selection-editor-container">
242
+ <div class="selection-editor-container" @keydown=${this.handleGenericKeydown}>
243
+ ${this.renderSelectionControls()}
97
244
  <!-- Type display -->
98
245
  <div class="selection-editor-field">
99
246
  <span class="selection-editor-label">Type:</span>
@@ -110,42 +257,70 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
110
257
  `
111
258
  : ''}
112
259
 
113
- <!-- Attributes (always expanded) -->
260
+ <!-- Attributes -->
114
261
  ${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>
262
+ ? (() => {
263
+ const attrs = this.getEffectiveAttrs();
264
+ const filtered = Object.entries(attrs).filter(([key]) => key !== 'id' &&
265
+ key !== 'data' &&
266
+ !(this.elementInfo.type === 'citation' && key === 'source'));
267
+ if (this.elementInfo.type === 'heading' && filtered.length > 0) {
268
+ const levelEntry = filtered.find(([k]) => k === 'level');
269
+ const rest = filtered.filter(([k]) => k !== 'level');
270
+ return html `
271
+ ${levelEntry
272
+ ? html `
273
+ <div class="selection-editor-attrs">
274
+ ${this.renderAttributeField(levelEntry[0], levelEntry[1])}
137
275
  </div>
138
276
  `
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))}
277
+ : ''}
278
+ ${rest.length > 0
279
+ ? html `
280
+ <details class="selection-editor-details">
281
+ <summary class="selection-editor-details-summary">More attributes</summary>
282
+ <div class="selection-editor-details-body">
283
+ ${rest.map(([attrName, attrValue]) => this.renderAttributeField(attrName, attrValue))}
284
+ </div>
285
+ </details>
286
+ `
287
+ : ''}
288
+ `;
289
+ }
290
+ if (this.elementInfo.type === 'paragraph' && filtered.length > 0) {
291
+ const alignmentEntry = filtered.find(([k]) => k === 'text-align');
292
+ const rest = filtered.filter(([k]) => k !== 'text-align');
293
+ return html `
294
+ ${alignmentEntry
295
+ ? html `
296
+ <div class="selection-editor-attrs">
297
+ ${this.renderAttributeField(alignmentEntry[0], alignmentEntry[1])}
298
+ </div>
299
+ `
300
+ : ''}
301
+ ${rest.length > 0
302
+ ? html `
303
+ <details class="selection-editor-details">
304
+ <summary class="selection-editor-details-summary">More attributes</summary>
305
+ <div class="selection-editor-details-body">
306
+ ${rest.map(([attrName, attrValue]) => this.renderAttributeField(attrName, attrValue))}
307
+ </div>
308
+ </details>
309
+ `
310
+ : ''}
311
+ `;
312
+ }
313
+ return html `
314
+ <div class="selection-editor-field">
315
+ <span class="selection-editor-label">Attributes:</span>
316
+ <div class="selection-editor-attrs">
317
+ ${filtered.map(([attrName, attrValue]) => this.renderAttributeField(attrName, attrValue))}
318
+ </div>
145
319
  </div>
146
- </div>
147
- `
320
+ `;
321
+ })()
148
322
  : ''}
323
+ ${this.renderDraftActions()}
149
324
  </div>
150
325
  `;
151
326
  }
@@ -165,13 +340,13 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
165
340
  }
166
341
  this.selectionListener = (event) => {
167
342
  const selection = event.detail;
168
- this.updateElementInfo(selection);
343
+ this.scheduleSelectionUpdate(selection);
169
344
  };
170
345
  this.readyListener = () => {
171
346
  // Re-evaluate selection when editor becomes ready
172
347
  if (this.resolvedEditor?.editorView) {
173
348
  const selection = this.resolvedEditor.editorView.state.selection;
174
- this.updateElementInfo({
349
+ this.scheduleSelectionUpdate({
175
350
  anchor: selection.anchor,
176
351
  head: selection.head,
177
352
  });
@@ -182,7 +357,7 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
182
357
  // Initial evaluation
183
358
  if (next.editorView) {
184
359
  const selection = next.editorView.state.selection;
185
- this.updateElementInfo({
360
+ this.scheduleSelectionUpdate({
186
361
  anchor: selection.anchor,
187
362
  head: selection.head,
188
363
  });
@@ -220,6 +395,18 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
220
395
  this.readyListener = undefined;
221
396
  this.resolvedEditor = null;
222
397
  }
398
+ scheduleSelectionUpdate(selection) {
399
+ if (this.pinSelection) {
400
+ return;
401
+ }
402
+ if (this.selectionDebounce) {
403
+ clearTimeout(this.selectionDebounce);
404
+ }
405
+ this.selectionDebounce = setTimeout(() => {
406
+ this.selectionDebounce = null;
407
+ this.updateElementInfo(selection);
408
+ }, 100);
409
+ }
223
410
  /**
224
411
  * Extract element information from the current selection.
225
412
  */
@@ -229,32 +416,60 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
229
416
  this.elementInfo = null;
230
417
  return;
231
418
  }
419
+ const sel = view.state.selection;
420
+ if (sel instanceof NodeSelection && sel.node.type.name === 'math') {
421
+ const attrs = sel.node.attrs;
422
+ this.elementInfo = {
423
+ type: 'math',
424
+ id: attrs?.id ?? null,
425
+ attrs: { ...attrs },
426
+ position: sel.from,
427
+ };
428
+ return;
429
+ }
430
+ if (sel instanceof NodeSelection && sel.node.type.name === 'citation') {
431
+ const attrs = sel.node.attrs;
432
+ this.elementInfo = {
433
+ type: 'citation',
434
+ id: attrs?.id ?? null,
435
+ attrs: { ...attrs },
436
+ position: sel.from,
437
+ };
438
+ return;
439
+ }
440
+ if (sel instanceof NodeSelection && sel.node.type.name === 'figure') {
441
+ const attrs = sel.node.attrs;
442
+ this.elementInfo = {
443
+ type: 'figure',
444
+ id: attrs?.id ?? null,
445
+ attrs: { ...attrs },
446
+ position: sel.from,
447
+ };
448
+ return;
449
+ }
450
+ if (sel instanceof NodeSelection && sel.node.type.name === 'link') {
451
+ const attrs = sel.node.attrs;
452
+ this.elementInfo = {
453
+ type: 'link',
454
+ id: attrs?.id ?? null,
455
+ attrs: { ...attrs },
456
+ position: sel.from,
457
+ };
458
+ return;
459
+ }
232
460
  let elementInfo = null;
233
461
  let nodePosition = null;
234
462
  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,
245
- };
246
- }
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
463
+ const $pos = view.state.doc.resolve(anchor);
464
+ const citationType = view.state.schema.nodes.citation;
465
+ // Prefer citation (inline) when cursor is inside one
466
+ if (citationType) {
251
467
  for (let depth = $pos.depth; depth > 0; depth--) {
252
468
  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') {
469
+ if (node.type === citationType) {
255
470
  nodePosition = $pos.before(depth);
256
471
  elementInfo = {
257
- type: node.type.name,
472
+ type: 'citation',
258
473
  id: node.attrs?.id ?? null,
259
474
  attrs: node.attrs ?? null,
260
475
  position: nodePosition,
@@ -262,19 +477,217 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
262
477
  break;
263
478
  }
264
479
  }
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);
480
+ }
481
+ // Prefer figure when cursor is inside one (e.g. in caption)
482
+ if (!elementInfo) {
483
+ const figureType = view.state.schema.nodes.figure;
484
+ if (figureType) {
485
+ for (let depth = $pos.depth; depth > 0; depth--) {
486
+ const node = $pos.node(depth);
487
+ if (node.type === figureType) {
488
+ nodePosition = $pos.before(depth);
489
+ elementInfo = {
490
+ type: 'figure',
491
+ id: node.attrs?.id ?? null,
492
+ attrs: node.attrs ?? null,
493
+ position: nodePosition,
494
+ };
495
+ break;
496
+ }
497
+ }
498
+ }
499
+ }
500
+ // Prefer link (cross-reference) when cursor is inside one
501
+ if (!elementInfo) {
502
+ const linkType = view.state.schema.nodes.link;
503
+ if (linkType) {
504
+ for (let depth = $pos.depth; depth > 0; depth--) {
505
+ const node = $pos.node(depth);
506
+ if (node.type === linkType) {
507
+ nodePosition = $pos.before(depth);
508
+ elementInfo = {
509
+ type: 'link',
510
+ id: node.attrs?.id ?? null,
511
+ attrs: node.attrs ?? null,
512
+ position: nodePosition,
513
+ };
514
+ break;
515
+ }
516
+ }
517
+ }
518
+ }
519
+ if (!elementInfo) {
520
+ const nodeAtPos = view.state.doc.nodeAt(anchor);
521
+ if (nodeAtPos && !nodeAtPos.isText) {
522
+ nodePosition = anchor;
268
523
  elementInfo = {
269
- type: $pos.parent.type.name,
270
- id: $pos.parent.attrs?.id ?? null,
271
- attrs: $pos.parent.attrs ?? null,
524
+ type: nodeAtPos.type.name,
525
+ id: nodeAtPos.attrs?.id ?? null,
526
+ attrs: nodeAtPos.attrs ?? null,
272
527
  position: nodePosition,
273
528
  };
274
529
  }
530
+ else {
531
+ for (let depth = $pos.depth; depth > 0; depth--) {
532
+ const node = $pos.node(depth);
533
+ if (node.type !== view.state.schema.topNodeType && node.type.spec.group === 'block') {
534
+ nodePosition = $pos.before(depth);
535
+ elementInfo = {
536
+ type: node.type.name,
537
+ id: node.attrs?.id ?? null,
538
+ attrs: node.attrs ?? null,
539
+ position: nodePosition,
540
+ };
541
+ break;
542
+ }
543
+ }
544
+ if (!elementInfo && $pos.parent && $pos.parent.type !== view.state.schema.topNodeType) {
545
+ nodePosition = $pos.before($pos.depth);
546
+ elementInfo = {
547
+ type: $pos.parent.type.name,
548
+ id: $pos.parent.attrs?.id ?? null,
549
+ attrs: $pos.parent.attrs ?? null,
550
+ position: nodePosition,
551
+ };
552
+ }
553
+ }
275
554
  }
276
555
  this.elementInfo = elementInfo;
277
556
  }
557
+ /** Get the selected citation node's text content from the main document (for draft init). */
558
+ getCitationNodeTextContent() {
559
+ const node = this.getCitationNode();
560
+ return node?.textContent ?? '';
561
+ }
562
+ /** Get the selected citation node's content as a Fragment (same schema as citation allows). */
563
+ getCitationNodeContentFragment() {
564
+ const node = this.getCitationNode();
565
+ return node?.content ?? PMFragment.empty;
566
+ }
567
+ /** Get the citation node at the current selection, or null. */
568
+ getCitationNode() {
569
+ const view = this.resolvedEditor?.editorView;
570
+ if (!view || !this.elementInfo || this.elementInfo.type !== 'citation' || this.elementInfo.position == null) {
571
+ return null;
572
+ }
573
+ const doc = view.state.doc;
574
+ const pos = this.elementInfo.position;
575
+ const nodeAt = doc.nodeAt(pos);
576
+ const citationType = view.state.schema.nodes.citation;
577
+ if (nodeAt?.type === citationType) {
578
+ return nodeAt;
579
+ }
580
+ const $pos = doc.resolve(pos);
581
+ for (let d = $pos.depth; d > 0; d--) {
582
+ const node = $pos.node(d);
583
+ if (node.type === citationType) {
584
+ return node;
585
+ }
586
+ }
587
+ return null;
588
+ }
589
+ /** Get the selected cross-reference link node's text content from the main document. */
590
+ getLinkNodeTextContent() {
591
+ const node = this.getLinkNode();
592
+ return node?.textContent ?? '';
593
+ }
594
+ /** Get the selected cross-reference link node at current selection, or null. */
595
+ getLinkNode() {
596
+ const view = this.resolvedEditor?.editorView;
597
+ if (!view || !this.elementInfo || this.elementInfo.type !== 'link' || this.elementInfo.position == null) {
598
+ return null;
599
+ }
600
+ const doc = view.state.doc;
601
+ const pos = this.elementInfo.position;
602
+ const nodeAt = doc.nodeAt(pos);
603
+ const linkType = view.state.schema.nodes.link;
604
+ if (nodeAt?.type === linkType) {
605
+ return nodeAt;
606
+ }
607
+ const $pos = doc.resolve(pos);
608
+ for (let d = $pos.depth; d > 0; d--) {
609
+ const node = $pos.node(d);
610
+ if (node.type === linkType) {
611
+ return node;
612
+ }
613
+ }
614
+ return null;
615
+ }
616
+ /** Build outline target options for link href dropdown (headings and figures with id). */
617
+ getOutlineTargetOptions() {
618
+ const view = this.resolvedEditor?.editorView;
619
+ const pmDoc = view?.state?.doc ?? null;
620
+ if (!pmDoc) {
621
+ this.outlineTargetOptionsCacheDoc = null;
622
+ this.outlineTargetOptionsCache = [];
623
+ return [];
624
+ }
625
+ if (this.outlineTargetOptionsCacheDoc === pmDoc) {
626
+ return this.outlineTargetOptionsCache;
627
+ }
628
+ const outline = collectDocumentOutline(null, pmDoc);
629
+ const options = [];
630
+ for (const h of outline.headings) {
631
+ const id = h.id?.trim();
632
+ if (!id)
633
+ continue;
634
+ const level = h.level != null ? `H${h.level}` : 'Heading';
635
+ const text = h.text?.trim() || id;
636
+ options.push({ id: `#${id}`, label: `${level}: ${text}` });
637
+ }
638
+ for (const f of outline.figures) {
639
+ const id = f.id?.trim();
640
+ if (!id)
641
+ continue;
642
+ const text = f.text?.trim() || id;
643
+ options.push({ id: `#${id}`, label: `Figure: ${text}` });
644
+ }
645
+ this.outlineTargetOptionsCacheDoc = pmDoc;
646
+ this.outlineTargetOptionsCache = options;
647
+ return options;
648
+ }
649
+ destroyCitationContentView() {
650
+ if (this.citationContentView) {
651
+ this.citationContentView.destroy();
652
+ this.citationContentView = null;
653
+ }
654
+ this.citationContentEditorContainer = null;
655
+ this.citationContentEditorPosition = null;
656
+ }
657
+ mountCitationContentEditor(container) {
658
+ const view = this.resolvedEditor?.editorView;
659
+ if (!view || !this.elementInfo || this.elementInfo.type !== 'citation' || this.elementInfo.position == null) {
660
+ return;
661
+ }
662
+ const schema = view.state.schema;
663
+ const paragraph = schema.nodes.paragraph;
664
+ const docNode = schema.nodes.doc;
665
+ if (!paragraph || !docNode) {
666
+ return;
667
+ }
668
+ const pos = this.elementInfo.position;
669
+ if (this.citationContentView && this.citationContentEditorPosition === pos && this.citationContentEditorContainer === container) {
670
+ return;
671
+ }
672
+ this.destroyCitationContentView();
673
+ this.citationContentEditorContainer = container;
674
+ this.citationContentEditorPosition = pos;
675
+ const initialContent = this.getCitationNodeContentFragment();
676
+ const paragraphNode = paragraph.create(null, initialContent);
677
+ const stateDoc = docNode.create(null, [paragraphNode]);
678
+ const strong = schema.marks.strong;
679
+ const em = schema.marks.em;
680
+ const plugins = [
681
+ keymap(baseKeymap),
682
+ ...(strong ? [keymap({ 'Mod-b': toggleMark(strong) })] : []),
683
+ ...(em ? [keymap({ 'Mod-i': toggleMark(em) })] : []),
684
+ ];
685
+ const state = EditorState.create({ doc: stateDoc, schema, plugins });
686
+ this.citationContentView = new EditorView(container, {
687
+ state,
688
+ editable: () => true,
689
+ });
690
+ }
278
691
  /** Convenience getter mirroring `editor.getCommands()`. */
279
692
  getCommandRunner() {
280
693
  return this.resolvedEditor?.commands ?? null;
@@ -285,6 +698,27 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
285
698
  renderAttributeField(attrName, attrValue) {
286
699
  const decodedValue = this.decodeSpecialAttribute(attrName, attrValue);
287
700
  const renderedValue = decodedValue === null || decodedValue === undefined ? '' : String(decodedValue);
701
+ if (attrName === 'level' && this.elementInfo?.type === 'heading') {
702
+ const level = Math.min(6, Math.max(1, parseInt(renderedValue, 10) || 1));
703
+ return html `
704
+ <div class="selection-editor-attr-field">
705
+ <label
706
+ class="selection-editor-attr-label"
707
+ for="selection-editor-attr-${attrName}"
708
+ >
709
+ ${attrName}:
710
+ </label>
711
+ <select
712
+ id="selection-editor-attr-${attrName}"
713
+ class="selection-editor-input selection-editor-select"
714
+ .value=${String(level)}
715
+ @change=${(e) => this.handleDraftAttrChange(e, attrName)}
716
+ >
717
+ ${[1, 2, 3, 4, 5, 6].map((n) => html `<option value="${n}">${n}</option>`)}
718
+ </select>
719
+ </div>
720
+ `;
721
+ }
288
722
  return html `
289
723
  <div class="selection-editor-attr-field">
290
724
  <label
@@ -299,7 +733,7 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
299
733
  class="selection-editor-input"
300
734
  .value=${renderedValue}
301
735
  placeholder="No value"
302
- @change=${(e) => this.handleAttrChange(e, attrName)}
736
+ @change=${(e) => this.handleDraftAttrChange(e, attrName)}
303
737
  />
304
738
  </div>
305
739
  `;
@@ -325,25 +759,33 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
325
759
  }
326
760
  /**
327
761
  * Mount the citation source adapter UI when a citation node is selected.
762
+ * Uses draft context (onSourceChange) so changes apply only on Update.
328
763
  */
329
764
  mountCitationSourceAdapter(container) {
330
765
  if (!this.elementInfo || this.elementInfo.type !== 'citation') {
331
766
  return;
332
767
  }
333
- const rawSource = this.elementInfo.attrs?.source;
334
- const items = typeof rawSource === 'string'
335
- ? SourceField.fromString(rawSource)
336
- : Array.isArray(rawSource)
337
- ? rawSource
338
- : [];
768
+ const items = this.citationDraft != null
769
+ ? (SourceField.fromString(this.citationDraft.sourceEncoded ?? '') ?? [])
770
+ : (() => {
771
+ const rawSource = this.elementInfo.attrs?.source;
772
+ return typeof rawSource === 'string'
773
+ ? SourceField.fromString(rawSource)
774
+ : Array.isArray(rawSource)
775
+ ? rawSource
776
+ : [];
777
+ })();
339
778
  const adapter = this.citationSourceAdapter ?? getCitationSourceAdapter();
779
+ const refs = this.resolvedEditor?.references ?? [];
340
780
  const context = {
341
- applySource: (encoded) => {
342
- this.updateNodeAttribute('source', encoded);
343
- return true;
781
+ onSourceChange: (encoded) => {
782
+ if (this.citationDraft) {
783
+ this.citationDraft = { ...this.citationDraft, sourceEncoded: encoded };
784
+ }
344
785
  },
786
+ getReferences: () => refs.map((r) => ({ id: r.id, rawCitation: r.rawCitation })),
345
787
  };
346
- adapter.render(container, items, context);
788
+ adapter.render(container, items.length > 0 ? items : [{ id: '' }], context);
347
789
  }
348
790
  /**
349
791
  * Unmount the citation source adapter and clean up.
@@ -357,16 +799,16 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
357
799
  container.innerHTML = '';
358
800
  }
359
801
  }
360
- /**
361
- * Handle attribute field changes.
362
- */
363
- handleAttrChange(event, attrName) {
802
+ handleDraftAttrChange(event, attrName) {
364
803
  const input = event.target;
365
804
  const value = input.value.trim();
366
805
  let parsedValue = value;
367
806
  if (attrName === 'source') {
368
807
  parsedValue = this.encodeSourceField(value);
369
808
  }
809
+ else if (attrName === 'alt') {
810
+ parsedValue = value;
811
+ }
370
812
  else if (value !== '') {
371
813
  const numValue = parseFloat(value);
372
814
  if (!isNaN(numValue) && isFinite(numValue)) {
@@ -379,49 +821,521 @@ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends
379
821
  else {
380
822
  parsedValue = null;
381
823
  }
382
- this.updateNodeAttribute(attrName, parsedValue);
824
+ this.setDraftAttr(attrName, parsedValue);
383
825
  }
384
826
  /**
385
- * Update node attributes using the command API.
386
- * Preserves the 'data' attribute if it exists, even though it's not displayed.
827
+ * Render the figure sidebar: type, id, alt text.
387
828
  */
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');
829
+ renderFigureSidebar() {
830
+ const attrs = this.getEffectiveAttrs();
831
+ const alt = typeof attrs.alt === 'string' ? attrs.alt : '';
832
+ return html `
833
+ <div class="selection-editor-container selection-editor-figure" role="form" aria-label="Edit figure" @keydown=${this.handleGenericKeydown}>
834
+ ${this.renderSelectionControls()}
835
+ <div class="selection-editor-field">
836
+ <span class="selection-editor-label">Type:</span>
837
+ <code class="selection-editor-value">figure</code>
838
+ </div>
839
+ ${this.elementInfo?.id
840
+ ? html `
841
+ <div class="selection-editor-field">
842
+ <span class="selection-editor-label">ID:</span>
843
+ <code class="selection-editor-value">${this.elementInfo.id}</code>
844
+ </div>
845
+ `
846
+ : ''}
847
+ <div class="selection-editor-field">
848
+ <label class="selection-editor-label" for="figure-alt">Alt text</label>
849
+ <input
850
+ type="text"
851
+ id="figure-alt"
852
+ class="selection-editor-input"
853
+ .value=${alt}
854
+ placeholder="Describe the image for accessibility"
855
+ @change=${(e) => this.handleDraftAttrChange(e, 'alt')}
856
+ />
857
+ </div>
858
+ ${this.renderDraftActions()}
859
+ </div>
860
+ `;
861
+ }
862
+ /**
863
+ * Render the math equation sidebar: TeX source, style, label, preview, status, Apply/Cancel.
864
+ */
865
+ renderMathSidebar() {
866
+ const d = this.mathDraft;
867
+ const previewErr = this.mathPreviewResult?.error;
868
+ const previewWarnings = this.mathPreviewResult?.warnings ?? [];
869
+ return html `
870
+ <div
871
+ class="selection-editor-container selection-editor-math"
872
+ role="form"
873
+ aria-label="Edit equation"
874
+ @keydown=${this.handleMathKeydown}
875
+ >
876
+ ${this.renderSelectionControls()}
877
+ <div class="selection-editor-field">
878
+ <span class="selection-editor-label">Type:</span>
879
+ <code class="selection-editor-value">math</code>
880
+ </div>
881
+ <div class="selection-editor-field">
882
+ <label class="selection-editor-label" for="math-tex">TeX</label>
883
+ <textarea
884
+ id="math-tex"
885
+ class="selection-editor-textarea"
886
+ .value=${d.tex}
887
+ placeholder="e.g. x^2 + y^2 = z^2"
888
+ rows="4"
889
+ aria-describedby=${previewErr ? 'math-preview-error' : previewWarnings.length ? 'math-preview-warnings' : undefined}
890
+ @input=${(e) => {
891
+ const t = e.target.value;
892
+ this.mathDraft = { ...this.mathDraft, tex: t };
893
+ this.scheduleMathPreview();
894
+ }}
895
+ ></textarea>
896
+ </div>
897
+ <div class="selection-editor-field">
898
+ <label class="selection-editor-label" for="math-style">Style</label>
899
+ <select
900
+ id="math-style"
901
+ class="selection-editor-input"
902
+ .value=${d.style}
903
+ @change=${(e) => {
904
+ this.mathDraft = { ...this.mathDraft, style: e.target.value };
905
+ this.scheduleMathPreview();
906
+ }}
907
+ >
908
+ <option value="inline">Inline</option>
909
+ <option value="display">Display</option>
910
+ </select>
911
+ </div>
912
+ <div class="selection-editor-field">
913
+ <label class="selection-editor-label" for="math-label">Label (optional)</label>
914
+ <input
915
+ id="math-label"
916
+ type="text"
917
+ class="selection-editor-input"
918
+ .value=${d.label}
919
+ placeholder="eq:mylabel"
920
+ @input=${(e) => {
921
+ this.mathDraft = { ...this.mathDraft, label: e.target.value };
922
+ }}
923
+ />
924
+ </div>
925
+ <div class="selection-editor-field">
926
+ <span class="selection-editor-label">Preview</span>
927
+ <div
928
+ class="selection-editor-math-preview"
929
+ ${ref((el) => {
930
+ this.mathPreviewContainer = el ?? null;
931
+ if (el) {
932
+ this.runMathPreview();
933
+ }
934
+ })}
935
+ role="img"
936
+ aria-label="Equation preview"
937
+ ></div>
938
+ ${previewErr
939
+ ? html `<p id="math-preview-error" class="selection-editor-math-error" role="alert">${previewErr}</p>`
940
+ : ''}
941
+ ${previewWarnings.length
942
+ ? html `
943
+ <p id="math-preview-warnings" class="selection-editor-math-warnings" role="status">
944
+ ${previewWarnings.join(' ')}
945
+ </p>
946
+ `
947
+ : ''}
948
+ </div>
949
+ <div class="selection-editor-math-actions">
950
+ <button
951
+ type="button"
952
+ class="selection-editor-button selection-editor-button--primary"
953
+ @click=${this.applyMathDraft}
954
+ >
955
+ Apply
956
+ </button>
957
+ <span class="selection-editor-math-hint">Ctrl+Enter to apply, Escape to cancel</span>
958
+ </div>
959
+ <p class="selection-editor-math-docs">
960
+ <a
961
+ href="https://docs.mathjax.org/en/v4.0"
962
+ target="_blank"
963
+ rel="noopener noreferrer"
964
+ class="selection-editor-math-docs-link"
965
+ >MathJax 4 docs</a>
966
+ </p>
967
+ </div>
968
+ `;
969
+ }
970
+ scheduleMathPreview() {
971
+ if (this.mathPreviewDebounce) {
972
+ clearTimeout(this.mathPreviewDebounce);
973
+ }
974
+ this.mathPreviewDebounce = setTimeout(() => {
975
+ this.mathPreviewDebounce = null;
976
+ this.runMathPreview();
977
+ }, 300);
978
+ }
979
+ runMathPreview() {
980
+ if (!this.mathDraft || !this.mathPreviewContainer) {
981
+ return;
982
+ }
983
+ const tex = this.mathDraft.tex.trim() || '\\phantom{x}';
984
+ const display = this.mathDraft.style === 'display';
985
+ renderTexToSvg(tex, { display, metricsNode: this.mathPreviewContainer }).then((result) => {
986
+ if (!this.mathPreviewContainer) {
987
+ return;
988
+ }
989
+ this.mathPreviewContainer.innerHTML = '';
990
+ if (result.svg) {
991
+ this.mathPreviewContainer.appendChild(result.svg);
992
+ this.mathPreviewResult = result.warnings?.length ? { warnings: result.warnings } : null;
993
+ }
994
+ else {
995
+ this.mathPreviewResult = {
996
+ ...(result.error ? { error: result.error } : {}),
997
+ ...(result.warnings?.length ? { warnings: result.warnings } : {}),
998
+ };
999
+ if (result.unavailable && result.error) {
1000
+ const p = document.createElement('p');
1001
+ p.className = 'selection-editor-math-error';
1002
+ p.setAttribute('role', 'alert');
1003
+ p.textContent = result.error;
1004
+ this.mathPreviewContainer.appendChild(p);
1005
+ }
1006
+ }
1007
+ this.requestUpdate();
1008
+ });
1009
+ }
1010
+ applyMathDraft() {
1011
+ if (!this.elementInfo || this.elementInfo.type !== 'math' || this.elementInfo.position === null || !this.mathDraft) {
391
1012
  return;
392
1013
  }
393
1014
  const runner = this.getCommandRunner();
394
- if (!runner) {
395
- console.warn('[sciflow-selection-editor] Cannot update: no command runner');
1015
+ const setNodeAttrs = runner?.commands?.setNodeAttrs;
1016
+ if (!setNodeAttrs) {
396
1017
  return;
397
1018
  }
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,
1019
+ const ok = setNodeAttrs(this.elementInfo.position, {
1020
+ tex: this.mathDraft.tex,
1021
+ style: this.mathDraft.style,
1022
+ label: this.mathDraft.label || undefined,
1023
+ });
1024
+ if (ok && this.resolvedEditor?.editorView) {
1025
+ const sel = this.resolvedEditor.editorView.state.selection;
1026
+ this.updateElementInfo({ anchor: sel.anchor, head: sel.head });
1027
+ }
1028
+ }
1029
+ cancelMathDraft() {
1030
+ if (this.elementInfo?.type === 'math' && this.elementInfo.attrs) {
1031
+ const attrs = this.elementInfo.attrs;
1032
+ this.mathDraft = {
1033
+ tex: attrs.tex ?? '',
1034
+ style: attrs.style === 'display' ? 'display' : 'inline',
1035
+ label: attrs.label ?? '',
403
1036
  };
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;
1037
+ this.scheduleMathPreview();
1038
+ this.mathPreviewResult = null;
1039
+ }
1040
+ }
1041
+ handleMathKeydown(e) {
1042
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1043
+ e.preventDefault();
1044
+ this.applyMathDraft();
1045
+ }
1046
+ else if (e.key === 'Escape') {
1047
+ e.preventDefault();
1048
+ this.cancelMathDraft();
1049
+ }
1050
+ }
1051
+ /**
1052
+ * Render the citation sidebar: content editor, source fields, Update button.
1053
+ */
1054
+ renderCitationSidebar() {
1055
+ return html `
1056
+ <div
1057
+ class="selection-editor-container selection-editor-citation"
1058
+ role="form"
1059
+ aria-label="Edit citation"
1060
+ @keydown=${this.handleCitationKeydown}
1061
+ >
1062
+ ${this.renderSelectionControls()}
1063
+ <div class="selection-editor-field">
1064
+ <span class="selection-editor-label">Type:</span>
1065
+ <code class="selection-editor-value">citation</code>
1066
+ </div>
1067
+ <div class="selection-editor-field">
1068
+ <label class="selection-editor-label" for="citation-content">Raw citation text</label>
1069
+ <div
1070
+ id="citation-content"
1071
+ class="selection-editor-input selection-editor-citation-content selection-editor-citation-pm"
1072
+ role="textbox"
1073
+ aria-label="Raw citation text (supports bold and italic)"
1074
+ ${ref((el) => {
1075
+ if (el) {
1076
+ this.mountCitationContentEditor(el);
410
1077
  }
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
- });
1078
+ else {
1079
+ this.destroyCitationContentView();
418
1080
  }
419
- else if (!success) {
420
- console.warn('[sciflow-selection-editor] Failed to update node attribute');
1081
+ })}
1082
+ ></div>
1083
+ </div>
1084
+ <div class="selection-editor-attr-field">
1085
+ <span class="selection-editor-label">Semantic reference:</span>
1086
+ <div
1087
+ class="citation-source-adapter-root"
1088
+ ${ref((el) => {
1089
+ if (el) {
1090
+ this.citationSourceContainer = el;
1091
+ this.mountCitationSourceAdapter(el);
1092
+ }
1093
+ else if (this.citationSourceContainer) {
1094
+ this.unmountCitationSourceAdapter(this.citationSourceContainer);
1095
+ this.citationSourceContainer = null;
421
1096
  }
1097
+ })}
1098
+ ></div>
1099
+ </div>
1100
+ <div class="selection-editor-citation-actions">
1101
+ <button
1102
+ type="button"
1103
+ class="selection-editor-button selection-editor-button--primary"
1104
+ @click=${this.applyCitationDraft}
1105
+ >
1106
+ Update
1107
+ </button>
1108
+ <span class="selection-editor-citation-hint">Ctrl+Enter to apply</span>
1109
+ </div>
1110
+ </div>
1111
+ `;
1112
+ }
1113
+ applyCitationDraft() {
1114
+ if (!this.elementInfo ||
1115
+ this.elementInfo.type !== 'citation' ||
1116
+ this.elementInfo.position === null ||
1117
+ !this.citationDraft) {
1118
+ return;
1119
+ }
1120
+ const runner = this.getCommandRunner();
1121
+ const updateCitationNode = runner?.commands?.updateCitationNode;
1122
+ if (!updateCitationNode) {
1123
+ console.warn('[sciflow-selection-editor] updateCitationNode command not available');
1124
+ return;
1125
+ }
1126
+ const content = this.citationContentView?.state?.doc?.firstChild?.content ?? this.citationDraft.content;
1127
+ const ok = updateCitationNode(this.elementInfo.position, {
1128
+ attrs: { source: this.citationDraft.sourceEncoded },
1129
+ content,
1130
+ });
1131
+ if (ok && this.resolvedEditor?.editorView) {
1132
+ const sel = this.resolvedEditor.editorView.state.selection;
1133
+ this.updateElementInfo({ anchor: sel.anchor, head: sel.head });
422
1134
  }
423
- catch (error) {
424
- console.error('[sciflow-selection-editor] Failed to update node attribute:', error);
1135
+ }
1136
+ handleCitationKeydown(e) {
1137
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1138
+ e.preventDefault();
1139
+ this.applyCitationDraft();
1140
+ }
1141
+ }
1142
+ /**
1143
+ * Render the cross-reference sidebar: display text + target, Update button.
1144
+ */
1145
+ renderCrossReferenceSidebar() {
1146
+ const d = this.crossReferenceDraft;
1147
+ const options = this.getOutlineTargetOptions();
1148
+ const hasCurrentTarget = options.some((o) => o.id === d.href);
1149
+ return html `
1150
+ <div
1151
+ class="selection-editor-container selection-editor-citation"
1152
+ role="form"
1153
+ aria-label="Edit cross-reference"
1154
+ @keydown=${this.handleCrossReferenceKeydown}
1155
+ >
1156
+ ${this.renderSelectionControls()}
1157
+ <div class="selection-editor-field">
1158
+ <span class="selection-editor-label">Type:</span>
1159
+ <code class="selection-editor-value">cross-reference</code>
1160
+ </div>
1161
+ <div class="selection-editor-field">
1162
+ <label class="selection-editor-label" for="crossref-content">Display text</label>
1163
+ <input
1164
+ id="crossref-content"
1165
+ type="text"
1166
+ class="selection-editor-input"
1167
+ .value=${d.content}
1168
+ placeholder="e.g. Chapter 1, Figure 1, Abstract"
1169
+ @input=${(e) => {
1170
+ this.crossReferenceDraft = {
1171
+ ...this.crossReferenceDraft,
1172
+ content: e.target.value,
1173
+ };
1174
+ }}
1175
+ />
1176
+ </div>
1177
+ <div class="selection-editor-field">
1178
+ <label class="selection-editor-label" for="crossref-target">Target</label>
1179
+ <select
1180
+ id="crossref-target"
1181
+ class="selection-editor-input selection-editor-select"
1182
+ .value=${d.href}
1183
+ @change=${(e) => {
1184
+ this.crossReferenceDraft = {
1185
+ ...this.crossReferenceDraft,
1186
+ href: e.target.value,
1187
+ };
1188
+ }}
1189
+ >
1190
+ ${!hasCurrentTarget && d.href
1191
+ ? html `<option value="${d.href}">${d.href}</option>`
1192
+ : ''}
1193
+ ${options.map((option) => html `<option value="${option.id}">${option.label ?? option.id}</option>`)}
1194
+ </select>
1195
+ </div>
1196
+ <div class="selection-editor-citation-actions">
1197
+ <button
1198
+ type="button"
1199
+ class="selection-editor-button selection-editor-button--primary"
1200
+ @click=${this.applyCrossReferenceDraft}
1201
+ >
1202
+ Update
1203
+ </button>
1204
+ <span class="selection-editor-citation-hint">Ctrl+Enter to apply</span>
1205
+ </div>
1206
+ </div>
1207
+ `;
1208
+ }
1209
+ applyCrossReferenceDraft() {
1210
+ if (!this.elementInfo ||
1211
+ this.elementInfo.type !== 'link' ||
1212
+ this.elementInfo.position === null ||
1213
+ !this.crossReferenceDraft) {
1214
+ return;
1215
+ }
1216
+ const runner = this.getCommandRunner();
1217
+ const updateCrossReferenceNode = runner?.commands?.updateCrossReferenceNode;
1218
+ if (!updateCrossReferenceNode) {
1219
+ console.warn('[sciflow-selection-editor] updateCrossReferenceNode command not available');
1220
+ return;
1221
+ }
1222
+ const ok = updateCrossReferenceNode(this.elementInfo.position, {
1223
+ attrs: { href: this.crossReferenceDraft.href },
1224
+ content: this.crossReferenceDraft.content,
1225
+ });
1226
+ if (ok && this.resolvedEditor?.editorView) {
1227
+ const sel = this.resolvedEditor.editorView.state.selection;
1228
+ this.updateElementInfo({ anchor: sel.anchor, head: sel.head });
1229
+ }
1230
+ }
1231
+ handleCrossReferenceKeydown(e) {
1232
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1233
+ e.preventDefault();
1234
+ this.applyCrossReferenceDraft();
1235
+ }
1236
+ }
1237
+ resetDraftAttrs() {
1238
+ this.draftAttrs = null;
1239
+ this.draftDirty = false;
1240
+ }
1241
+ getEffectiveAttrs() {
1242
+ const base = (this.elementInfo?.attrs ?? {});
1243
+ if (!this.draftAttrs || Object.keys(this.draftAttrs).length === 0) {
1244
+ return base;
1245
+ }
1246
+ return { ...base, ...this.draftAttrs };
1247
+ }
1248
+ setDraftAttr(attrName, value) {
1249
+ const currentAttrs = (this.elementInfo?.attrs ?? {});
1250
+ const previous = currentAttrs[attrName] ?? null;
1251
+ const nextDraft = { ...(this.draftAttrs ?? {}) };
1252
+ if (previous === value) {
1253
+ delete nextDraft[attrName];
1254
+ }
1255
+ else {
1256
+ nextDraft[attrName] = value;
1257
+ }
1258
+ this.draftAttrs = Object.keys(nextDraft).length > 0 ? nextDraft : null;
1259
+ this.draftDirty = Boolean(this.draftAttrs && Object.keys(this.draftAttrs).length > 0);
1260
+ }
1261
+ applyDraftAttrs() {
1262
+ if (!this.draftAttrs || !this.draftDirty || !this.elementInfo || this.elementInfo.position === null) {
1263
+ return;
1264
+ }
1265
+ const runner = this.getCommandRunner();
1266
+ const setNodeAttrsCommand = runner?.commands?.setNodeAttrs;
1267
+ if (!setNodeAttrsCommand) {
1268
+ return;
1269
+ }
1270
+ const success = setNodeAttrsCommand(this.elementInfo.position, this.draftAttrs);
1271
+ if (success && this.resolvedEditor?.editorView) {
1272
+ const selection = this.resolvedEditor.editorView.state.selection;
1273
+ this.resetDraftAttrs();
1274
+ this.updateElementInfo({
1275
+ anchor: selection.anchor,
1276
+ head: selection.head,
1277
+ });
1278
+ }
1279
+ }
1280
+ revertDraftAttrs() {
1281
+ this.resetDraftAttrs();
1282
+ }
1283
+ renderDraftActions() {
1284
+ if (!this.draftDirty) {
1285
+ return null;
1286
+ }
1287
+ return html `
1288
+ <div class="selection-editor-actions">
1289
+ <button
1290
+ type="button"
1291
+ class="selection-editor-button selection-editor-button--primary"
1292
+ @click=${this.applyDraftAttrs}
1293
+ >
1294
+ Apply
1295
+ </button>
1296
+ <button
1297
+ type="button"
1298
+ class="selection-editor-button"
1299
+ @click=${this.revertDraftAttrs}
1300
+ >
1301
+ Revert
1302
+ </button>
1303
+ <span class="selection-editor-hint">Ctrl+Enter to apply, Escape to revert</span>
1304
+ </div>
1305
+ `;
1306
+ }
1307
+ renderSelectionControls() {
1308
+ return html `
1309
+ <div class="selection-editor-controls">
1310
+ <button
1311
+ type="button"
1312
+ class="selection-editor-button selection-editor-button--secondary"
1313
+ @click=${this.togglePinSelection}
1314
+ aria-pressed=${this.pinSelection ? 'true' : 'false'}
1315
+ >
1316
+ ${this.pinSelection ? 'Unpin selection' : 'Pin selection'}
1317
+ </button>
1318
+ </div>
1319
+ `;
1320
+ }
1321
+ togglePinSelection() {
1322
+ this.pinSelection = !this.pinSelection;
1323
+ if (!this.pinSelection && this.resolvedEditor?.editorView) {
1324
+ const selection = this.resolvedEditor.editorView.state.selection;
1325
+ this.scheduleSelectionUpdate({
1326
+ anchor: selection.anchor,
1327
+ head: selection.head,
1328
+ });
1329
+ }
1330
+ }
1331
+ handleGenericKeydown(e) {
1332
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1333
+ e.preventDefault();
1334
+ this.applyDraftAttrs();
1335
+ }
1336
+ else if (e.key === 'Escape') {
1337
+ e.preventDefault();
1338
+ this.revertDraftAttrs();
425
1339
  }
426
1340
  }
427
1341
  /**
@@ -461,6 +1375,27 @@ __decorate([
461
1375
  __decorate([
462
1376
  state()
463
1377
  ], SciFlowSelectionEditorElement.prototype, "elementInfo", void 0);
1378
+ __decorate([
1379
+ state()
1380
+ ], SciFlowSelectionEditorElement.prototype, "mathDraft", void 0);
1381
+ __decorate([
1382
+ state()
1383
+ ], SciFlowSelectionEditorElement.prototype, "mathPreviewResult", void 0);
1384
+ __decorate([
1385
+ state()
1386
+ ], SciFlowSelectionEditorElement.prototype, "citationDraft", void 0);
1387
+ __decorate([
1388
+ state()
1389
+ ], SciFlowSelectionEditorElement.prototype, "crossReferenceDraft", void 0);
1390
+ __decorate([
1391
+ state()
1392
+ ], SciFlowSelectionEditorElement.prototype, "draftAttrs", void 0);
1393
+ __decorate([
1394
+ state()
1395
+ ], SciFlowSelectionEditorElement.prototype, "draftDirty", void 0);
1396
+ __decorate([
1397
+ state()
1398
+ ], SciFlowSelectionEditorElement.prototype, "pinSelection", void 0);
464
1399
  SciFlowSelectionEditorElement = __decorate([
465
1400
  customElement('sciflow-selection-editor')
466
1401
  ], SciFlowSelectionEditorElement);