@sciflow/editor-start 0.0.1-beta

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.
@@ -0,0 +1,467 @@
1
+ /**
2
+ * @module selection-editor
3
+ *
4
+ * Developer overview:
5
+ * --------------------
6
+ * `SciFlowSelectionEditorElement` is a companion component for `sciflow-editor`
7
+ * that displays and allows editing of the currently selected element's attributes.
8
+ *
9
+ * It demonstrates how to:
10
+ * - Listen to editor selection changes
11
+ * - Extract node information from the document
12
+ * - Use the command API to update node attributes
13
+ * - Build reactive UI that stays in sync with editor state
14
+ */
15
+ import { __decorate } from "tslib";
16
+ import { css, html, LitElement, unsafeCSS } from 'lit';
17
+ import { customElement, property, state } from 'lit/decorators.js';
18
+ import { ref } from 'lit/directives/ref.js';
19
+ import { SourceField } from '@sciflow/editor-core';
20
+ import { getCitationSourceAdapter, } from './citation-source-adapter.js';
21
+ import selectionEditorCss from './selection-editor.css?inline';
22
+ import { applyThemeStylesToRoot, subscribeToSciFlowTheme } from './theme.js';
23
+ /**
24
+ * Custom element that displays and edits the selected element's attributes.
25
+ *
26
+ * Responsibilities:
27
+ * - Resolve the nearest `sciflow-editor` instance (or accept one via property)
28
+ * - Listen to selection changes and extract node information
29
+ * - Display node type, ID, and attributes
30
+ * - Allow editing of attributes through the command API
31
+ */
32
+ let SciFlowSelectionEditorElement = class SciFlowSelectionEditorElement extends LitElement {
33
+ constructor() {
34
+ super(...arguments);
35
+ /** Reference to the editor element. May be passed directly via property binding. */
36
+ this.editor = null;
37
+ /** Custom adapter for editing citation source. When unset, uses the global adapter. */
38
+ this.citationSourceAdapter = null;
39
+ this.elementInfo = null;
40
+ this.resolvedEditor = null;
41
+ this.themeStyleElements = [];
42
+ this.citationSourceContainer = null;
43
+ }
44
+ static { this.styles = css `${unsafeCSS(selectionEditorCss)}`; }
45
+ connectedCallback() {
46
+ super.connectedCallback();
47
+ this.themeUnsub = subscribeToSciFlowTheme((cssTexts) => {
48
+ this.themeStyleElements = applyThemeStylesToRoot(this.renderRoot, this.themeStyleElements, cssTexts);
49
+ });
50
+ this.resolveEditorReference();
51
+ }
52
+ disconnectedCallback() {
53
+ super.disconnectedCallback();
54
+ this.detachEditorListeners();
55
+ this.themeUnsub?.();
56
+ this.themeUnsub = undefined;
57
+ }
58
+ updated(changedProperties) {
59
+ super.updated(changedProperties);
60
+ if (changedProperties.has('editor') || changedProperties.has('for')) {
61
+ this.resolveEditorReference();
62
+ }
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);
82
+ }
83
+ }
84
+ }
85
+ render() {
86
+ if (!this.elementInfo || !this.elementInfo.type) {
87
+ return html `
88
+ <div class="selection-editor-container">
89
+ <p class="selection-editor-placeholder">
90
+ No element selected. Click or select an element in the editor.
91
+ </p>
92
+ </div>
93
+ `;
94
+ }
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>
101
+ </div>
102
+
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
+ : ''}
112
+
113
+ <!-- Attributes (always expanded) -->
114
+ ${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
+ `
148
+ : ''}
149
+ </div>
150
+ `;
151
+ }
152
+ /**
153
+ * Find the target editor element and attach listeners for selection changes.
154
+ */
155
+ resolveEditorReference() {
156
+ const previous = this.resolvedEditor;
157
+ const next = this.resolveEditor();
158
+ if (previous === next && this.selectionListener) {
159
+ return;
160
+ }
161
+ this.detachEditorListeners();
162
+ this.resolvedEditor = next;
163
+ if (!next) {
164
+ return;
165
+ }
166
+ this.selectionListener = (event) => {
167
+ const selection = event.detail;
168
+ this.updateElementInfo(selection);
169
+ };
170
+ this.readyListener = () => {
171
+ // Re-evaluate selection when editor becomes ready
172
+ if (this.resolvedEditor?.editorView) {
173
+ const selection = this.resolvedEditor.editorView.state.selection;
174
+ this.updateElementInfo({
175
+ anchor: selection.anchor,
176
+ head: selection.head,
177
+ });
178
+ }
179
+ };
180
+ next.addEventListener('editor-selection-change', this.selectionListener);
181
+ next.addEventListener('editor-ready', this.readyListener);
182
+ // Initial evaluation
183
+ if (next.editorView) {
184
+ const selection = next.editorView.state.selection;
185
+ this.updateElementInfo({
186
+ anchor: selection.anchor,
187
+ head: selection.head,
188
+ });
189
+ }
190
+ }
191
+ resolveEditor() {
192
+ if (this.editor) {
193
+ return this.editor;
194
+ }
195
+ // When using declarative binding, walk the DOM to locate the target editor.
196
+ if (this.for) {
197
+ const root = this.getRootNode();
198
+ const candidate = root.getElementById(this.for);
199
+ if (candidate) {
200
+ return candidate;
201
+ }
202
+ }
203
+ const nearest = this.closest('sciflow-editor');
204
+ if (nearest) {
205
+ return nearest;
206
+ }
207
+ return null;
208
+ }
209
+ /** Clean up listeners attached to the last editor instance. */
210
+ detachEditorListeners() {
211
+ if (this.resolvedEditor) {
212
+ if (this.selectionListener) {
213
+ this.resolvedEditor.removeEventListener('editor-selection-change', this.selectionListener);
214
+ }
215
+ if (this.readyListener) {
216
+ this.resolvedEditor.removeEventListener('editor-ready', this.readyListener);
217
+ }
218
+ }
219
+ this.selectionListener = undefined;
220
+ this.readyListener = undefined;
221
+ this.resolvedEditor = null;
222
+ }
223
+ /**
224
+ * Extract element information from the current selection.
225
+ */
226
+ updateElementInfo(selection) {
227
+ const view = this.resolvedEditor?.editorView;
228
+ if (!view) {
229
+ this.elementInfo = null;
230
+ return;
231
+ }
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,
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
251
+ for (let depth = $pos.depth; depth > 0; depth--) {
252
+ 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') {
255
+ nodePosition = $pos.before(depth);
256
+ elementInfo = {
257
+ type: node.type.name,
258
+ id: node.attrs?.id ?? null,
259
+ attrs: node.attrs ?? null,
260
+ position: nodePosition,
261
+ };
262
+ break;
263
+ }
264
+ }
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);
268
+ elementInfo = {
269
+ type: $pos.parent.type.name,
270
+ id: $pos.parent.attrs?.id ?? null,
271
+ attrs: $pos.parent.attrs ?? null,
272
+ position: nodePosition,
273
+ };
274
+ }
275
+ }
276
+ this.elementInfo = elementInfo;
277
+ }
278
+ /** Convenience getter mirroring `editor.getCommands()`. */
279
+ getCommandRunner() {
280
+ return this.resolvedEditor?.commands ?? null;
281
+ }
282
+ /**
283
+ * Render a single attribute field, decoding known encoded values for readability.
284
+ */
285
+ renderAttributeField(attrName, attrValue) {
286
+ const decodedValue = this.decodeSpecialAttribute(attrName, attrValue);
287
+ const renderedValue = decodedValue === null || decodedValue === undefined ? '' : String(decodedValue);
288
+ return html `
289
+ <div class="selection-editor-attr-field">
290
+ <label
291
+ class="selection-editor-attr-label"
292
+ for="selection-editor-attr-${attrName}"
293
+ >
294
+ ${attrName}:
295
+ </label>
296
+ <input
297
+ type="text"
298
+ id="selection-editor-attr-${attrName}"
299
+ class="selection-editor-input"
300
+ .value=${renderedValue}
301
+ placeholder="No value"
302
+ @change=${(e) => this.handleAttrChange(e, attrName)}
303
+ />
304
+ </div>
305
+ `;
306
+ }
307
+ /**
308
+ * Decode known encoded attributes for more readable display/editing.
309
+ */
310
+ decodeSpecialAttribute(attrName, value) {
311
+ if (attrName === 'source') {
312
+ try {
313
+ const items = typeof value === 'string'
314
+ ? SourceField.fromString(value)
315
+ : Array.isArray(value)
316
+ ? value
317
+ : [];
318
+ return items.length > 0 ? JSON.stringify(items) : '';
319
+ }
320
+ catch {
321
+ return value;
322
+ }
323
+ }
324
+ return value;
325
+ }
326
+ /**
327
+ * Mount the citation source adapter UI when a citation node is selected.
328
+ */
329
+ mountCitationSourceAdapter(container) {
330
+ if (!this.elementInfo || this.elementInfo.type !== 'citation') {
331
+ return;
332
+ }
333
+ const rawSource = this.elementInfo.attrs?.source;
334
+ const items = typeof rawSource === 'string'
335
+ ? SourceField.fromString(rawSource)
336
+ : Array.isArray(rawSource)
337
+ ? rawSource
338
+ : [];
339
+ const adapter = this.citationSourceAdapter ?? getCitationSourceAdapter();
340
+ const context = {
341
+ applySource: (encoded) => {
342
+ this.updateNodeAttribute('source', encoded);
343
+ return true;
344
+ },
345
+ };
346
+ adapter.render(container, items, context);
347
+ }
348
+ /**
349
+ * Unmount the citation source adapter and clean up.
350
+ */
351
+ unmountCitationSourceAdapter(container) {
352
+ const adapter = this.citationSourceAdapter ?? getCitationSourceAdapter();
353
+ if (adapter.destroy) {
354
+ adapter.destroy(container);
355
+ }
356
+ else {
357
+ container.innerHTML = '';
358
+ }
359
+ }
360
+ /**
361
+ * Handle attribute field changes.
362
+ */
363
+ handleAttrChange(event, attrName) {
364
+ const input = event.target;
365
+ const value = input.value.trim();
366
+ let parsedValue = value;
367
+ if (attrName === 'source') {
368
+ parsedValue = this.encodeSourceField(value);
369
+ }
370
+ else if (value !== '') {
371
+ const numValue = parseFloat(value);
372
+ if (!isNaN(numValue) && isFinite(numValue)) {
373
+ parsedValue = numValue;
374
+ if (Number.isInteger(parsedValue)) {
375
+ parsedValue = parseInt(value, 10);
376
+ }
377
+ }
378
+ }
379
+ else {
380
+ parsedValue = null;
381
+ }
382
+ this.updateNodeAttribute(attrName, parsedValue);
383
+ }
384
+ /**
385
+ * Update node attributes using the command API.
386
+ * Preserves the 'data' attribute if it exists, even though it's not displayed.
387
+ */
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');
391
+ return;
392
+ }
393
+ const runner = this.getCommandRunner();
394
+ if (!runner) {
395
+ console.warn('[sciflow-selection-editor] Cannot update: no command runner');
396
+ return;
397
+ }
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,
403
+ };
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;
410
+ }
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
+ });
418
+ }
419
+ else if (!success) {
420
+ console.warn('[sciflow-selection-editor] Failed to update node attribute');
421
+ }
422
+ }
423
+ catch (error) {
424
+ console.error('[sciflow-selection-editor] Failed to update node attribute:', error);
425
+ }
426
+ }
427
+ /**
428
+ * Encode a source string/JSON into the URI-encoded source field format.
429
+ */
430
+ encodeSourceField(raw) {
431
+ if (!raw) {
432
+ return null;
433
+ }
434
+ try {
435
+ const parsed = JSON.parse(raw);
436
+ if (Array.isArray(parsed)) {
437
+ return SourceField.toString(parsed) ?? null;
438
+ }
439
+ }
440
+ catch {
441
+ // Not JSON; fall through.
442
+ }
443
+ try {
444
+ const normalized = SourceField.fromString(raw);
445
+ return SourceField.toString(normalized);
446
+ }
447
+ catch {
448
+ return raw;
449
+ }
450
+ }
451
+ };
452
+ __decorate([
453
+ property({ attribute: false })
454
+ ], SciFlowSelectionEditorElement.prototype, "editor", void 0);
455
+ __decorate([
456
+ property({ type: String, attribute: 'for' })
457
+ ], SciFlowSelectionEditorElement.prototype, "for", void 0);
458
+ __decorate([
459
+ property({ attribute: false })
460
+ ], SciFlowSelectionEditorElement.prototype, "citationSourceAdapter", void 0);
461
+ __decorate([
462
+ state()
463
+ ], SciFlowSelectionEditorElement.prototype, "elementInfo", void 0);
464
+ SciFlowSelectionEditorElement = __decorate([
465
+ customElement('sciflow-selection-editor')
466
+ ], SciFlowSelectionEditorElement);
467
+ export { SciFlowSelectionEditorElement };
@@ -0,0 +1,8 @@
1
+ export type ThemeStyleInput = string | string[] | CSSStyleSheet | CSSStyleSheet[] | null;
2
+ type ThemeSubscriber = (cssTexts: string[]) => void;
3
+ export declare const setSciFlowThemeStyles: (styles: ThemeStyleInput) => void;
4
+ export declare const getSciFlowThemeStyles: () => string[];
5
+ export declare const subscribeToSciFlowTheme: (callback: ThemeSubscriber) => (() => void);
6
+ export declare const applyThemeStylesToRoot: (root: ShadowRoot | HTMLElement | undefined, existing: HTMLStyleElement[], cssTexts: string[]) => HTMLStyleElement[];
7
+ export {};
8
+ //# sourceMappingURL=theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/lib/theme.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,EAAE,GAAG,aAAa,GAAG,aAAa,EAAE,GAAG,IAAI,CAAC;AAEzF,KAAK,eAAe,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;AAKpD,eAAO,MAAM,qBAAqB,GAAI,QAAQ,eAAe,KAAG,IAG/D,CAAC;AAEF,eAAO,MAAM,qBAAqB,QAAO,MAAM,EAA0B,CAAC;AAE1E,eAAO,MAAM,uBAAuB,GAAI,UAAU,eAAe,KAAG,CAAC,MAAM,IAAI,CAM9E,CAAC;AAEF,eAAO,MAAM,sBAAsB,GACjC,MAAM,UAAU,GAAG,WAAW,GAAG,SAAS,EAC1C,UAAU,gBAAgB,EAAE,EAC5B,UAAU,MAAM,EAAE,KACjB,gBAAgB,EAyBlB,CAAC"}
@@ -0,0 +1,72 @@
1
+ const subscribers = new Set();
2
+ let currentCssTexts = [];
3
+ export const setSciFlowThemeStyles = (styles) => {
4
+ currentCssTexts = normalizeThemeStyles(styles);
5
+ subscribers.forEach((callback) => callback([...currentCssTexts]));
6
+ };
7
+ export const getSciFlowThemeStyles = () => [...currentCssTexts];
8
+ export const subscribeToSciFlowTheme = (callback) => {
9
+ subscribers.add(callback);
10
+ callback([...currentCssTexts]);
11
+ return () => {
12
+ subscribers.delete(callback);
13
+ };
14
+ };
15
+ export const applyThemeStylesToRoot = (root, existing, cssTexts) => {
16
+ if (!root || typeof root.appendChild !== 'function') {
17
+ return existing;
18
+ }
19
+ for (const styleEl of existing) {
20
+ if (styleEl.parentNode === root) {
21
+ root.removeChild(styleEl);
22
+ }
23
+ }
24
+ if (cssTexts.length === 0) {
25
+ return [];
26
+ }
27
+ const next = [];
28
+ for (const cssText of cssTexts) {
29
+ const styleEl = document.createElement('style');
30
+ styleEl.setAttribute('data-sciflow-theme', 'true');
31
+ styleEl.textContent = cssText;
32
+ root.appendChild(styleEl);
33
+ next.push(styleEl);
34
+ }
35
+ return next;
36
+ };
37
+ const normalizeThemeStyles = (styles) => {
38
+ if (!styles) {
39
+ return [];
40
+ }
41
+ const list = Array.isArray(styles) ? styles : [styles];
42
+ const cssTexts = [];
43
+ for (const entry of list) {
44
+ if (!entry) {
45
+ continue;
46
+ }
47
+ if (typeof entry === 'string') {
48
+ const trimmed = entry.trim();
49
+ if (trimmed) {
50
+ cssTexts.push(trimmed);
51
+ }
52
+ continue;
53
+ }
54
+ if (typeof CSSStyleSheet !== 'undefined' && entry instanceof CSSStyleSheet) {
55
+ const serialized = extractCssFromSheet(entry);
56
+ if (serialized) {
57
+ cssTexts.push(serialized);
58
+ }
59
+ }
60
+ }
61
+ return cssTexts;
62
+ };
63
+ const extractCssFromSheet = (sheet) => {
64
+ try {
65
+ const rules = Array.from(sheet.cssRules ?? []);
66
+ const cssText = rules.map((rule) => rule.cssText).join('\n');
67
+ return cssText.trim() ? cssText : null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ };