@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.
- package/README.md +130 -0
- package/dist/bundle/sciflow-editor.css +1 -0
- package/dist/bundle/sciflow-editor.js +18739 -0
- package/dist/demo/demo.js +56 -0
- package/dist/demo/index.html +169 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/lib/citation-drop.d.ts +25 -0
- package/dist/lib/citation-drop.d.ts.map +1 -0
- package/dist/lib/citation-drop.js +53 -0
- package/dist/lib/citation-source-adapter.d.ts +56 -0
- package/dist/lib/citation-source-adapter.d.ts.map +1 -0
- package/dist/lib/citation-source-adapter.js +126 -0
- package/dist/lib/editor-element.d.ts +256 -0
- package/dist/lib/editor-element.d.ts.map +1 -0
- package/dist/lib/editor-element.js +681 -0
- package/dist/lib/format-bar.d.ts +98 -0
- package/dist/lib/format-bar.d.ts.map +1 -0
- package/dist/lib/format-bar.js +401 -0
- package/dist/lib/outline.d.ts +94 -0
- package/dist/lib/outline.d.ts.map +1 -0
- package/dist/lib/outline.js +491 -0
- package/dist/lib/reference-list.d.ts +58 -0
- package/dist/lib/reference-list.d.ts.map +1 -0
- package/dist/lib/reference-list.js +160 -0
- package/dist/lib/selection-editor.d.ts +94 -0
- package/dist/lib/selection-editor.d.ts.map +1 -0
- package/dist/lib/selection-editor.js +467 -0
- package/dist/lib/theme.d.ts +8 -0
- package/dist/lib/theme.d.ts.map +1 -0
- package/dist/lib/theme.js +72 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +104 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module editor-element
|
|
3
|
+
*
|
|
4
|
+
* Developer overview:
|
|
5
|
+
* --------------------
|
|
6
|
+
* `SciFlowEditorElement` is the reference web component that embeds the
|
|
7
|
+
* SciFlow editor runtime. The implementation illustrates how to:
|
|
8
|
+
*
|
|
9
|
+
* - bootstrap `@sciflow/editor-core` inside an arbitrary host UI
|
|
10
|
+
* - bridge external state (props/attributes) into the ProseMirror document
|
|
11
|
+
* - expose the command runner and view handles for surrounding widgets
|
|
12
|
+
* - surface lifecycle events so other controls can observe the editor
|
|
13
|
+
*
|
|
14
|
+
* When porting the editor to another framework (React, Vue, Svelte, …) follow
|
|
15
|
+
* the same high-level flow:
|
|
16
|
+
*
|
|
17
|
+
* 1. Create an `Editor` instance with your feature set.
|
|
18
|
+
* 2. Mount it into a DOM container supplied by your framework.
|
|
19
|
+
* 3. Subscribe to `onDoc`, `onSelection`, and other hooks you care about.
|
|
20
|
+
* 4. Re-dispatch the updates (events, signals, stores).
|
|
21
|
+
* 5. Dispose of everything when the host view unmounts.
|
|
22
|
+
*
|
|
23
|
+
* The rest of this file is heavily documented to map those steps to concrete
|
|
24
|
+
* code. Treat it as a cookbook for your own integration.
|
|
25
|
+
*/
|
|
26
|
+
import { __decorate } from "tslib";
|
|
27
|
+
import { css, html, unsafeCSS, LitElement } from 'lit';
|
|
28
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
29
|
+
import { Editor, SourceField, citationFeature, figureFeature, headingFeature, markFormattingFeature, tableFeature, } from '@sciflow/editor-core';
|
|
30
|
+
import { manuscript as manuscriptSchema } from '@sciflow/schema-prosemirror';
|
|
31
|
+
import editorStyles from './editor-element.css?inline';
|
|
32
|
+
import { applyThemeStylesToRoot, subscribeToSciFlowTheme } from './theme.js';
|
|
33
|
+
/**
|
|
34
|
+
* Custom element that owns a SciFlow editor instance.
|
|
35
|
+
*
|
|
36
|
+
* Responsibilities:
|
|
37
|
+
* - translate properties (`doc`, `initialContent`) into an editor snapshot
|
|
38
|
+
* - manage the underlying ProseMirror view lifecycle
|
|
39
|
+
* - publish high-level change events for host applications
|
|
40
|
+
* - expose convenience getters (`commands`, `editorView`) for toolbars
|
|
41
|
+
*
|
|
42
|
+
* The element intentionally keeps all side effects inside class methods so it
|
|
43
|
+
* can be reproduced in other UI frameworks with the same sequencing.
|
|
44
|
+
*/
|
|
45
|
+
let SciFlowEditorElement = class SciFlowEditorElement extends LitElement {
|
|
46
|
+
constructor() {
|
|
47
|
+
super(...arguments);
|
|
48
|
+
/**
|
|
49
|
+
* Initial ProseMirror document to render.
|
|
50
|
+
* Provide a `@sciflow/schema-prosemirror` node instance.
|
|
51
|
+
*/
|
|
52
|
+
this.initialContent = null;
|
|
53
|
+
/**
|
|
54
|
+
* JSON representation of a document to render.
|
|
55
|
+
* Provide a `SciFlowDocJSON` value, typically sourced from persistence.
|
|
56
|
+
*/
|
|
57
|
+
this.doc = null;
|
|
58
|
+
/**
|
|
59
|
+
* Optional list of editor features to load. When not provided, defaults to
|
|
60
|
+
* the bundled demo feature set (figures, citations, mark formatting, headings).
|
|
61
|
+
*/
|
|
62
|
+
this.features = null;
|
|
63
|
+
/**
|
|
64
|
+
* Optional CSS injected into the shadow root after the built-in styles.
|
|
65
|
+
* Can be set declaratively via property binding or imperatively via setShadowStyles().
|
|
66
|
+
*
|
|
67
|
+
* Note: Direct property assignment applies styles asynchronously via Lit's lifecycle.
|
|
68
|
+
* For immediate application (e.g., dynamic decorations), use setShadowStyles() instead.
|
|
69
|
+
*/
|
|
70
|
+
this._shadowStyles = null;
|
|
71
|
+
this._shadowStylesAppliedExternally = false;
|
|
72
|
+
/** Heading text rendered in the introductory document. */
|
|
73
|
+
this.headingText = 'Moby-Dick: A Study in Obsession';
|
|
74
|
+
/** Paragraph text used when no initial content is provided. */
|
|
75
|
+
this.placeholder = 'Introduce Ishmael and the enigmatic Captain Ahab…';
|
|
76
|
+
this.view = null;
|
|
77
|
+
this.pendingOps = [];
|
|
78
|
+
this.pendingOpsFromTransform = false;
|
|
79
|
+
this.suppressFeatureUpdate = false;
|
|
80
|
+
this.deferInitializationForDoc = false;
|
|
81
|
+
this.shadowStyleElements = [];
|
|
82
|
+
this.themeStyleElements = [];
|
|
83
|
+
}
|
|
84
|
+
get shadowStyles() {
|
|
85
|
+
return this._shadowStyles;
|
|
86
|
+
}
|
|
87
|
+
set shadowStyles(styles) {
|
|
88
|
+
const oldValue = this._shadowStyles;
|
|
89
|
+
this._shadowStyles = styles;
|
|
90
|
+
this._shadowStylesAppliedExternally = false; // Mark as Lit-managed
|
|
91
|
+
this.requestUpdate('shadowStyles', oldValue);
|
|
92
|
+
}
|
|
93
|
+
/** Access the underlying ProseMirror view instance. */
|
|
94
|
+
get editorView() {
|
|
95
|
+
return this.view;
|
|
96
|
+
}
|
|
97
|
+
/** Access SciFlow command helpers bound to the current editor view. */
|
|
98
|
+
get commands() {
|
|
99
|
+
return this.editor?.getCommands() ?? null;
|
|
100
|
+
}
|
|
101
|
+
/** Get read-only access to the document structure for traversal. */
|
|
102
|
+
get document() {
|
|
103
|
+
return this.editor?.getDocument() ?? null;
|
|
104
|
+
}
|
|
105
|
+
/** Get position utilities for coordinate mapping. */
|
|
106
|
+
get positions() {
|
|
107
|
+
return {
|
|
108
|
+
coordsAtPos: (pos) => this.editor?.coordsAtPos(pos) ?? null,
|
|
109
|
+
resolve: (pos) => this.editor?.resolvePosition(pos) ?? null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Get plugin management utilities. */
|
|
113
|
+
get plugins() {
|
|
114
|
+
return {
|
|
115
|
+
getState: (key) => this.editor?.getPluginState(key) ?? null,
|
|
116
|
+
dispatchMeta: (key, meta) => this.editor?.dispatchPluginMeta(key, meta) ?? false,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Get editor DOM utilities. */
|
|
120
|
+
get dom() {
|
|
121
|
+
return {
|
|
122
|
+
getBoundingRect: () => this.editor?.getBoundingRect() ?? null,
|
|
123
|
+
dispatchEvent: (event) => this.editor?.dispatchEvent(event) ?? false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
static { this.styles = css `${unsafeCSS(editorStyles)}`; }
|
|
127
|
+
render() {
|
|
128
|
+
return html `<div class="editor" part="editor"></div>`;
|
|
129
|
+
}
|
|
130
|
+
/** Lit lifecycle hook: start the editor once the DOM container exists. */
|
|
131
|
+
async firstUpdated() {
|
|
132
|
+
this.applyShadowStyles();
|
|
133
|
+
if (this.shouldDeferInitialization()) {
|
|
134
|
+
this.deferInitializationForDoc = true;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await this.initializeView();
|
|
138
|
+
}
|
|
139
|
+
connectedCallback() {
|
|
140
|
+
super.connectedCallback();
|
|
141
|
+
this.themeUnsub = subscribeToSciFlowTheme((cssTexts) => {
|
|
142
|
+
this.themeStyleElements = applyThemeStylesToRoot(this.renderRoot, this.themeStyleElements, cssTexts);
|
|
143
|
+
this.applyShadowStyles();
|
|
144
|
+
});
|
|
145
|
+
queueMicrotask(() => {
|
|
146
|
+
this.debugTestLog('connected microtask', { hasEditor: !!this.editor });
|
|
147
|
+
if (this.editor) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const initialDoc = this.resolveInitialDoc();
|
|
152
|
+
this.dispatchEditorChange(initialDoc.toJSON(), [], []);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Ignore errors during eager ready dispatch; initialization will retry in firstUpdated.
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* React to property changes coming from the host application.
|
|
161
|
+
* When `doc` changes we push the new snapshot into the running editor.
|
|
162
|
+
*/
|
|
163
|
+
updated(changedProperties) {
|
|
164
|
+
super.updated(changedProperties);
|
|
165
|
+
if (this.deferInitializationForDoc && this.doc !== null) {
|
|
166
|
+
this.deferInitializationForDoc = false;
|
|
167
|
+
void this.initializeView();
|
|
168
|
+
}
|
|
169
|
+
if (!this.suppressFeatureUpdate && changedProperties.has('features') && this.editor) {
|
|
170
|
+
void this.reinitializeEditor();
|
|
171
|
+
}
|
|
172
|
+
// Only apply shadowStyles if changed via property (not via setShadowStyles method)
|
|
173
|
+
if (changedProperties.has('shadowStyles') && !this._shadowStylesAppliedExternally) {
|
|
174
|
+
this.applyShadowStyles();
|
|
175
|
+
}
|
|
176
|
+
// Reset flag for next update
|
|
177
|
+
if (changedProperties.has('shadowStyles')) {
|
|
178
|
+
this._shadowStylesAppliedExternally = false;
|
|
179
|
+
}
|
|
180
|
+
if (changedProperties.has('doc')) {
|
|
181
|
+
this.applyDocUpdate();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Cleanly dispose the editor when the element leaves the DOM.
|
|
186
|
+
* Other frameworks should execute the same logic in their unmount hook.
|
|
187
|
+
*/
|
|
188
|
+
disconnectedCallback() {
|
|
189
|
+
super.disconnectedCallback();
|
|
190
|
+
this.themeUnsub?.();
|
|
191
|
+
this.themeUnsub = undefined;
|
|
192
|
+
this.destroyEditor();
|
|
193
|
+
this.view = null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Public helper to update the feature list and reconfigure the editor.
|
|
197
|
+
* Consumers (including the demo) can call this to immediately apply changes.
|
|
198
|
+
*/
|
|
199
|
+
async configureFeatures(features) {
|
|
200
|
+
this.suppressFeatureUpdate = true;
|
|
201
|
+
this.features = [...features];
|
|
202
|
+
if (!this.editor) {
|
|
203
|
+
this.suppressFeatureUpdate = false;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
await this.reinitializeEditor();
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
this.suppressFeatureUpdate = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Bootstrap the SciFlow editor inside this component's shadow DOM.
|
|
215
|
+
* Resolves a mount point, creates the editor instance, and wires listeners.
|
|
216
|
+
*/
|
|
217
|
+
getDefaultFeatures() {
|
|
218
|
+
return [citationFeature, markFormattingFeature, headingFeature, figureFeature, tableFeature];
|
|
219
|
+
}
|
|
220
|
+
getActiveFeatures() {
|
|
221
|
+
return this.features ?? this.getDefaultFeatures();
|
|
222
|
+
}
|
|
223
|
+
shouldDeferInitialization() {
|
|
224
|
+
return this.doc === null && this.initialContent === null;
|
|
225
|
+
}
|
|
226
|
+
async initializeView() {
|
|
227
|
+
this.debugTestLog('initializeView invoked');
|
|
228
|
+
if (this.view) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const mountPoint = this.renderRoot.querySelector('.editor');
|
|
232
|
+
if (!mountPoint) {
|
|
233
|
+
this.debugTestLog('initializeView: mount point missing');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const initialDoc = this.resolveInitialDoc();
|
|
237
|
+
const docInput = this.doc;
|
|
238
|
+
const initialFiles = this.isExternalDocUpdate(docInput) && Array.isArray(docInput.files) ? docInput.files : [];
|
|
239
|
+
const initialReferences = this.isExternalDocUpdate(docInput) && Array.isArray(docInput.references) ? docInput.references : [];
|
|
240
|
+
const activeFeatures = this.getActiveFeatures();
|
|
241
|
+
const externalSelection = this.isExternalDocUpdate(docInput) ? docInput.selection : undefined;
|
|
242
|
+
const initialVersion = this.isExternalDocUpdate(docInput) && typeof docInput.version === 'number' ? docInput.version : undefined;
|
|
243
|
+
const initialSelection = this.initialSelection ?? externalSelection;
|
|
244
|
+
try {
|
|
245
|
+
const editor = await Editor.create({
|
|
246
|
+
docId: `lit-${Math.random().toString(36).slice(2)}`,
|
|
247
|
+
sync: this.createLocalSync(initialDoc),
|
|
248
|
+
initialDoc: initialDoc.toJSON(),
|
|
249
|
+
initialFiles,
|
|
250
|
+
initialReferences,
|
|
251
|
+
features: activeFeatures,
|
|
252
|
+
initialSelection,
|
|
253
|
+
initialVersion,
|
|
254
|
+
transformOps: (snapshot, ops) => {
|
|
255
|
+
this.capturePendingOps(ops, 'transform');
|
|
256
|
+
return snapshot;
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
if (!this.isConnected) {
|
|
260
|
+
editor.destroy();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.editor = editor;
|
|
264
|
+
editor.mount(mountPoint);
|
|
265
|
+
this.view = editor.getView();
|
|
266
|
+
this.debugTestLog('editor mounted', { hasView: !!this.view });
|
|
267
|
+
const runner = editor.getCommands();
|
|
268
|
+
this.debugTestLog('command keys', runner ? Object.keys(runner.commands) : null);
|
|
269
|
+
this.docUnsub = editor.onDoc((doc) => {
|
|
270
|
+
this.debugTestLog('dispatching editor-change', { ops: this.pendingOps.length });
|
|
271
|
+
const files = editor.getFiles();
|
|
272
|
+
this.dispatchEditorChange(doc, this.pendingOps, files);
|
|
273
|
+
this.pendingOps = [];
|
|
274
|
+
this.pendingOpsFromTransform = false;
|
|
275
|
+
});
|
|
276
|
+
this.selectionUnsub = editor.onSelection((selection) => {
|
|
277
|
+
this.dispatchSelectionChange(selection);
|
|
278
|
+
});
|
|
279
|
+
this.validationUnsub = editor.onValidation(() => undefined);
|
|
280
|
+
this.dispatchEditorReady();
|
|
281
|
+
this.initialSelection = undefined;
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
console.error('[sciflow-editor] failed to initialize editor', error);
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Apply the `doc` attribute to the running editor instance. */
|
|
289
|
+
applyDocUpdate() {
|
|
290
|
+
if (!this.editor) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const update = this.buildSyncUpdate(this.doc);
|
|
294
|
+
if (!update) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.pendingOps = [];
|
|
298
|
+
this.pendingOpsFromTransform = false;
|
|
299
|
+
this.editor.updateFromSync(update);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Minimal in-memory sync strategy used for the demo element.
|
|
303
|
+
* Reimplementations typically swap this out for their persistence layer.
|
|
304
|
+
*/
|
|
305
|
+
createLocalSync(initialDoc) {
|
|
306
|
+
const docJSON = initialDoc.toJSON();
|
|
307
|
+
return {
|
|
308
|
+
async load() {
|
|
309
|
+
return {
|
|
310
|
+
doc: docJSON,
|
|
311
|
+
version: 0,
|
|
312
|
+
selection: { anchor: 0, head: 0 },
|
|
313
|
+
files: [],
|
|
314
|
+
references: [],
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
applyExternal(ops, meta) {
|
|
318
|
+
void ops;
|
|
319
|
+
void meta;
|
|
320
|
+
},
|
|
321
|
+
applyLocal: (ops, meta) => {
|
|
322
|
+
void meta;
|
|
323
|
+
if (ops.length === 0) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this.capturePendingOps(ops, 'sync');
|
|
327
|
+
},
|
|
328
|
+
dispose() {
|
|
329
|
+
// No resources to release for the in-memory strategy.
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
isExternalDocUpdate(value) {
|
|
334
|
+
return (typeof value === 'object' &&
|
|
335
|
+
value !== null &&
|
|
336
|
+
'doc' in value &&
|
|
337
|
+
typeof value.doc !== 'undefined');
|
|
338
|
+
}
|
|
339
|
+
extractDocFromInput(input) {
|
|
340
|
+
if (!input) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
return this.isExternalDocUpdate(input) ? input.doc : input;
|
|
344
|
+
}
|
|
345
|
+
buildSyncUpdate(input) {
|
|
346
|
+
if (!input) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
if (this.isExternalDocUpdate(input)) {
|
|
350
|
+
const update = {
|
|
351
|
+
doc: input.doc,
|
|
352
|
+
};
|
|
353
|
+
if (input.selection) {
|
|
354
|
+
update.selection = input.selection;
|
|
355
|
+
}
|
|
356
|
+
if (typeof input.version === 'number') {
|
|
357
|
+
update.version = input.version;
|
|
358
|
+
}
|
|
359
|
+
if (Array.isArray(input.files)) {
|
|
360
|
+
update.files = input.files;
|
|
361
|
+
}
|
|
362
|
+
if (Array.isArray(input.references)) {
|
|
363
|
+
update.references = input.references;
|
|
364
|
+
}
|
|
365
|
+
return update;
|
|
366
|
+
}
|
|
367
|
+
return { doc: input };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Determine which document should be loaded on startup.
|
|
371
|
+
* Preference order: externally supplied JSON, provided PM node, fallback.
|
|
372
|
+
*/
|
|
373
|
+
resolveInitialDoc() {
|
|
374
|
+
const docInput = this.extractDocFromInput(this.doc);
|
|
375
|
+
if (docInput) {
|
|
376
|
+
return this.docFromJSON(docInput);
|
|
377
|
+
}
|
|
378
|
+
if (this.initialContent) {
|
|
379
|
+
return this.initialContent;
|
|
380
|
+
}
|
|
381
|
+
return this.createDefaultDoc();
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Build the bundled demonstration document (heading + paragraph + figure).
|
|
385
|
+
* Feel free to replace this when embedding in your own product.
|
|
386
|
+
*/
|
|
387
|
+
createDefaultDoc() {
|
|
388
|
+
const heading = manuscriptSchema.node('heading', { level: 1 }, [manuscriptSchema.text(this.headingText || 'Moby-Dick: A Study in Obsession')]);
|
|
389
|
+
const introText = (this.placeholder ?? '').trim() || 'Introduce Ishmael and the enigmatic Captain Ahab…';
|
|
390
|
+
const citationSource = SourceField.toString([{ id: '299' }]) ?? '';
|
|
391
|
+
const citation = manuscriptSchema.node('citation', { id: 'citation-mobydick', style: 'apa', source: citationSource }, [manuscriptSchema.text('(Melville, 2018)')]);
|
|
392
|
+
const paragraph = manuscriptSchema.node('paragraph', undefined, [
|
|
393
|
+
manuscriptSchema.text(`${introText} `),
|
|
394
|
+
citation,
|
|
395
|
+
manuscriptSchema.text(' offers a canonical touchpoint for exploring literary obsession.'),
|
|
396
|
+
]);
|
|
397
|
+
// Create a figure with a caption
|
|
398
|
+
const captionParagraph = manuscriptSchema.node('paragraph', undefined, [
|
|
399
|
+
manuscriptSchema.text('A figure caption demonstrating the figure node view.'),
|
|
400
|
+
]);
|
|
401
|
+
const caption = manuscriptSchema.node('caption', undefined, [captionParagraph]);
|
|
402
|
+
const figure = manuscriptSchema.node('figure', {
|
|
403
|
+
id: 'demo-figure',
|
|
404
|
+
src: 'https://placehold.co/600x400',
|
|
405
|
+
alt: 'Demo figure',
|
|
406
|
+
type: 'figure',
|
|
407
|
+
orientation: 'landscape',
|
|
408
|
+
}, [caption]);
|
|
409
|
+
return manuscriptSchema.node('doc', undefined, [heading, paragraph, figure]);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Safely convert SciFlow document JSON back into a ProseMirror node.
|
|
413
|
+
* Falls back to the default document when parsing fails.
|
|
414
|
+
*/
|
|
415
|
+
docFromJSON(doc) {
|
|
416
|
+
try {
|
|
417
|
+
const json = doc;
|
|
418
|
+
const sanitized = this.sanitizeDocJSON(json);
|
|
419
|
+
return manuscriptSchema.nodeFromJSON(sanitized);
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
console.warn('[sciflow-editor] received invalid doc JSON; falling back to default document.', error);
|
|
423
|
+
return this.createDefaultDoc();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Remove or fix malformed nodes before handing JSON to the schema parser.
|
|
428
|
+
* - Drops empty xref/link nodes (no content).
|
|
429
|
+
* - Drops xref/link nodes that target missing ids.
|
|
430
|
+
* This avoids ProseMirror widget creation paths that receive undefined DOM.
|
|
431
|
+
*/
|
|
432
|
+
sanitizeDocJSON(doc) {
|
|
433
|
+
const ids = new Set();
|
|
434
|
+
const collectIds = (node) => {
|
|
435
|
+
if (!node || typeof node !== 'object') {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const id = node?.attrs?.id;
|
|
439
|
+
if (typeof id === 'string') {
|
|
440
|
+
ids.add(id);
|
|
441
|
+
}
|
|
442
|
+
if (Array.isArray(node?.content)) {
|
|
443
|
+
node.content.forEach((child) => collectIds(child));
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
collectIds(doc);
|
|
447
|
+
const sanitizeNode = (node) => {
|
|
448
|
+
if (!node || typeof node !== 'object') {
|
|
449
|
+
return node;
|
|
450
|
+
}
|
|
451
|
+
const rawContent = Array.isArray(node.content) ? node.content : [];
|
|
452
|
+
const content = rawContent
|
|
453
|
+
.map((child) => sanitizeNode(child))
|
|
454
|
+
.filter((child) => Boolean(child));
|
|
455
|
+
if (node.type === 'link') {
|
|
456
|
+
const href = node?.attrs?.href;
|
|
457
|
+
const target = typeof href === 'string' && href.startsWith('#') ? href.slice(1) : null;
|
|
458
|
+
const hasContent = content.length > 0;
|
|
459
|
+
const missingTarget = Boolean(target && !ids.has(target));
|
|
460
|
+
if (!hasContent || missingTarget) {
|
|
461
|
+
console.warn('[sciflow-editor] dropping invalid link node', {
|
|
462
|
+
href,
|
|
463
|
+
hasContent,
|
|
464
|
+
missingTarget,
|
|
465
|
+
});
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const sanitized = { ...node };
|
|
470
|
+
if (rawContent.length > 0) {
|
|
471
|
+
sanitized.content = content;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
delete sanitized.content;
|
|
475
|
+
}
|
|
476
|
+
return sanitized;
|
|
477
|
+
};
|
|
478
|
+
return sanitizeNode(doc) ?? doc;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Emit a high-level `editor-change` event with the new document snapshot.
|
|
482
|
+
* Applications can listen and update their stores or routing state.
|
|
483
|
+
*/
|
|
484
|
+
dispatchEditorChange(doc, operations, files) {
|
|
485
|
+
this.debugTestLog('editor-change event', { doc, operations });
|
|
486
|
+
this.dispatchEvent(new CustomEvent('editor-change', {
|
|
487
|
+
detail: { doc, operations, files, references: this.editor?.getReferences() ?? [] },
|
|
488
|
+
bubbles: true,
|
|
489
|
+
composed: true,
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Emit `editor-selection-change` whenever the cursor or selection updates.
|
|
494
|
+
* Handy for co-ordinating ancillary UI like reference or outline panels.
|
|
495
|
+
*/
|
|
496
|
+
dispatchSelectionChange(selection) {
|
|
497
|
+
this.dispatchEvent(new CustomEvent('editor-selection-change', {
|
|
498
|
+
detail: selection,
|
|
499
|
+
bubbles: true,
|
|
500
|
+
composed: true,
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
/** Notify listeners that the editor has mounted and is ready for input. */
|
|
504
|
+
dispatchEditorReady() {
|
|
505
|
+
this.dispatchEvent(new CustomEvent('editor-ready', {
|
|
506
|
+
bubbles: true,
|
|
507
|
+
composed: true,
|
|
508
|
+
}));
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Tear down subscriptions and dispose the editor instance.
|
|
512
|
+
* Called automatically when the element disconnects.
|
|
513
|
+
*/
|
|
514
|
+
async reinitializeEditor() {
|
|
515
|
+
if (!this.isConnected) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const currentDoc = this.editor?.getDoc() ?? null;
|
|
519
|
+
const currentSelection = this.editor?.getSelection() ?? null;
|
|
520
|
+
const currentFiles = this.editor?.getFiles() ?? [];
|
|
521
|
+
const currentVersion = this.editor?.getVersion();
|
|
522
|
+
this.destroyEditor();
|
|
523
|
+
this.view = null;
|
|
524
|
+
if (currentDoc) {
|
|
525
|
+
const nextDoc = {
|
|
526
|
+
doc: currentDoc,
|
|
527
|
+
files: currentFiles,
|
|
528
|
+
};
|
|
529
|
+
if (currentSelection) {
|
|
530
|
+
nextDoc.selection = currentSelection;
|
|
531
|
+
}
|
|
532
|
+
if (typeof currentVersion === 'number') {
|
|
533
|
+
nextDoc.version = currentVersion;
|
|
534
|
+
}
|
|
535
|
+
this.doc = nextDoc;
|
|
536
|
+
}
|
|
537
|
+
this.initialSelection = currentSelection ?? this.initialSelection;
|
|
538
|
+
await this.initializeView();
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Convenience method to immediately apply shadow root styles.
|
|
542
|
+
* Recommended for dynamic style injection after editor initialization.
|
|
543
|
+
*
|
|
544
|
+
* @example
|
|
545
|
+
* ```typescript
|
|
546
|
+
* // Add styles for ProseMirror decorations dynamically
|
|
547
|
+
* editor.setShadowStyles([`
|
|
548
|
+
* .my-annotation {
|
|
549
|
+
* background: rgba(255, 200, 0, 0.3);
|
|
550
|
+
* border-bottom: 2px solid orange;
|
|
551
|
+
* }
|
|
552
|
+
* `]);
|
|
553
|
+
* ```
|
|
554
|
+
*/
|
|
555
|
+
setShadowStyles(styles) {
|
|
556
|
+
const oldValue = this._shadowStyles;
|
|
557
|
+
this._shadowStyles = styles;
|
|
558
|
+
this._shadowStylesAppliedExternally = true; // Mark as externally managed
|
|
559
|
+
// Apply immediately (synchronous)
|
|
560
|
+
this.applyShadowStyles();
|
|
561
|
+
// Notify Lit of the change (for re-renders, but won't re-apply in updated())
|
|
562
|
+
this.requestUpdate('shadowStyles', oldValue);
|
|
563
|
+
}
|
|
564
|
+
destroyEditor() {
|
|
565
|
+
this.docUnsub?.();
|
|
566
|
+
this.selectionUnsub?.();
|
|
567
|
+
this.validationUnsub?.();
|
|
568
|
+
this.docUnsub = undefined;
|
|
569
|
+
this.selectionUnsub = undefined;
|
|
570
|
+
this.validationUnsub = undefined;
|
|
571
|
+
this.editor?.destroy();
|
|
572
|
+
this.editor = undefined;
|
|
573
|
+
this.pendingOps = [];
|
|
574
|
+
this.pendingOpsFromTransform = false;
|
|
575
|
+
}
|
|
576
|
+
capturePendingOps(ops, source) {
|
|
577
|
+
if (!ops || ops.length === 0) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (source === 'transform') {
|
|
581
|
+
this.pendingOps = [...ops];
|
|
582
|
+
this.pendingOpsFromTransform = true;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (this.pendingOpsFromTransform) {
|
|
586
|
+
this.pendingOps = [...ops];
|
|
587
|
+
this.pendingOpsFromTransform = false;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
this.pendingOps = [...this.pendingOps, ...ops];
|
|
591
|
+
}
|
|
592
|
+
applyShadowStyles() {
|
|
593
|
+
const root = this.renderRoot;
|
|
594
|
+
if (!root || typeof root.appendChild !== 'function') {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
for (const styleEl of this.shadowStyleElements) {
|
|
598
|
+
if (styleEl.parentNode === root) {
|
|
599
|
+
root.removeChild(styleEl);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
this.shadowStyleElements = [];
|
|
603
|
+
const cssTexts = this.normalizeShadowStyles(this.shadowStyles);
|
|
604
|
+
if (cssTexts.length === 0) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
for (const cssText of cssTexts) {
|
|
608
|
+
const styleEl = document.createElement('style');
|
|
609
|
+
styleEl.textContent = cssText;
|
|
610
|
+
root.appendChild(styleEl);
|
|
611
|
+
this.shadowStyleElements.push(styleEl);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
normalizeShadowStyles(styles) {
|
|
615
|
+
if (!styles) {
|
|
616
|
+
return [];
|
|
617
|
+
}
|
|
618
|
+
const list = Array.isArray(styles) ? styles : [styles];
|
|
619
|
+
const cssTexts = [];
|
|
620
|
+
for (const entry of list) {
|
|
621
|
+
if (!entry) {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (typeof entry === 'string') {
|
|
625
|
+
const trimmed = entry.trim();
|
|
626
|
+
if (trimmed) {
|
|
627
|
+
cssTexts.push(trimmed);
|
|
628
|
+
}
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (typeof CSSStyleSheet !== 'undefined' && entry instanceof CSSStyleSheet) {
|
|
632
|
+
const serialized = this.extractCssFromSheet(entry);
|
|
633
|
+
if (serialized) {
|
|
634
|
+
cssTexts.push(serialized);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return cssTexts;
|
|
639
|
+
}
|
|
640
|
+
extractCssFromSheet(sheet) {
|
|
641
|
+
try {
|
|
642
|
+
const rules = Array.from(sheet.cssRules ?? []);
|
|
643
|
+
const cssText = rules.map((rule) => rule.cssText).join('\n');
|
|
644
|
+
return cssText.trim() ? cssText : null;
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
console.warn('[sciflow-editor] unable to read supplied CSSStyleSheet', error);
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
debugTestLog(message, payload) {
|
|
652
|
+
const isDebug = (typeof process !== 'undefined' && process.env?.DEBUG_TESTS === 'true') ||
|
|
653
|
+
(typeof import.meta !== 'undefined' && import.meta.env?.DEBUG_TESTS === 'true');
|
|
654
|
+
if (!isDebug) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
console.log('[sciflow-editor]', message, payload ?? '');
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
__decorate([
|
|
661
|
+
property({ attribute: false })
|
|
662
|
+
], SciFlowEditorElement.prototype, "initialContent", void 0);
|
|
663
|
+
__decorate([
|
|
664
|
+
property({ attribute: false })
|
|
665
|
+
], SciFlowEditorElement.prototype, "doc", void 0);
|
|
666
|
+
__decorate([
|
|
667
|
+
property({ attribute: false })
|
|
668
|
+
], SciFlowEditorElement.prototype, "features", void 0);
|
|
669
|
+
__decorate([
|
|
670
|
+
property({ attribute: false })
|
|
671
|
+
], SciFlowEditorElement.prototype, "shadowStyles", null);
|
|
672
|
+
__decorate([
|
|
673
|
+
property({ type: String })
|
|
674
|
+
], SciFlowEditorElement.prototype, "headingText", void 0);
|
|
675
|
+
__decorate([
|
|
676
|
+
property({ type: String })
|
|
677
|
+
], SciFlowEditorElement.prototype, "placeholder", void 0);
|
|
678
|
+
SciFlowEditorElement = __decorate([
|
|
679
|
+
customElement('sciflow-editor')
|
|
680
|
+
], SciFlowEditorElement);
|
|
681
|
+
export { SciFlowEditorElement };
|