svedit 0.1.0
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/LICENSE +21 -0
- package/README.md +323 -0
- package/dist/AnnotatedStringProperty.svelte +126 -0
- package/dist/AnnotatedStringProperty.svelte.d.ts +7 -0
- package/dist/AnnotatedStringProperty.svelte.d.ts.map +1 -0
- package/dist/CustomProperty.svelte +40 -0
- package/dist/CustomProperty.svelte.d.ts +8 -0
- package/dist/CustomProperty.svelte.d.ts.map +1 -0
- package/dist/Document.svelte.d.ts +191 -0
- package/dist/Document.svelte.d.ts.map +1 -0
- package/dist/Document.svelte.js +681 -0
- package/dist/Node.svelte +41 -0
- package/dist/Node.svelte.d.ts +7 -0
- package/dist/Node.svelte.d.ts.map +1 -0
- package/dist/NodeArrayProperty.svelte +45 -0
- package/dist/NodeArrayProperty.svelte.d.ts +7 -0
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -0
- package/dist/Svedit.svelte +936 -0
- package/dist/Svedit.svelte.d.ts +12 -0
- package/dist/Svedit.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.d.ts +198 -0
- package/dist/Transaction.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.js +577 -0
- package/dist/UnknownNode.svelte +12 -0
- package/dist/UnknownNode.svelte.d.ts +12 -0
- package/dist/UnknownNode.svelte.d.ts.map +1 -0
- package/dist/commands.svelte.d.ts +5 -0
- package/dist/commands.svelte.d.ts.map +1 -0
- package/dist/commands.svelte.js +261 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/types.d.ts +264 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +78 -0
- package/package.json +65 -0
- package/src/lib/AnnotatedStringProperty.svelte +126 -0
- package/src/lib/CustomProperty.svelte +40 -0
- package/src/lib/Document.svelte.js +681 -0
- package/src/lib/Node.svelte +41 -0
- package/src/lib/NodeArrayProperty.svelte +45 -0
- package/src/lib/Svedit.svelte +936 -0
- package/src/lib/Transaction.svelte.js +577 -0
- package/src/lib/UnknownNode.svelte +12 -0
- package/src/lib/commands.svelte.js +261 -0
- package/src/lib/index.js +14 -0
- package/src/lib/types.d.ts +264 -0
- package/src/lib/util.js +78 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import Transaction from './Transaction.svelte.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @import {
|
|
5
|
+
* NodeId,
|
|
6
|
+
* DocumentPath,
|
|
7
|
+
* Selection,
|
|
8
|
+
* Annotation,
|
|
9
|
+
* PrimitiveType,
|
|
10
|
+
* DocumentSchemaPrimitive,
|
|
11
|
+
* NodeProperty,
|
|
12
|
+
* NodeArrayProperty,
|
|
13
|
+
* DocumentSchema,
|
|
14
|
+
* SerializedDocument
|
|
15
|
+
* } from './types.d.ts';
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Identity function — keeps schema at runtime & makes IDE infer types.
|
|
20
|
+
* Similar to your define_schema pattern but for document schemas.
|
|
21
|
+
*
|
|
22
|
+
* @template {Record<string, Record<string, {type: DocumentSchemaPrimitive}>>} S
|
|
23
|
+
* @param {S} schema - The document schema to validate
|
|
24
|
+
* @returns {S} The same schema, but with type information preserved
|
|
25
|
+
*/
|
|
26
|
+
export function define_document_schema(schema) {
|
|
27
|
+
return schema;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if a string represents a valid primitive type.
|
|
32
|
+
* @param {string} type - The type string to check
|
|
33
|
+
* @returns {type is PrimitiveType} True if it's a valid primitive type
|
|
34
|
+
*/
|
|
35
|
+
export function is_primitive_type(type) {
|
|
36
|
+
return [
|
|
37
|
+
'string', 'number', 'boolean', 'integer', 'datetime',
|
|
38
|
+
'annotated_string', 'string_array', 'number_array',
|
|
39
|
+
'boolean_array', 'integer_array'
|
|
40
|
+
].includes(type);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the default node type for a property that references nodes.
|
|
45
|
+
* @param {NodeProperty | NodeArrayProperty} property_definition - The property definition
|
|
46
|
+
* @returns {string | null} The default node type, or null if none specified
|
|
47
|
+
*/
|
|
48
|
+
export function get_default_node_type(property_definition) {
|
|
49
|
+
if (!property_definition || !property_definition.node_types) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return property_definition.default_node_type ||
|
|
54
|
+
(property_definition.node_types.length === 1 ? property_definition.node_types[0] : null);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate a document schema to ensure all referenced node types exist.
|
|
59
|
+
* @param {DocumentSchema} document_schema - The document schema to validate
|
|
60
|
+
* @throws {Error} Throws if the document schema is invalid
|
|
61
|
+
*/
|
|
62
|
+
export function validate_document_schema(document_schema) {
|
|
63
|
+
// Check that all referenced node types exist
|
|
64
|
+
for (const [node_type, node_schema] of Object.entries(document_schema)) {
|
|
65
|
+
for (const [prop_name, prop_def] of Object.entries(node_schema)) {
|
|
66
|
+
if (prop_def.type === 'node' || prop_def.type === 'node_array') {
|
|
67
|
+
const missing_types = prop_def.node_types.filter(ref_type => !(ref_type in document_schema));
|
|
68
|
+
if (missing_types.length > 0) {
|
|
69
|
+
throw new Error(`Node type "${node_type}" property "${prop_name}" references unknown node types: ${missing_types.join(', ')}. Available node types: ${Object.keys(document_schema).join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validate a primitive value against its schema type.
|
|
78
|
+
* @param {PrimitiveType} type - The expected type
|
|
79
|
+
* @param {any} value - The value to validate
|
|
80
|
+
* @returns {boolean} True if the value matches the type
|
|
81
|
+
*/
|
|
82
|
+
function validate_primitive_value(type, value) {
|
|
83
|
+
switch (type) {
|
|
84
|
+
case 'string':
|
|
85
|
+
return typeof value === 'string';
|
|
86
|
+
case 'number':
|
|
87
|
+
return typeof value === 'number' && !isNaN(value);
|
|
88
|
+
case 'boolean':
|
|
89
|
+
return typeof value === 'boolean';
|
|
90
|
+
case 'integer':
|
|
91
|
+
return Number.isInteger(value);
|
|
92
|
+
case 'datetime':
|
|
93
|
+
return typeof value === 'string' && !isNaN(Date.parse(value));
|
|
94
|
+
case 'annotated_string':
|
|
95
|
+
return Array.isArray(value) &&
|
|
96
|
+
value.length === 2 &&
|
|
97
|
+
typeof value[0] === 'string' &&
|
|
98
|
+
Array.isArray(value[1]);
|
|
99
|
+
case 'string_array':
|
|
100
|
+
return Array.isArray(value) && value.every(v => typeof v === 'string');
|
|
101
|
+
case 'number_array':
|
|
102
|
+
return Array.isArray(value) && value.every(v => typeof v === 'number' && !isNaN(v));
|
|
103
|
+
case 'boolean_array':
|
|
104
|
+
return Array.isArray(value) && value.every(v => typeof v === 'boolean');
|
|
105
|
+
case 'integer_array':
|
|
106
|
+
return Array.isArray(value) && value.every(v => Number.isInteger(v));
|
|
107
|
+
default:
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {String} id
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
function is_id_valid(id) {
|
|
117
|
+
return typeof id === 'string' && id.length > 0
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validate a node against its schema.
|
|
122
|
+
* @param {any} node - The node to validate
|
|
123
|
+
* @param {DocumentSchema} schema - The document schema
|
|
124
|
+
* @param {Record<string, any>} all_nodes - All nodes in the document to check references
|
|
125
|
+
* @throws {Error} Throws if the node is invalid
|
|
126
|
+
*/
|
|
127
|
+
function validate_node(node, schema, all_nodes = {}) {
|
|
128
|
+
if (!is_id_valid(node.id)) {
|
|
129
|
+
throw new Error(`Node ${node.id} has an invalid id.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!node.type || !schema[node.type]) {
|
|
133
|
+
throw new Error(`Node ${node.id} has an invalid type: ${node.type}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const node_schema = schema[node.type];
|
|
137
|
+
|
|
138
|
+
for (const [prop_name, prop_def] of Object.entries(node_schema)) {
|
|
139
|
+
const value = node[prop_name];
|
|
140
|
+
|
|
141
|
+
// Check primitive types
|
|
142
|
+
if (is_primitive_type(prop_def.type)) {
|
|
143
|
+
if (!validate_primitive_value(prop_def.type, value)) {
|
|
144
|
+
throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be of type ${prop_def.type}.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Check node references
|
|
148
|
+
if (prop_def.type === 'node') {
|
|
149
|
+
if (!is_id_valid(value)) {
|
|
150
|
+
throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be a valid node id.`);
|
|
151
|
+
}
|
|
152
|
+
// Check if referenced node exists and is of allowed type
|
|
153
|
+
const referenced_node = all_nodes[value];
|
|
154
|
+
if (
|
|
155
|
+
referenced_node &&
|
|
156
|
+
!prop_def.node_types.includes(referenced_node.type)
|
|
157
|
+
) {
|
|
158
|
+
throw new Error(`Node ${node.id} property ${prop_name} references node ${value} of type ${referenced_node.type}, but only types [${(/** @type {NodeProperty} */ (prop_def)).node_types.join(', ')}] are allowed.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Check node arrays
|
|
162
|
+
else if (prop_def.type === 'node_array') {
|
|
163
|
+
if (!Array.isArray(value) || !value.every(id => typeof id === 'string' && is_id_valid(id))) {
|
|
164
|
+
throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be an array of node ids.`);
|
|
165
|
+
}
|
|
166
|
+
// Check if all referenced nodes are of allowed types
|
|
167
|
+
for (const ref_id of value) {
|
|
168
|
+
const referenced_node = all_nodes[ref_id];
|
|
169
|
+
if (referenced_node && !prop_def.node_types.includes(referenced_node.type)) {
|
|
170
|
+
throw new Error(`Node ${node.id} property ${prop_name} references node ${ref_id} of type ${referenced_node.type}, but only types [${prop_def.node_types.join(', ')}] are allowed.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default class Document {
|
|
178
|
+
/** @type {Selection | undefined} */
|
|
179
|
+
selection = $state();
|
|
180
|
+
config = $state();
|
|
181
|
+
document_id = $state();
|
|
182
|
+
nodes = $state();
|
|
183
|
+
history = $state([]);
|
|
184
|
+
history_index = $state(-1);
|
|
185
|
+
|
|
186
|
+
// Reactive helpers for UI state
|
|
187
|
+
can_undo = $derived(this.history_index >= 0);
|
|
188
|
+
can_redo = $derived(this.history_index < this.history.length - 1);
|
|
189
|
+
|
|
190
|
+
// Reactive variable for selected node
|
|
191
|
+
selected_node = $derived(this.get_selected_node());
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {DocumentSchema} schema - The document schema
|
|
195
|
+
* @param {SerializedDocument} serialized_doc - The serialized document array
|
|
196
|
+
* @param {object} [options] - Optional configuration
|
|
197
|
+
* @param {Selection} [options.selection] - Initial selection state
|
|
198
|
+
* @param {any} [options.config] - Document configuration
|
|
199
|
+
*/
|
|
200
|
+
constructor(schema, serialized_doc, { selection, config } = {}) {
|
|
201
|
+
// Validate the schema first
|
|
202
|
+
validate_document_schema(schema);
|
|
203
|
+
|
|
204
|
+
this.schema = schema;
|
|
205
|
+
this.selection = selection;
|
|
206
|
+
this.config = config;
|
|
207
|
+
this.nodes = {};
|
|
208
|
+
|
|
209
|
+
// Initialize and validate nodes one by one
|
|
210
|
+
// This ensures references only point to already-loaded nodes
|
|
211
|
+
for (const node of serialized_doc) {
|
|
212
|
+
this.validate_node(node);
|
|
213
|
+
this.nodes[node.id] = node;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The last element in the serialized_doc is the document itself (the root node)
|
|
217
|
+
this.document_id = serialized_doc.at(-1)?.id;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
generate_id() {
|
|
221
|
+
if (this.config.generate_id) {
|
|
222
|
+
return this.config.generate_id();
|
|
223
|
+
} else {
|
|
224
|
+
return crypto.randomUUID();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Helper function to get the currently selected node
|
|
229
|
+
get_selected_node() {
|
|
230
|
+
if (!this.selection) return null;
|
|
231
|
+
|
|
232
|
+
if (this.selection.type === 'node') {
|
|
233
|
+
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
234
|
+
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
235
|
+
// Only consider selection of a single node
|
|
236
|
+
if (end - start !== 1) return null;
|
|
237
|
+
const node_array = this.get(this.selection.path);
|
|
238
|
+
const node_id = node_array[start];
|
|
239
|
+
return node_id ? this.get(node_id) : null;
|
|
240
|
+
} else {
|
|
241
|
+
// we are assuming we are either in a text or property (=custom) selection
|
|
242
|
+
const owner_node = this.get(this.selection.path.slice(0, -1));
|
|
243
|
+
return owner_node;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Internal unsafe function: Never call this directly
|
|
248
|
+
_apply_op(op) {
|
|
249
|
+
const [type, ...args] = op;
|
|
250
|
+
if (type === 'set') {
|
|
251
|
+
const node = this.get(args[0].slice(0, -1));
|
|
252
|
+
node[args[0].at(-1)] = args[1];
|
|
253
|
+
} else if (type === 'create') {
|
|
254
|
+
|
|
255
|
+
this.nodes[args[0].id] = args[0];
|
|
256
|
+
} else if (type === 'delete') {
|
|
257
|
+
delete this.nodes[args[0]];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Validate a node against the document schema.
|
|
263
|
+
* @param {any} node - The node to validate
|
|
264
|
+
* @throws {Error} Throws if the node is invalid
|
|
265
|
+
*/
|
|
266
|
+
validate_node(node) {
|
|
267
|
+
validate_node(node, this.schema, this.nodes);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Creates a new transaction for making atomic changes to the document.
|
|
272
|
+
*
|
|
273
|
+
* @returns {Transaction} A new transaction instance
|
|
274
|
+
*/
|
|
275
|
+
get tr() {
|
|
276
|
+
// We create a copy of the current document to avoid modifying the original
|
|
277
|
+
const transaction_doc = new Document(this.schema, this.to_json(), {
|
|
278
|
+
config: this.config,
|
|
279
|
+
selection: this.selection
|
|
280
|
+
});
|
|
281
|
+
return new Transaction(transaction_doc);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Applies a transaction to the document.
|
|
286
|
+
*
|
|
287
|
+
* @param {Transaction} transaction - The transaction to apply
|
|
288
|
+
*/
|
|
289
|
+
apply(transaction) {
|
|
290
|
+
this.nodes = transaction.doc.nodes; // No deep copy, trust transaction's evolved state
|
|
291
|
+
// Make sure selection gets a new reference (is rerendered)
|
|
292
|
+
this.selection = { ...transaction.doc.selection };
|
|
293
|
+
if (this.history_index < this.history.length - 1) {
|
|
294
|
+
this.history = this.history.slice(0, this.history_index + 1);
|
|
295
|
+
}
|
|
296
|
+
this.history.push({
|
|
297
|
+
ops: transaction.ops,
|
|
298
|
+
inverse_ops: transaction.inverse_ops,
|
|
299
|
+
selection_before: transaction.selection_before,
|
|
300
|
+
selection_after: this.selection
|
|
301
|
+
});
|
|
302
|
+
this.history_index++;
|
|
303
|
+
|
|
304
|
+
return this;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
undo() {
|
|
308
|
+
if (this.history_index < 0) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const change = this.history[this.history_index];
|
|
312
|
+
const tr = this.tr;
|
|
313
|
+
change.inverse_ops.slice().reverse().forEach(op => tr.doc._apply_op(op));
|
|
314
|
+
this.nodes = tr.doc.nodes;
|
|
315
|
+
this.selection = change.selection_before;
|
|
316
|
+
this.history_index--;
|
|
317
|
+
return this;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
redo() {
|
|
321
|
+
if (this.history_index >= this.history.length - 1) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.history_index++;
|
|
325
|
+
const change = this.history[this.history_index];
|
|
326
|
+
const tr = this.tr;
|
|
327
|
+
change.ops.forEach(op => tr.doc._apply_op(op));
|
|
328
|
+
tr.set_selection(change.selection_after);
|
|
329
|
+
|
|
330
|
+
this.nodes = tr.doc.nodes;
|
|
331
|
+
this.selection = change.selection_after;
|
|
332
|
+
return this;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Gets a node instance or property value at the specified path.
|
|
337
|
+
* @param {DocumentPath|string} path - Path to the node or property
|
|
338
|
+
* @returns {any} Either a node instance object or the value of a property
|
|
339
|
+
* @example
|
|
340
|
+
* // Get a node by ID
|
|
341
|
+
* doc.get('list_1') // => { type: 'list', id: 'list_1', ... }
|
|
342
|
+
*
|
|
343
|
+
* @example
|
|
344
|
+
* // Get a node array property
|
|
345
|
+
* doc.get(['list_1', 'list_items']) // => [ 'list_item_1', 'list_item_2' ]
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* // Get a specific node from an array
|
|
349
|
+
* doc.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* // Get an annotated string property
|
|
353
|
+
* doc.get(['page_1', 'cover', 'title']) // => ['Hello world', []]
|
|
354
|
+
*/
|
|
355
|
+
get(path) {
|
|
356
|
+
if (typeof path === 'string') {
|
|
357
|
+
path = [ path ];
|
|
358
|
+
}
|
|
359
|
+
if (!(Array.isArray(path) && path.length >= 1)) {
|
|
360
|
+
throw new Error(`Invalid path provided ${JSON.stringify(path)}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let val = this.nodes[path[0]];
|
|
364
|
+
let val_type = 'node';
|
|
365
|
+
|
|
366
|
+
for (let i = 1; i < path.length; i++) {
|
|
367
|
+
const path_segment = path[i];
|
|
368
|
+
if (val_type === 'node') {
|
|
369
|
+
if (this.property_type(val.type, path_segment) === 'node_array') {
|
|
370
|
+
val = val[path_segment]; // e.g. for the page body ['list_1', 'paragraph_1']
|
|
371
|
+
val_type = 'node_array';
|
|
372
|
+
} else if (this.property_type(val.type, path_segment) === 'node') {
|
|
373
|
+
val = this.nodes[val[path_segment]];
|
|
374
|
+
val_type = 'node';
|
|
375
|
+
} else if (['string_array', 'integer_array'].includes(this.property_type(val.type, path_segment))) {
|
|
376
|
+
val = val[path_segment];
|
|
377
|
+
val_type = 'value_array';
|
|
378
|
+
} else {
|
|
379
|
+
// Resolve value properties sucha as string, integer, datetime
|
|
380
|
+
val = val[path_segment];
|
|
381
|
+
val_type = 'value';
|
|
382
|
+
}
|
|
383
|
+
} else if (val_type === 'node_array') {
|
|
384
|
+
// We expect the val to be an array of node ids and the path_segment to be an array index
|
|
385
|
+
val = this.nodes[val[path_segment]];
|
|
386
|
+
val_type = 'node';
|
|
387
|
+
} else if (val_type === 'value_array') {
|
|
388
|
+
val = val[path_segment];
|
|
389
|
+
val_type = 'value';
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return val;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* While .get gives you the value of a path, inspect gives you
|
|
397
|
+
* the type info of that value.
|
|
398
|
+
*
|
|
399
|
+
* @todo The layout of these should be improved and more explictly typed
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* doc.inspect(['page_1', 'body']) => {
|
|
403
|
+
* kind: 'property',
|
|
404
|
+
* name: 'body',
|
|
405
|
+
* type: 'node_array',
|
|
406
|
+
* node_types: ['text', 'story', 'list'],
|
|
407
|
+
* default_node_type: 'text'
|
|
408
|
+
* }
|
|
409
|
+
*
|
|
410
|
+
* @example
|
|
411
|
+
* doc.inspect(['page_1', 'body', 1]) => {
|
|
412
|
+
* kind: 'node',
|
|
413
|
+
* id: 'paragraph_234',
|
|
414
|
+
* type: 'paragraph',
|
|
415
|
+
* properties: {...}
|
|
416
|
+
* }
|
|
417
|
+
*
|
|
418
|
+
* @param {DocumentPath} path
|
|
419
|
+
* @returns {{kind: 'property'|'node', [key: string]: any}}
|
|
420
|
+
*/
|
|
421
|
+
inspect(path) {
|
|
422
|
+
const parent = path.length > 1 ? this.get(path.slice(0, -1)) : undefined;
|
|
423
|
+
if (parent?.type) {
|
|
424
|
+
// Parent is a node, so we are dealing with a property.
|
|
425
|
+
const property_name = path.at(-1);
|
|
426
|
+
return {
|
|
427
|
+
kind: 'property',
|
|
428
|
+
name: property_name,
|
|
429
|
+
// Merge property spec from schema
|
|
430
|
+
...this.schema[parent.type][property_name]
|
|
431
|
+
};
|
|
432
|
+
} else {
|
|
433
|
+
// Parent is a property (or we are at the root), so we are dealing with a node.
|
|
434
|
+
const node = this.get(path);
|
|
435
|
+
return {
|
|
436
|
+
kind: 'node',
|
|
437
|
+
id: node.id,
|
|
438
|
+
type: node.type,
|
|
439
|
+
properties: this.schema[node.type]
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Determines the kind of a node ('text' for pure text nodes or 'node' for anything else)
|
|
446
|
+
* NOTE: currently we assume a 'content' property for pure text nodes
|
|
447
|
+
* @param {any} node
|
|
448
|
+
* @returns {'text'|'node'}
|
|
449
|
+
*/
|
|
450
|
+
kind(node) {
|
|
451
|
+
if (['annotated_string', 'string'].includes(this.schema[node.type]?.content?.type)) {
|
|
452
|
+
return 'text'
|
|
453
|
+
} else {
|
|
454
|
+
return 'node';
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Returns the annotation object that is currently "under the cursor".
|
|
460
|
+
* NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
|
|
461
|
+
*
|
|
462
|
+
* @param {string} annotation_type
|
|
463
|
+
* @returns {Annotation|null}
|
|
464
|
+
*/
|
|
465
|
+
active_annotation(annotation_type) {
|
|
466
|
+
if (this.selection?.type !== 'text') return null;
|
|
467
|
+
|
|
468
|
+
const { start, end } = this.get_selection_range();
|
|
469
|
+
const annotated_string = this.get(this.selection.path);
|
|
470
|
+
const annotations = annotated_string[1];
|
|
471
|
+
|
|
472
|
+
const active_annotation = annotations.find(([annotation_start, annotation_end]) =>
|
|
473
|
+
(annotation_start <= start && annotation_end > start) ||
|
|
474
|
+
(annotation_start < end && annotation_end >= end) ||
|
|
475
|
+
(annotation_start >= start && annotation_end <= end)
|
|
476
|
+
) || null;
|
|
477
|
+
|
|
478
|
+
if (annotation_type) {
|
|
479
|
+
return active_annotation?.[2] === annotation_type ? active_annotation : null;
|
|
480
|
+
} else {
|
|
481
|
+
return active_annotation;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// TODO: think about ways how we can also turn a node selection into plain text.
|
|
486
|
+
get_selected_plain_text() {
|
|
487
|
+
if (this.selection?.type !== 'text') return null;
|
|
488
|
+
|
|
489
|
+
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
490
|
+
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
491
|
+
const annotated_string = this.get(this.selection.path);
|
|
492
|
+
return annotated_string[0].slice(start, end);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
get_selected_nodes() {
|
|
496
|
+
if (this.selection?.type !== 'node') return null;
|
|
497
|
+
|
|
498
|
+
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
499
|
+
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
500
|
+
const node_array = this.get(this.selection.path);
|
|
501
|
+
return $state.snapshot(node_array.slice(start, end));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
move_node_cursor(direction) {
|
|
505
|
+
if (this.selection?.type !== 'node') return;
|
|
506
|
+
const node_array = this.get(this.selection.path);
|
|
507
|
+
|
|
508
|
+
const { start, end } = this.get_selection_range();
|
|
509
|
+
|
|
510
|
+
if (this.selection.anchor_offset !== this.selection.focus_offset) {
|
|
511
|
+
// If selection is not collapsed, collapse it to the right or the left
|
|
512
|
+
if (direction === 'forward') {
|
|
513
|
+
this.selection.focus_offset = end;
|
|
514
|
+
this.selection.anchor_offset = end;
|
|
515
|
+
} else if (direction === 'backward') {
|
|
516
|
+
this.selection.focus_offset = start;
|
|
517
|
+
this.selection.anchor_offset = start;
|
|
518
|
+
}
|
|
519
|
+
} else if (direction === 'forward' && end < node_array.length) {
|
|
520
|
+
this.selection.focus_offset = end + 1;
|
|
521
|
+
this.selection.anchor_offset = end + 1;
|
|
522
|
+
} else if (direction === 'backward' && start > 0) {
|
|
523
|
+
this.selection.focus_offset = start - 1;
|
|
524
|
+
this.selection.anchor_offset = start - 1;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
expand_node_selection(direction) {
|
|
529
|
+
if (this.selection.type !== 'node') return;
|
|
530
|
+
const node_array = this.get(this.selection.path);
|
|
531
|
+
|
|
532
|
+
if (direction === 'forward') {
|
|
533
|
+
this.selection.focus_offset = Math.min(this.selection.focus_offset + 1, node_array.length);
|
|
534
|
+
} else if (direction === 'backward') {
|
|
535
|
+
this.selection.focus_offset = Math.max(this.selection.focus_offset - 1, 0);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
select_parent() {
|
|
540
|
+
if (!this.selection) return;
|
|
541
|
+
if (['text', 'property'].includes(this.selection?.type)) {
|
|
542
|
+
// For text and property selections (e.g. ['page_1', 'body', 0, 'image']), we need to go up two levels
|
|
543
|
+
// in the path
|
|
544
|
+
if (this.selection.path.length > 3) {
|
|
545
|
+
const parent_path = this.selection.path.slice(0, -2);
|
|
546
|
+
const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
|
|
547
|
+
this.selection = {
|
|
548
|
+
type: 'node',
|
|
549
|
+
path: parent_path,
|
|
550
|
+
anchor_offset: current_index,
|
|
551
|
+
focus_offset: current_index + 1
|
|
552
|
+
};
|
|
553
|
+
} else {
|
|
554
|
+
this.selection = undefined;
|
|
555
|
+
}
|
|
556
|
+
} else if (this.selection.type === 'node') {
|
|
557
|
+
// For node selections, we go up one level
|
|
558
|
+
if (this.selection.path.length > 3) {
|
|
559
|
+
const parent_path = this.selection.path.slice(0, -2);
|
|
560
|
+
const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
|
|
561
|
+
|
|
562
|
+
this.selection = {
|
|
563
|
+
type: 'node',
|
|
564
|
+
path: parent_path,
|
|
565
|
+
anchor_offset: current_index,
|
|
566
|
+
focus_offset: current_index + 1
|
|
567
|
+
};
|
|
568
|
+
} else {
|
|
569
|
+
this.selection = undefined;
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
this.selection = undefined;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
get_selection_range() {
|
|
577
|
+
if (this.selection && this.selection.type !== 'property') {
|
|
578
|
+
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
579
|
+
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
start,
|
|
583
|
+
end,
|
|
584
|
+
length: end - start
|
|
585
|
+
};
|
|
586
|
+
} else {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Traverses the document and returns a JSON representation.
|
|
592
|
+
// IMPORTANT: Leaf nodes must go first, branches second and the root node (entry point) last (depth-first traversal)
|
|
593
|
+
// NOTE: Nodes that are not reachable from the entry point node will not be included in the serialization
|
|
594
|
+
traverse(node_id) {
|
|
595
|
+
const json = [];
|
|
596
|
+
const visited = {};
|
|
597
|
+
const visit = (node) => {
|
|
598
|
+
if (!node || visited[node.id]) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
visited[node.id] = true;
|
|
602
|
+
for (const [, value] of Object.entries(node)) {
|
|
603
|
+
// TODO: Use schema inspection and do this only for properties of type `node_array`
|
|
604
|
+
if (Array.isArray(value)) {
|
|
605
|
+
for (const v of value) {
|
|
606
|
+
if (typeof v === 'string') {
|
|
607
|
+
visit($state.snapshot(this.get(v)));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
} else if (typeof value === 'string') {
|
|
611
|
+
// TODO: Use schema inspection and do this only for properties of type `ref`
|
|
612
|
+
visit($state.snapshot(this.get(value)));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
// Finally add the node to the result.
|
|
616
|
+
// We use a deep clone, so we make sure nothing of the original document is referenced.
|
|
617
|
+
json.push(structuredClone(node));
|
|
618
|
+
}
|
|
619
|
+
// Start with the root node (document_id)
|
|
620
|
+
visit($state.snapshot(this.get(node_id)));
|
|
621
|
+
return json;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Convert the document to serialized format.
|
|
626
|
+
* @returns {SerializedDocument} The serialized document array
|
|
627
|
+
*/
|
|
628
|
+
to_json() {
|
|
629
|
+
return this.traverse(this.document_id);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// property_type('page', 'body') => 'node_array'
|
|
633
|
+
// property_type('paragraph', 'content') => 'annotated_string'
|
|
634
|
+
property_type(type, property) {
|
|
635
|
+
if (typeof type !== 'string') throw new Error(`Invalid type ${type} provided`);
|
|
636
|
+
if (typeof property !== 'string') throw new Error(`Invalid property ${property} provided`);
|
|
637
|
+
// NOTE: Not sure if we should treat type and id as properties
|
|
638
|
+
if (property === 'type') return 'string';
|
|
639
|
+
if (property === 'id') return 'string';
|
|
640
|
+
|
|
641
|
+
if (!this.schema[type]) throw new Error(`Type ${type} not found in schema`);
|
|
642
|
+
if (!this.schema[type][property]) throw new Error(`Property ${property} not found in type ${type}`);
|
|
643
|
+
|
|
644
|
+
return this.schema[type][property].type;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Count how many times a node is referenced in the document
|
|
648
|
+
count_references(node_id) {
|
|
649
|
+
let count = 0;
|
|
650
|
+
|
|
651
|
+
for (const node of Object.values(this.nodes)) {
|
|
652
|
+
for (const [property, value] of Object.entries(node)) {
|
|
653
|
+
if (property === 'id' || property === 'type') continue;
|
|
654
|
+
|
|
655
|
+
const prop_type = this.property_type(node.type, property);
|
|
656
|
+
|
|
657
|
+
if (prop_type === 'node_array' && Array.isArray(value)) {
|
|
658
|
+
count += value.filter(id => id === node_id).length;
|
|
659
|
+
} else if (prop_type === 'node' && value === node_id) {
|
|
660
|
+
count += 1;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return count;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Get all nodes referenced by a given node (recursively)
|
|
669
|
+
/**
|
|
670
|
+
* @param {NodeId} node_id
|
|
671
|
+
* @returns {NodeId[]}
|
|
672
|
+
*/
|
|
673
|
+
get_referenced_nodes(node_id) {
|
|
674
|
+
const traversed_nodes = this.traverse(node_id);
|
|
675
|
+
|
|
676
|
+
// Extract IDs and exclude the last element (root node)
|
|
677
|
+
return traversed_nodes
|
|
678
|
+
.slice(0, -1)
|
|
679
|
+
.map(node => node.id);
|
|
680
|
+
}
|
|
681
|
+
}
|