svedit 0.5.0 → 0.6.1
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 +761 -120
- package/dist/AnnotatedTextProperty.svelte +129 -76
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.d.ts +118 -0
- package/dist/Command.svelte.d.ts.map +1 -0
- package/dist/Command.svelte.js +314 -0
- package/dist/CustomProperty.svelte +31 -42
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.d.ts +36 -0
- package/dist/KeyMapper.svelte.d.ts.map +1 -0
- package/dist/KeyMapper.svelte.js +141 -0
- package/dist/Node.svelte +33 -35
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +40 -37
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/{Document.svelte.d.ts → Session.svelte.d.ts} +76 -83
- package/dist/Session.svelte.d.ts.map +1 -0
- package/dist/Session.svelte.js +594 -0
- package/dist/Svedit.svelte +1338 -1512
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +114 -40
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +845 -661
- package/dist/UnknownNode.svelte +6 -6
- package/dist/UnknownNode.svelte.d.ts.map +1 -1
- package/dist/doc_utils.d.ts +127 -0
- package/dist/doc_utils.d.ts.map +1 -0
- package/dist/doc_utils.js +482 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +20 -4
- package/dist/{commands.svelte.d.ts → transforms.svelte.d.ts} +1 -2
- package/dist/transforms.svelte.d.ts.map +1 -0
- package/dist/transforms.svelte.js +155 -0
- package/dist/types.d.ts +256 -110
- package/dist/{util.d.ts → utils.d.ts} +22 -2
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +338 -0
- package/package.json +20 -18
- package/src/lib/AnnotatedTextProperty.svelte +129 -76
- package/src/lib/Command.svelte.js +314 -0
- package/src/lib/CustomProperty.svelte +31 -42
- package/src/lib/KeyMapper.svelte.js +141 -0
- package/src/lib/Node.svelte +33 -35
- package/src/lib/NodeArrayProperty.svelte +40 -37
- package/src/lib/Session.svelte.js +594 -0
- package/src/lib/Svedit.svelte +1338 -1512
- package/src/lib/Transaction.svelte.js +845 -661
- package/src/lib/UnknownNode.svelte +6 -6
- package/src/lib/doc_utils.js +482 -0
- package/src/lib/index.js +20 -4
- package/src/lib/transforms.svelte.js +155 -0
- package/src/lib/types.d.ts +256 -110
- package/src/lib/utils.js +338 -0
- package/dist/Document.svelte.d.ts.map +0 -1
- package/dist/Document.svelte.js +0 -833
- package/dist/commands.svelte.d.ts.map +0 -1
- package/dist/commands.svelte.js +0 -266
- package/dist/util.d.ts.map +0 -1
- package/dist/util.js +0 -269
- package/src/lib/Document.svelte.js +0 -833
- package/src/lib/commands.svelte.js +0 -266
- package/src/lib/util.js +0 -269
|
@@ -1,833 +0,0 @@
|
|
|
1
|
-
import Transaction from './Transaction.svelte.js';
|
|
2
|
-
import { char_slice, get_char_length, traverse } from './util.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* @import {
|
|
6
|
-
* NodeId,
|
|
7
|
-
* DocumentPath,
|
|
8
|
-
* Selection,
|
|
9
|
-
* Annotation,
|
|
10
|
-
* PrimitiveType,
|
|
11
|
-
* NodeProperty,
|
|
12
|
-
* NodeArrayProperty,
|
|
13
|
-
* NodeKind,
|
|
14
|
-
* NodeSchema,
|
|
15
|
-
* DocumentSchema,
|
|
16
|
-
* SerializedDocument
|
|
17
|
-
* } from './types.d.ts';
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Identity function — keeps schema at runtime & makes IDE infer types.
|
|
22
|
-
* Similar to your define_schema pattern but for document schemas.
|
|
23
|
-
*
|
|
24
|
-
* @template {Record<string, NodeSchema>} S
|
|
25
|
-
* @param {S} schema - The document schema to validate
|
|
26
|
-
* @returns {S} The same schema, but with type information preserved
|
|
27
|
-
*/
|
|
28
|
-
export function define_document_schema(schema) {
|
|
29
|
-
return schema;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Check if a string represents a valid primitive type.
|
|
34
|
-
* @param {string} type - The type string to check
|
|
35
|
-
* @returns {type is PrimitiveType} True if it's a valid primitive type
|
|
36
|
-
*/
|
|
37
|
-
export function is_primitive_type(type) {
|
|
38
|
-
return [
|
|
39
|
-
'string', 'number', 'boolean', 'integer', 'datetime',
|
|
40
|
-
'annotated_text', 'string_array', 'number_array',
|
|
41
|
-
'boolean_array', 'integer_array'
|
|
42
|
-
].includes(type);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Get the default node type for a property that references nodes.
|
|
47
|
-
* @param {NodeProperty | NodeArrayProperty} property_definition - The property definition
|
|
48
|
-
* @returns {string | null} The default node type, or null if none specified
|
|
49
|
-
*/
|
|
50
|
-
export function get_default_node_type(property_definition) {
|
|
51
|
-
if (!property_definition || !property_definition.node_types) {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return property_definition.default_node_type ||
|
|
56
|
-
(property_definition.node_types.length === 1 ? property_definition.node_types[0] : null);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Validate a document schema to ensure all referenced node types exist.
|
|
61
|
-
* @param {DocumentSchema} document_schema - The document schema to validate
|
|
62
|
-
* @throws {Error} Throws if the document schema is invalid
|
|
63
|
-
*/
|
|
64
|
-
export function validate_document_schema(document_schema) {
|
|
65
|
-
// Check that all referenced node types exist
|
|
66
|
-
for (const [node_type, node_schema] of Object.entries(document_schema)) {
|
|
67
|
-
for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
|
|
68
|
-
if (prop_def.type === 'node' || prop_def.type === 'node_array') {
|
|
69
|
-
const missing_types = prop_def.node_types.filter(ref_type => !(ref_type in document_schema));
|
|
70
|
-
if (missing_types.length > 0) {
|
|
71
|
-
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(', ')}`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Validate a primitive value against its schema type.
|
|
80
|
-
* @param {PrimitiveType} type - The expected type
|
|
81
|
-
* @param {any} value - The value to validate
|
|
82
|
-
* @returns {boolean} True if the value matches the type
|
|
83
|
-
*/
|
|
84
|
-
function validate_primitive_value(type, value) {
|
|
85
|
-
switch (type) {
|
|
86
|
-
case 'string':
|
|
87
|
-
return typeof value === 'string';
|
|
88
|
-
case 'number':
|
|
89
|
-
return typeof value === 'number' && !isNaN(value);
|
|
90
|
-
case 'boolean':
|
|
91
|
-
return typeof value === 'boolean';
|
|
92
|
-
case 'integer':
|
|
93
|
-
return Number.isInteger(value);
|
|
94
|
-
case 'datetime':
|
|
95
|
-
return typeof value === 'string' && !isNaN(Date.parse(value));
|
|
96
|
-
case 'annotated_text':
|
|
97
|
-
return typeof value === 'object' &&
|
|
98
|
-
value !== null &&
|
|
99
|
-
typeof value.text === 'string' &&
|
|
100
|
-
Array.isArray(value.annotations);
|
|
101
|
-
case 'string_array':
|
|
102
|
-
return Array.isArray(value) && value.every(v => typeof v === 'string');
|
|
103
|
-
case 'number_array':
|
|
104
|
-
return Array.isArray(value) && value.every(v => typeof v === 'number' && !isNaN(v));
|
|
105
|
-
case 'boolean_array':
|
|
106
|
-
return Array.isArray(value) && value.every(v => typeof v === 'boolean');
|
|
107
|
-
case 'integer_array':
|
|
108
|
-
return Array.isArray(value) && value.every(v => Number.isInteger(v));
|
|
109
|
-
default:
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* @param {String} id
|
|
116
|
-
* @returns {boolean}
|
|
117
|
-
*/
|
|
118
|
-
function is_id_valid(id) {
|
|
119
|
-
return typeof id === 'string' && id.length > 0
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Validate a node against its schema.
|
|
124
|
-
* @param {any} node - The node to validate
|
|
125
|
-
* @param {DocumentSchema} schema - The document schema
|
|
126
|
-
* @param {Record<string, any>} all_nodes - All nodes in the document to check references
|
|
127
|
-
* @throws {Error} Throws if the node is invalid
|
|
128
|
-
*/
|
|
129
|
-
function validate_node(node, schema, all_nodes = {}) {
|
|
130
|
-
if (!is_id_valid(node.id)) {
|
|
131
|
-
throw new Error(`Node ${node.id} has an invalid id.`);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (!node.type || !schema[node.type]) {
|
|
135
|
-
throw new Error(`Node ${node.id} has an invalid type: ${node.type}`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const node_schema = schema[node.type];
|
|
139
|
-
|
|
140
|
-
for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
|
|
141
|
-
const value = node[prop_name];
|
|
142
|
-
|
|
143
|
-
// Check primitive types
|
|
144
|
-
if (is_primitive_type(prop_def.type)) {
|
|
145
|
-
if (!validate_primitive_value(prop_def.type, value)) {
|
|
146
|
-
throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be of type ${prop_def.type}.`);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
// Check node references
|
|
150
|
-
if (prop_def.type === 'node') {
|
|
151
|
-
if (!is_id_valid(value)) {
|
|
152
|
-
throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be a valid node id.`);
|
|
153
|
-
}
|
|
154
|
-
// Check if referenced node exists and is of allowed type
|
|
155
|
-
const referenced_node = all_nodes[value];
|
|
156
|
-
if (
|
|
157
|
-
referenced_node &&
|
|
158
|
-
!prop_def.node_types.includes(referenced_node.type)
|
|
159
|
-
) {
|
|
160
|
-
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.`);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
// Check node arrays
|
|
164
|
-
else if (prop_def.type === 'node_array') {
|
|
165
|
-
if (!Array.isArray(value) || !value.every(id => typeof id === 'string' && is_id_valid(id))) {
|
|
166
|
-
throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be an array of node ids.`);
|
|
167
|
-
}
|
|
168
|
-
// Check if all referenced nodes are of allowed types
|
|
169
|
-
for (const ref_id of value) {
|
|
170
|
-
const referenced_node = all_nodes[ref_id];
|
|
171
|
-
if (referenced_node && !prop_def.node_types.includes(referenced_node.type)) {
|
|
172
|
-
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.`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export default class Document {
|
|
180
|
-
/** @type {Selection | undefined} */
|
|
181
|
-
#selection = $state();
|
|
182
|
-
config = $state();
|
|
183
|
-
document_id = $state();
|
|
184
|
-
nodes = $state();
|
|
185
|
-
history = $state([]);
|
|
186
|
-
history_index = $state(-1);
|
|
187
|
-
|
|
188
|
-
// Reactive helpers for UI state
|
|
189
|
-
can_undo = $derived(this.history_index >= 0);
|
|
190
|
-
can_redo = $derived(this.history_index < this.history.length - 1);
|
|
191
|
-
|
|
192
|
-
// Reactive variable for selected node
|
|
193
|
-
selected_node = $derived(this.get_selected_node());
|
|
194
|
-
layout_node = $derived(this.get_layout_node());
|
|
195
|
-
available_annotation_types = $derived(this.get_available_annotation_types());
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Gets the current selection
|
|
199
|
-
* @returns {Selection | undefined}
|
|
200
|
-
*/
|
|
201
|
-
get selection() {
|
|
202
|
-
return this.#selection;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Sets the selection with validation
|
|
207
|
-
* @param {Selection | undefined} value - The new selection
|
|
208
|
-
* @throws {Error} Throws if the selection is invalid
|
|
209
|
-
*/
|
|
210
|
-
set selection(value) {
|
|
211
|
-
// if (value !== undefined) {
|
|
212
|
-
// this._validate_selection(value);
|
|
213
|
-
// }
|
|
214
|
-
this.#selection = value;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Validates that a selection is within bounds and refers to valid paths.
|
|
219
|
-
*
|
|
220
|
-
* @param {Selection} selection - The selection to validate
|
|
221
|
-
* @throws {Error} Throws if the selection is invalid
|
|
222
|
-
* @private
|
|
223
|
-
*/
|
|
224
|
-
_validate_selection(selection) {
|
|
225
|
-
if (!selection) return; // no selection is a valid selection
|
|
226
|
-
if (selection && !Array.isArray(selection.path)) {
|
|
227
|
-
throw new Error('Selection must have a valid path');
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const selection_type = selection.type;
|
|
231
|
-
if (selection_type === 'node') {
|
|
232
|
-
// For node selections, path should point to a node_array
|
|
233
|
-
if (this.inspect(selection.path).type !== 'node_array') {
|
|
234
|
-
throw new Error(`Node selection path does not point to a node array: ${selection.path.join('.')}`);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const node_array = this.get(selection.path);
|
|
238
|
-
|
|
239
|
-
// Validate anchor_offset and focus_offset are within bounds
|
|
240
|
-
const max_offset = node_array.length;
|
|
241
|
-
|
|
242
|
-
if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
|
|
243
|
-
throw new Error(`Node selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${max_offset})`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
|
|
247
|
-
throw new Error(`Node selection focus_offset ${selection.focus_offset} is out of bounds (0-${max_offset})`);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
} else if (selection_type === 'text') {
|
|
251
|
-
// For text selections, path should point to an annotated_text property
|
|
252
|
-
const annotated_text = this.get(selection.path);
|
|
253
|
-
|
|
254
|
-
// Validate anchor_offset and focus_offset are within text bounds
|
|
255
|
-
const char_length = get_char_length(annotated_text.text);
|
|
256
|
-
|
|
257
|
-
if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
|
|
258
|
-
throw new Error(`Text selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${char_length})`);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
|
|
262
|
-
throw new Error(`Text selection focus_offset ${selection.focus_offset} is out of bounds (0-${char_length})`);
|
|
263
|
-
}
|
|
264
|
-
} else if (selection_type === 'property') {
|
|
265
|
-
// For property selections, just validate the path exists
|
|
266
|
-
if (!this.inspect(selection.path)) {
|
|
267
|
-
throw new Error(`Property selection path not found: ${selection.path.join('.')}`);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
} else {
|
|
271
|
-
throw new Error(`Unknown selection type: ${selection_type}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* @param {DocumentSchema} schema - The document schema
|
|
277
|
-
* @param {SerializedDocument} serialized_doc - The serialized document array
|
|
278
|
-
* @param {object} [options] - Optional configuration
|
|
279
|
-
* @param {Selection} [options.selection] - Initial selection state
|
|
280
|
-
* @param {any} [options.config] - Document configuration
|
|
281
|
-
*/
|
|
282
|
-
constructor(schema, serialized_doc, { selection, config } = {}) {
|
|
283
|
-
// Validate the schema first
|
|
284
|
-
validate_document_schema(schema);
|
|
285
|
-
|
|
286
|
-
this.schema = schema;
|
|
287
|
-
this.config = config;
|
|
288
|
-
this.nodes = {};
|
|
289
|
-
|
|
290
|
-
// Initialize and validate nodes one by one
|
|
291
|
-
// This ensures references only point to already-loaded nodes
|
|
292
|
-
for (const node of serialized_doc) {
|
|
293
|
-
this.validate_node(node);
|
|
294
|
-
this.nodes[node.id] = node;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// The last element in the serialized_doc is the document itself (the root node)
|
|
298
|
-
this.document_id = serialized_doc.at(-1)?.id;
|
|
299
|
-
|
|
300
|
-
// Set selection after nodes are initialized so validation can work properly
|
|
301
|
-
this.selection = selection;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
generate_id() {
|
|
305
|
-
if (this.config.generate_id) {
|
|
306
|
-
return this.config.generate_id();
|
|
307
|
-
} else {
|
|
308
|
-
return crypto.randomUUID();
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
get_available_annotation_types() {
|
|
313
|
-
if (this.selection?.type !== 'text') return [];
|
|
314
|
-
const path = this.selection.path;
|
|
315
|
-
const property_definition = this.inspect(path);
|
|
316
|
-
return property_definition.node_types || [];
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Helper function to get the currently selected node
|
|
320
|
-
get_selected_node() {
|
|
321
|
-
if (!this.selection) return null;
|
|
322
|
-
|
|
323
|
-
if (this.selection.type === 'node') {
|
|
324
|
-
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
325
|
-
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
326
|
-
// Only consider selection of a single node
|
|
327
|
-
if (end - start !== 1) return null;
|
|
328
|
-
const node_array = this.get(this.selection.path);
|
|
329
|
-
const node_id = node_array[start];
|
|
330
|
-
return node_id ? this.get(node_id) : null;
|
|
331
|
-
} else {
|
|
332
|
-
// we are assuming we are either in a text or property (=custom) selection
|
|
333
|
-
const owner_node_path = this.selection?.path?.slice(0, -1);
|
|
334
|
-
if (!owner_node_path) return null;
|
|
335
|
-
const owner_node = this.get(owner_node_path);
|
|
336
|
-
return owner_node;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// NOTE: This code is a bit whacky, but works for now.
|
|
341
|
-
// TODO: Refactor as we settle on a final API
|
|
342
|
-
get_layout_node() {
|
|
343
|
-
if (!this.selected_node) return null;
|
|
344
|
-
|
|
345
|
-
// The selected node already is a layout node
|
|
346
|
-
if (this.selected_node.layout) {
|
|
347
|
-
return this.selected_node;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// We resolve the parent node if available, and return it if it's a layout node.
|
|
351
|
-
// NOTE: We only support one level atm, we may want to implement this recursively
|
|
352
|
-
if (this.selection.type === 'node') {
|
|
353
|
-
const parent_node = this.get(this.selection.path.slice(0, -1));
|
|
354
|
-
return parent_node.layout ? parent_node : null;
|
|
355
|
-
} else {
|
|
356
|
-
// We are either in a text or property (=custom) selection
|
|
357
|
-
const parent_node_path = this.selection?.path?.slice(0, -3);
|
|
358
|
-
if (!parent_node_path) return null;
|
|
359
|
-
const parent_node = this.get(parent_node_path);
|
|
360
|
-
return parent_node.layout ? parent_node : null;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Internal unsafe function: Never call this directly
|
|
365
|
-
_apply_op(op) {
|
|
366
|
-
const [type, ...args] = op;
|
|
367
|
-
if (type === 'set') {
|
|
368
|
-
const node = this.get(args[0].slice(0, -1));
|
|
369
|
-
node[args[0].at(-1)] = args[1];
|
|
370
|
-
} else if (type === 'create') {
|
|
371
|
-
|
|
372
|
-
this.nodes[args[0].id] = args[0];
|
|
373
|
-
} else if (type === 'delete') {
|
|
374
|
-
delete this.nodes[args[0]];
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* Validate a node against the document schema.
|
|
380
|
-
* @param {any} node - The node to validate
|
|
381
|
-
* @throws {Error} Throws if the node is invalid
|
|
382
|
-
*/
|
|
383
|
-
validate_node(node) {
|
|
384
|
-
validate_node(node, this.schema, this.nodes);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Creates a new transaction for making atomic changes to the document.
|
|
389
|
-
*
|
|
390
|
-
* @returns {Transaction} A new transaction instance
|
|
391
|
-
*/
|
|
392
|
-
get tr() {
|
|
393
|
-
// We create a copy of the current document to avoid modifying the original
|
|
394
|
-
const transaction_doc = new Document(this.schema, this.to_json(), {
|
|
395
|
-
config: this.config,
|
|
396
|
-
selection: this.selection
|
|
397
|
-
});
|
|
398
|
-
return new Transaction(transaction_doc);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Applies a transaction to the document.
|
|
403
|
-
*
|
|
404
|
-
* @param {Transaction} transaction - The transaction to apply
|
|
405
|
-
*/
|
|
406
|
-
apply(transaction) {
|
|
407
|
-
this.nodes = transaction.doc.nodes; // No deep copy, trust transaction's evolved state
|
|
408
|
-
// Make sure selection gets a new reference (is rerendered)
|
|
409
|
-
this.selection = { ...transaction.doc.selection };
|
|
410
|
-
if (this.history_index < this.history.length - 1) {
|
|
411
|
-
this.history = this.history.slice(0, this.history_index + 1);
|
|
412
|
-
}
|
|
413
|
-
this.history.push({
|
|
414
|
-
ops: transaction.ops,
|
|
415
|
-
inverse_ops: transaction.inverse_ops,
|
|
416
|
-
selection_before: transaction.selection_before,
|
|
417
|
-
selection_after: this.selection
|
|
418
|
-
});
|
|
419
|
-
this.history_index++;
|
|
420
|
-
|
|
421
|
-
return this;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
undo() {
|
|
425
|
-
if (this.history_index < 0) {
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
const change = this.history[this.history_index];
|
|
429
|
-
const tr = this.tr;
|
|
430
|
-
change.inverse_ops.slice().reverse().forEach(op => tr.doc._apply_op(op));
|
|
431
|
-
this.nodes = tr.doc.nodes;
|
|
432
|
-
this.selection = change.selection_before;
|
|
433
|
-
this.history_index--;
|
|
434
|
-
return this;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
redo() {
|
|
438
|
-
if (this.history_index >= this.history.length - 1) {
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
this.history_index++;
|
|
442
|
-
const change = this.history[this.history_index];
|
|
443
|
-
const tr = this.tr;
|
|
444
|
-
change.ops.forEach(op => tr.doc._apply_op(op));
|
|
445
|
-
tr.set_selection(change.selection_after);
|
|
446
|
-
|
|
447
|
-
this.nodes = tr.doc.nodes;
|
|
448
|
-
this.selection = change.selection_after;
|
|
449
|
-
return this;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Gets a node instance or property value at the specified path.
|
|
454
|
-
* @param {DocumentPath|string} path - Path to the node or property
|
|
455
|
-
* @returns {any} Either a node instance object or the value of a property
|
|
456
|
-
* @example
|
|
457
|
-
* // Get a node by ID
|
|
458
|
-
* doc.get('list_1') // => { type: 'list', id: 'list_1', ... }
|
|
459
|
-
*
|
|
460
|
-
* @example
|
|
461
|
-
* // Get a node array property
|
|
462
|
-
* doc.get(['list_1', 'list_items']) // => [ 'list_item_1', 'list_item_2' ]
|
|
463
|
-
*
|
|
464
|
-
* @example
|
|
465
|
-
* // Get a specific node from an array
|
|
466
|
-
* doc.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
|
|
467
|
-
*
|
|
468
|
-
* @example
|
|
469
|
-
* // Get an annotated text property
|
|
470
|
-
* doc.get(['page_1', 'cover', 'title']) // => {text: 'Hello world', annotations: []}
|
|
471
|
-
*/
|
|
472
|
-
get(path) {
|
|
473
|
-
if (typeof path === 'string') {
|
|
474
|
-
path = [ path ];
|
|
475
|
-
}
|
|
476
|
-
if (!(Array.isArray(path) && path.length >= 1)) {
|
|
477
|
-
throw new Error(`Invalid path provided ${JSON.stringify(path)}`);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
let val = this.nodes[path[0]];
|
|
481
|
-
let val_type = 'node';
|
|
482
|
-
|
|
483
|
-
for (let i = 1; i < path.length; i++) {
|
|
484
|
-
const path_segment = path[i];
|
|
485
|
-
if (val_type === 'node') {
|
|
486
|
-
if (this.property_type(val.type, path_segment) === 'node_array') {
|
|
487
|
-
val = val[path_segment]; // e.g. for the page body ['list_1', 'paragraph_1']
|
|
488
|
-
val_type = 'node_array';
|
|
489
|
-
} else if (this.property_type(val.type, path_segment) === 'annotated_text') {
|
|
490
|
-
val = val[path_segment];
|
|
491
|
-
val_type = 'annotated_text';
|
|
492
|
-
} else if (this.property_type(val.type, path_segment) === 'node') {
|
|
493
|
-
val = this.nodes[val[path_segment]];
|
|
494
|
-
val_type = 'node';
|
|
495
|
-
} else if (['string_array', 'integer_array'].includes(this.property_type(val.type, path_segment))) {
|
|
496
|
-
val = val[path_segment];
|
|
497
|
-
val_type = 'value_array';
|
|
498
|
-
} else {
|
|
499
|
-
// Resolve value properties such as string, integer, datetime
|
|
500
|
-
val = val[path_segment];
|
|
501
|
-
val_type = 'value';
|
|
502
|
-
}
|
|
503
|
-
} else if (val_type === 'node_array') {
|
|
504
|
-
// We expect the val to be an array of node ids and the path_segment to be an array index
|
|
505
|
-
val = this.nodes[val[path_segment]];
|
|
506
|
-
val_type = 'node';
|
|
507
|
-
} else if (val_type === 'value_array') {
|
|
508
|
-
val = val[path_segment];
|
|
509
|
-
val_type = 'value';
|
|
510
|
-
} else if (val_type === 'annotated_text') {
|
|
511
|
-
if (path_segment === 'text') {
|
|
512
|
-
val = val.text;
|
|
513
|
-
val_type = 'value';
|
|
514
|
-
} else if (path_segment === 'annotations') {
|
|
515
|
-
val = val.annotations;
|
|
516
|
-
val_type = 'annotation_array';
|
|
517
|
-
} else {
|
|
518
|
-
throw new Error(`Invalid path segment "${path_segment}" for annotated_text. Use "text" or "annotations".`);
|
|
519
|
-
}
|
|
520
|
-
} else if (val_type === 'annotation_array') {
|
|
521
|
-
val = val[path_segment];
|
|
522
|
-
val_type = 'annotation';
|
|
523
|
-
} else if (val_type === 'annotation') {
|
|
524
|
-
if (path_segment === 'node_id') {
|
|
525
|
-
val = this.nodes[val.node_id];
|
|
526
|
-
val_type = 'node';
|
|
527
|
-
} else if (path_segment === 'start_offset') {
|
|
528
|
-
val = val.start_offset;
|
|
529
|
-
val_type = 'value';
|
|
530
|
-
} else if (path_segment === 'end_offset') {
|
|
531
|
-
val = val.end_offset;
|
|
532
|
-
val_type = 'value';
|
|
533
|
-
} else {
|
|
534
|
-
throw new Error(`Invalid path segment "${path_segment}" for annotation. Use "start_offset", "end_offset", or "node_id".`);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
return val;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* While .get gives you the value of a path, inspect gives you
|
|
543
|
-
* the type info of that value.
|
|
544
|
-
*
|
|
545
|
-
* @todo The layout of these should be improved and more explictly typed
|
|
546
|
-
*
|
|
547
|
-
* @example
|
|
548
|
-
* doc.inspect(['page_1', 'body']) => {
|
|
549
|
-
* kind: 'property',
|
|
550
|
-
* name: 'body',
|
|
551
|
-
* type: 'node_array',
|
|
552
|
-
* node_types: ['text', 'story', 'list'],
|
|
553
|
-
* default_node_type: 'text'
|
|
554
|
-
* }
|
|
555
|
-
*
|
|
556
|
-
* @example
|
|
557
|
-
* doc.inspect(['page_1', 'body', 1]) => {
|
|
558
|
-
* kind: 'node',
|
|
559
|
-
* id: 'paragraph_234',
|
|
560
|
-
* type: 'paragraph',
|
|
561
|
-
* properties: {...}
|
|
562
|
-
* }
|
|
563
|
-
*
|
|
564
|
-
* @param {DocumentPath} path
|
|
565
|
-
* @returns {{kind: 'property'|'node', [key: string]: any}}
|
|
566
|
-
*/
|
|
567
|
-
inspect(path) {
|
|
568
|
-
const parent = path.length > 1 ? this.get(path.slice(0, -1)) : undefined;
|
|
569
|
-
if (parent?.type) {
|
|
570
|
-
// Parent is a node, so we are dealing with a property.
|
|
571
|
-
const property_name = path.at(-1);
|
|
572
|
-
return {
|
|
573
|
-
kind: 'property',
|
|
574
|
-
name: property_name,
|
|
575
|
-
// Merge property spec from schema
|
|
576
|
-
...this.schema[parent.type].properties[property_name]
|
|
577
|
-
};
|
|
578
|
-
} else {
|
|
579
|
-
// Parent is a property (or we are at the root), so we are dealing with a node.
|
|
580
|
-
const node = this.get(path);
|
|
581
|
-
return {
|
|
582
|
-
kind: 'node',
|
|
583
|
-
id: node.id,
|
|
584
|
-
type: node.type,
|
|
585
|
-
properties: this.schema[node.type]
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* Determines the kind of a node ('block' for structured blocks, 'text' for pure
|
|
592
|
-
* text nodes or 'annotation' for annotation nodes.
|
|
593
|
-
* @param {any} node
|
|
594
|
-
* @returns {NodeKind}
|
|
595
|
-
*/
|
|
596
|
-
kind(node) {
|
|
597
|
-
return this.schema[node.type].kind;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Determines whether a node type can be inserted at a given selection.
|
|
602
|
-
* @param {string} node_type - The type of node to insert.
|
|
603
|
-
* @param {Selection} [selection] - The selection at which to insert the node.
|
|
604
|
-
* @returns {boolean} True if the node type can be inserted, false otherwise.
|
|
605
|
-
*/
|
|
606
|
-
can_insert(node_type, selection = this.selection) {
|
|
607
|
-
if (selection?.type === 'node') {
|
|
608
|
-
const property_definition = this.inspect(selection.path);
|
|
609
|
-
if (property_definition.node_types.includes(node_type)) {
|
|
610
|
-
return true;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// No insert position found yet, and root not reached, try one level up if possible
|
|
615
|
-
let next_node_insert_cursor = this.get_next_node_insert_cursor(selection);
|
|
616
|
-
if (!next_node_insert_cursor) return false;
|
|
617
|
-
return this.can_insert(node_type, next_node_insert_cursor);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* Compute next possible insert position from a given selection
|
|
622
|
-
*
|
|
623
|
-
* @param {Selection} [selection] - Reference selection
|
|
624
|
-
* @returns {Selection} True if the paste operation was successful, false otherwise
|
|
625
|
-
*/
|
|
626
|
-
get_next_node_insert_cursor(selection = this.selection) {
|
|
627
|
-
// There's no parent path to insert into
|
|
628
|
-
if (!selection || selection.path.length <= 2) {
|
|
629
|
-
return null;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
const node_offset = parseInt(String(selection.path.at(-2)), 10) + 1;
|
|
633
|
-
return {
|
|
634
|
-
type: 'node',
|
|
635
|
-
path: selection.path.slice(0, -2),
|
|
636
|
-
anchor_offset: node_offset,
|
|
637
|
-
focus_offset: node_offset,
|
|
638
|
-
};
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
* Returns the annotation object that is currently "under the cursor".
|
|
643
|
-
* NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
|
|
644
|
-
*
|
|
645
|
-
* @param {string} annotation_type
|
|
646
|
-
* @returns {Annotation|null}
|
|
647
|
-
*/
|
|
648
|
-
active_annotation(annotation_type) {
|
|
649
|
-
if (this.selection?.type !== 'text') return null;
|
|
650
|
-
|
|
651
|
-
const { start, end } = this.get_selection_range();
|
|
652
|
-
const annotated_text = this.get(this.selection.path);
|
|
653
|
-
const annotations = annotated_text.annotations;
|
|
654
|
-
|
|
655
|
-
const active_annotation = annotations.find(({start_offset, end_offset}) =>
|
|
656
|
-
(start_offset <= start && end_offset > start) ||
|
|
657
|
-
(start_offset < end && end_offset >= end) ||
|
|
658
|
-
(start_offset >= start && end_offset <= end)
|
|
659
|
-
) || null;
|
|
660
|
-
|
|
661
|
-
if (annotation_type && active_annotation) {
|
|
662
|
-
const annotation_node = this.get(active_annotation?.node_id);
|
|
663
|
-
return annotation_node?.type === annotation_type ? active_annotation : null;
|
|
664
|
-
} else {
|
|
665
|
-
return active_annotation;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
get_selected_annotated_text() {
|
|
670
|
-
if (this.selection?.type !== 'text') return null;
|
|
671
|
-
|
|
672
|
-
const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
673
|
-
const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
674
|
-
const annotated_text = this.get(this.selection.path);
|
|
675
|
-
const text = char_slice(annotated_text.text, selection_start, selection_end);
|
|
676
|
-
const nodes = {};
|
|
677
|
-
const annotations = annotated_text.annotations.map(a => {
|
|
678
|
-
if (selection_start < a.end_offset && selection_end > a.start_offset) {
|
|
679
|
-
const sub_graph = this.traverse(a.node_id);
|
|
680
|
-
for (const node of sub_graph) {
|
|
681
|
-
if (!nodes[node.id]) {
|
|
682
|
-
nodes[node.id] = node;
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
return {
|
|
686
|
-
start_offset: Math.max(a.start_offset - selection_start, 0),
|
|
687
|
-
end_offset: Math.min(a.end_offset - selection_start, selection_end - selection_start),
|
|
688
|
-
node_id: a.node_id
|
|
689
|
-
};
|
|
690
|
-
} else {
|
|
691
|
-
return null;
|
|
692
|
-
}
|
|
693
|
-
}).filter(Boolean);
|
|
694
|
-
|
|
695
|
-
return { text, annotations, nodes };
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// TODO: think about ways how we can also turn a node selection into plain text.
|
|
699
|
-
get_selected_plain_text() {
|
|
700
|
-
if (this.selection?.type !== 'text') return null;
|
|
701
|
-
|
|
702
|
-
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
703
|
-
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
704
|
-
const annotated_text = this.get(this.selection.path);
|
|
705
|
-
return char_slice(annotated_text.text, start, end);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
get_selected_nodes() {
|
|
709
|
-
if (this.selection?.type !== 'node') return null;
|
|
710
|
-
|
|
711
|
-
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
712
|
-
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
713
|
-
const node_array = this.get(this.selection.path);
|
|
714
|
-
return $state.snapshot(node_array.slice(start, end));
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
select_parent() {
|
|
718
|
-
if (!this.selection) return;
|
|
719
|
-
if (['text', 'property'].includes(this.selection?.type)) {
|
|
720
|
-
// For text and property selections (e.g. ['page_1', 'body', 0, 'image']), we need to go up two levels
|
|
721
|
-
// in the path
|
|
722
|
-
if (this.selection.path.length > 3) {
|
|
723
|
-
const parent_path = this.selection.path.slice(0, -2);
|
|
724
|
-
const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
|
|
725
|
-
this.selection = {
|
|
726
|
-
type: 'node',
|
|
727
|
-
path: parent_path,
|
|
728
|
-
anchor_offset: current_index,
|
|
729
|
-
focus_offset: current_index + 1
|
|
730
|
-
};
|
|
731
|
-
} else {
|
|
732
|
-
this.selection = undefined;
|
|
733
|
-
}
|
|
734
|
-
} else if (this.selection.type === 'node') {
|
|
735
|
-
// For node selections, we go up one level
|
|
736
|
-
if (this.selection.path.length > 3) {
|
|
737
|
-
const parent_path = this.selection.path.slice(0, -2);
|
|
738
|
-
const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
|
|
739
|
-
|
|
740
|
-
this.selection = {
|
|
741
|
-
type: 'node',
|
|
742
|
-
path: parent_path,
|
|
743
|
-
anchor_offset: current_index,
|
|
744
|
-
focus_offset: current_index + 1
|
|
745
|
-
};
|
|
746
|
-
} else {
|
|
747
|
-
this.selection = undefined;
|
|
748
|
-
}
|
|
749
|
-
} else {
|
|
750
|
-
this.selection = undefined;
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
get_selection_range() {
|
|
755
|
-
if (this.selection && this.selection.type !== 'property') {
|
|
756
|
-
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
757
|
-
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
758
|
-
|
|
759
|
-
return {
|
|
760
|
-
start,
|
|
761
|
-
end,
|
|
762
|
-
length: end - start
|
|
763
|
-
};
|
|
764
|
-
} else {
|
|
765
|
-
return null;
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// Traverses the document and returns a JSON representation.
|
|
770
|
-
// IMPORTANT: Leaf nodes must go first, branches second and the root node (entry point) last (depth-first traversal)
|
|
771
|
-
// NOTE: Nodes that are not reachable from the entry point node will not be included in the serialization
|
|
772
|
-
traverse(node_id) {
|
|
773
|
-
return traverse(node_id, this.schema, $state.snapshot(this.nodes));
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* Convert the document to serialized format.
|
|
778
|
-
* @returns {SerializedDocument} The serialized document array
|
|
779
|
-
*/
|
|
780
|
-
to_json() {
|
|
781
|
-
return this.traverse(this.document_id);
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// property_type('page', 'body') => 'node_array'
|
|
785
|
-
// property_type('paragraph', 'content') => 'annotated_text'
|
|
786
|
-
property_type(type, property) {
|
|
787
|
-
if (typeof type !== 'string') throw new Error(`Invalid type ${type} provided`);
|
|
788
|
-
if (typeof property !== 'string') throw new Error(`Invalid property ${property} provided`);
|
|
789
|
-
// NOTE: Not sure if we should treat type and id as properties
|
|
790
|
-
if (property === 'type') return 'string';
|
|
791
|
-
if (property === 'id') return 'string';
|
|
792
|
-
|
|
793
|
-
if (!this.schema[type]) throw new Error(`Type ${type} not found in schema`);
|
|
794
|
-
if (!this.schema[type].properties[property]) throw new Error(`Property ${property} not found in type ${type}`);
|
|
795
|
-
|
|
796
|
-
return this.schema[type].properties[property].type;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Count how many times a node is referenced in the document
|
|
800
|
-
count_references(node_id) {
|
|
801
|
-
let count = 0;
|
|
802
|
-
|
|
803
|
-
for (const node of Object.values(this.nodes)) {
|
|
804
|
-
for (const [property, value] of Object.entries(node)) {
|
|
805
|
-
if (property === 'id' || property === 'type') continue;
|
|
806
|
-
|
|
807
|
-
const prop_type = this.property_type(node.type, property);
|
|
808
|
-
|
|
809
|
-
if (prop_type === 'node_array' && Array.isArray(value)) {
|
|
810
|
-
count += value.filter(id => id === node_id).length;
|
|
811
|
-
} else if (prop_type === 'node' && value === node_id) {
|
|
812
|
-
count += 1;
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
return count;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// Get all nodes referenced by a given node (recursively)
|
|
821
|
-
/**
|
|
822
|
-
* @param {NodeId} node_id
|
|
823
|
-
* @returns {NodeId[]}
|
|
824
|
-
*/
|
|
825
|
-
get_referenced_nodes(node_id) {
|
|
826
|
-
const traversed_nodes = this.traverse(node_id);
|
|
827
|
-
|
|
828
|
-
// Extract IDs and exclude the last element (root node)
|
|
829
|
-
return traversed_nodes
|
|
830
|
-
.slice(0, -1)
|
|
831
|
-
.map(node => node.id);
|
|
832
|
-
}
|
|
833
|
-
}
|