svedit 0.5.0 → 0.6.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/README.md +761 -120
- package/dist/AnnotatedTextProperty.svelte +122 -75
- 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 +1352 -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 +122 -75
- 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 +1352 -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,58 +1,77 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { getContext } from 'svelte';
|
|
3
|
-
import { char_slice, get_char_length, snake_to_pascal } from './
|
|
3
|
+
import { char_slice, get_char_length, snake_to_pascal, get_selection_range } from './utils.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** @import { AnnotatedTextPropertyProps, Annotation, Fragment, SelectionRange } from './types.d.ts'; */
|
|
6
6
|
|
|
7
7
|
const svedit = getContext('svedit');
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
let {
|
|
11
|
-
path,
|
|
12
|
-
class: css_class,
|
|
13
|
-
placeholder = '',
|
|
14
|
-
} = $props();
|
|
9
|
+
/** @type {AnnotatedTextPropertyProps} */
|
|
10
|
+
let { path, class: css_class, placeholder = '', tag = 'div' } = $props();
|
|
15
11
|
|
|
16
12
|
let is_focused = $derived.by(() => {
|
|
17
|
-
return
|
|
13
|
+
return (
|
|
14
|
+
svedit.session.selection?.type === 'text' &&
|
|
15
|
+
path.join('.') === svedit.session.selection?.path.join('.')
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Get selection highlight range if not inside an annotation
|
|
20
|
+
let selection_highlight_range = $derived.by(() => {
|
|
21
|
+
if (!is_focused) return null;
|
|
22
|
+
if (svedit.session.active_annotation()) return null;
|
|
23
|
+
const sel = svedit.session.selection;
|
|
24
|
+
if (!sel || sel.type !== 'text') return null;
|
|
25
|
+
return get_selection_range(sel);
|
|
18
26
|
});
|
|
19
27
|
|
|
20
28
|
/**
|
|
21
29
|
* Converts text with annotations into renderable fragments for display.
|
|
22
30
|
* @param {string} text - The plain text content
|
|
23
|
-
* @param {Array<Annotation>} annotations - Array of annotations
|
|
24
|
-
* @
|
|
31
|
+
* @param {Array<Annotation>} annotations - Array of annotations
|
|
32
|
+
* @param {SelectionRange} [selection_highlight_range] - Optional selection highlight range
|
|
33
|
+
* @returns {Array<Fragment>} Array of fragments
|
|
25
34
|
*/
|
|
26
|
-
function get_fragments(text, annotations) {
|
|
35
|
+
function get_fragments(text, annotations, selection_highlight_range) {
|
|
36
|
+
/** @type {Array<Fragment>} */
|
|
27
37
|
let fragments = [];
|
|
28
38
|
let last_index = 0;
|
|
29
39
|
|
|
30
|
-
//
|
|
31
|
-
const
|
|
40
|
+
// Merge annotations with selection highlight and sort by start offset
|
|
41
|
+
const ranges = [
|
|
42
|
+
...annotations,
|
|
43
|
+
...(selection_highlight_range ? [selection_highlight_range] : [])
|
|
44
|
+
].sort((a, b) => a.start_offset - b.start_offset);
|
|
32
45
|
|
|
33
|
-
for (
|
|
34
|
-
// Add text before
|
|
35
|
-
if (
|
|
36
|
-
fragments.push(char_slice(text, last_index,
|
|
46
|
+
for (const range of ranges) {
|
|
47
|
+
// Add text before this range
|
|
48
|
+
if (range.start_offset > last_index) {
|
|
49
|
+
fragments.push(char_slice(text, last_index, range.start_offset));
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
const content = char_slice(text, range.start_offset, range.end_offset);
|
|
53
|
+
|
|
54
|
+
if ('node_id' in range) {
|
|
55
|
+
const node = svedit.session.get(range.node_id);
|
|
56
|
+
if (!node) throw new Error(`Node not found for annotation ${range.node_id}`);
|
|
57
|
+
|
|
58
|
+
fragments.push({
|
|
59
|
+
type: 'annotation',
|
|
60
|
+
node,
|
|
61
|
+
content,
|
|
62
|
+
annotation_index: annotations.indexOf(/** @type {Annotation} */ (range))
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
fragments.push({
|
|
66
|
+
type: 'selection_highlight',
|
|
67
|
+
content
|
|
68
|
+
});
|
|
69
|
+
}
|
|
51
70
|
|
|
52
|
-
last_index =
|
|
71
|
+
last_index = range.end_offset;
|
|
53
72
|
}
|
|
54
73
|
|
|
55
|
-
// Add any remaining text
|
|
74
|
+
// Add any remaining text
|
|
56
75
|
if (last_index < get_char_length(text)) {
|
|
57
76
|
fragments.push(char_slice(text, last_index));
|
|
58
77
|
}
|
|
@@ -60,58 +79,86 @@
|
|
|
60
79
|
return fragments;
|
|
61
80
|
}
|
|
62
81
|
|
|
63
|
-
let fragments = $derived(
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
let fragments = $derived(
|
|
83
|
+
get_fragments(
|
|
84
|
+
svedit.session.get(path).text,
|
|
85
|
+
svedit.session.get(path).annotations,
|
|
86
|
+
selection_highlight_range
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
let plain_text = $derived(svedit.session.get(path).text);
|
|
90
|
+
let is_empty = $derived(
|
|
91
|
+
get_char_length(plain_text) === 0 && !(svedit.is_composing && is_focused)
|
|
92
|
+
);
|
|
66
93
|
</script>
|
|
67
94
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
{
|
|
95
|
+
<!-- ATTENTION: The comments are needed to prevent unwanted text nodes with whitespace. -->
|
|
96
|
+
<!-- We need to use <br> so the element is reachable by Arrow Up/Down navigation. -->
|
|
97
|
+
<!-- But as soon as the element is focused, we get rid of the <br> as it causes issues with caret positioning. -->
|
|
98
|
+
<!-- Before, we were just hiding it on focus, but that caused the cursor to disappear on Safari Desktop (tested with v26). -->
|
|
99
|
+
<!-- Edge Case: Shift Enter stops working if <br> is not present on a non-empty text property. -->
|
|
100
|
+
<svelte:element
|
|
101
|
+
this={tag}
|
|
102
|
+
data-type="text"
|
|
103
|
+
data-path={path.join('.')}
|
|
104
|
+
style="anchor-name: --{path.join('-')};"
|
|
105
|
+
class="text svedit-selectable {css_class}"
|
|
106
|
+
class:empty={is_empty}
|
|
107
|
+
class:focused={is_focused}
|
|
108
|
+
{placeholder}
|
|
109
|
+
>
|
|
110
|
+
{#each fragments as fragment, index (index)}
|
|
111
|
+
{#if typeof fragment === 'string'}{fragment}{:else if fragment.type === 'selection_highlight'}<span
|
|
112
|
+
class="selection-highlight"
|
|
113
|
+
style="anchor-name: --selection-highlight;">{fragment.content}</span
|
|
114
|
+
>{:else if fragment.type === 'annotation'}
|
|
115
|
+
{@const AnnotationComponent =
|
|
116
|
+
svedit.session.config.node_components[snake_to_pascal(fragment.node.type)]}
|
|
117
|
+
<AnnotationComponent
|
|
118
|
+
path={[...path, 'annotations', fragment.annotation_index, 'node_id']}
|
|
119
|
+
content={fragment.content}
|
|
120
|
+
/>
|
|
121
|
+
{/if}
|
|
122
|
+
{/each}<!--
|
|
123
|
+
-->{#if !is_focused || !is_empty}<br />{/if}
|
|
124
|
+
</svelte:element>
|
|
88
125
|
|
|
89
126
|
<style>
|
|
90
|
-
|
|
91
|
-
|
|
127
|
+
.text {
|
|
128
|
+
white-space: pre-wrap;
|
|
92
129
|
overflow-wrap: anywhere;
|
|
93
130
|
box-sizing: content-box;
|
|
94
131
|
|
|
95
132
|
&.heading1 {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
/*.text.focused {
|
|
133
|
+
text-wrap: var(--text-wrap);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/* We switch from ::before to ::after when the element is focused. So the the caret is always before the placeholder. */
|
|
137
|
+
[placeholder].empty:not(.focused)::before,
|
|
138
|
+
[placeholder].empty.focused::after {
|
|
139
|
+
content: attr(placeholder);
|
|
140
|
+
pointer-events: none;
|
|
141
|
+
color: color-mix(in oklch, currentcolor 50%, transparent);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* Hide flickering: in Chrome, the caret jumps from end of placeholder string to start of text property when we focus */
|
|
145
|
+
.text:not(.focused) {
|
|
146
|
+
caret-color: transparent;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/*.text.focused {
|
|
114
150
|
background: none;
|
|
115
151
|
outline: 1px solid var(--editing-stroke-color);
|
|
116
152
|
}*/
|
|
153
|
+
|
|
154
|
+
/* Dim the selection highlight when canvas loses native focus */
|
|
155
|
+
:global(.svedit-canvas:not(:focus)) .selection-highlight {
|
|
156
|
+
background: oklch(from var(--editing-fill-color) l 0.02 h / 0.3);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Make a collapsed cursor visible */
|
|
160
|
+
:global(.svedit-canvas:not(:focus)) .selection-highlight:empty {
|
|
161
|
+
background: none;
|
|
162
|
+
outline: 0.5px solid oklch(from var(--editing-stroke-color) l 0.02 h / 0.3);
|
|
163
|
+
}
|
|
117
164
|
</style>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AnnotatedTextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AnnotatedTextProperty.svelte.js"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"AnnotatedTextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AnnotatedTextProperty.svelte.js"],"names":[],"mappings":";;;;;AAuHA,2HAAoE"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for commands that can be executed in response to user actions
|
|
3
|
+
* like keyboard shortcuts, menu items, or toolbar buttons.
|
|
4
|
+
*
|
|
5
|
+
* Commands are stateful and UI-aware, unlike transforms which are pure functions.
|
|
6
|
+
* They can have derived state (like is_active for toggle commands) or their own
|
|
7
|
+
* state (like form inputs for prompt-based commands).
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```js
|
|
11
|
+
* class SaveCommand extends Command {
|
|
12
|
+
* is_enabled() {
|
|
13
|
+
* return this.context.editable;
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* async execute() {
|
|
17
|
+
* await update_document(this.context.session);
|
|
18
|
+
* this.context.editable = false;
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export default class Command {
|
|
24
|
+
/**
|
|
25
|
+
* Creates a new Command instance.
|
|
26
|
+
*
|
|
27
|
+
* @param {any} context - The context object providing access to application state
|
|
28
|
+
*/
|
|
29
|
+
constructor(context: any);
|
|
30
|
+
/**
|
|
31
|
+
* Derived state that indicates if the command is disabled.
|
|
32
|
+
* Automatically computed from is_enabled().
|
|
33
|
+
*/
|
|
34
|
+
disabled: boolean;
|
|
35
|
+
context: any;
|
|
36
|
+
/**
|
|
37
|
+
* Determines if the command can currently be executed.
|
|
38
|
+
* Override this method to implement command-specific logic.
|
|
39
|
+
*
|
|
40
|
+
* @returns {boolean} true if the command can be executed, false otherwise
|
|
41
|
+
*/
|
|
42
|
+
is_enabled(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Executes the command.
|
|
45
|
+
* Override this method to implement the command's behavior.
|
|
46
|
+
* Can be async for commands that need to perform asynchronous operations.
|
|
47
|
+
*
|
|
48
|
+
* @returns {void | Promise<void>}
|
|
49
|
+
*/
|
|
50
|
+
execute(): void | Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Command that undoes the last change to the document.
|
|
54
|
+
*/
|
|
55
|
+
export class UndoCommand extends Command {
|
|
56
|
+
is_enabled(): any;
|
|
57
|
+
execute(): void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Command that redoes the last undone change to the document.
|
|
61
|
+
*/
|
|
62
|
+
export class RedoCommand extends Command {
|
|
63
|
+
is_enabled(): any;
|
|
64
|
+
execute(): void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Command that selects the parent of the current selection.
|
|
68
|
+
* Useful for navigating up the document hierarchy.
|
|
69
|
+
*/
|
|
70
|
+
export class SelectParentCommand extends Command {
|
|
71
|
+
is_enabled(): any;
|
|
72
|
+
execute(): void;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Generic command that toggles an annotation on the current text selection.
|
|
76
|
+
* Used for simple annotations like bold, italic, highlight, etc.
|
|
77
|
+
*/
|
|
78
|
+
export class ToggleAnnotationCommand extends Command {
|
|
79
|
+
constructor(node_type: any, context: any);
|
|
80
|
+
node_type: any;
|
|
81
|
+
active: any;
|
|
82
|
+
is_active(): any;
|
|
83
|
+
is_enabled(): any;
|
|
84
|
+
execute(): void;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Command that adds a new line character at the current cursor position.
|
|
88
|
+
* Only works in text selections where newlines are allowed.
|
|
89
|
+
* Disabled on mobile browsers where Shift+Enter has different behavior.
|
|
90
|
+
*/
|
|
91
|
+
export class AddNewLineCommand extends Command {
|
|
92
|
+
is_enabled(): any;
|
|
93
|
+
execute(): void;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Command that breaks a text node at the cursor position.
|
|
97
|
+
* Creates a new node and splits the content between the current and new node.
|
|
98
|
+
* Only works in text selections.
|
|
99
|
+
*/
|
|
100
|
+
export class BreakTextNodeCommand extends Command {
|
|
101
|
+
execute(): void;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Command that selects all content in the current context.
|
|
105
|
+
* Progressively expands selection from text → node → parent node array.
|
|
106
|
+
*/
|
|
107
|
+
export class SelectAllCommand extends Command {
|
|
108
|
+
is_enabled(): any;
|
|
109
|
+
execute(): void;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Command that inserts a default node at the current cursor position.
|
|
113
|
+
* Only works when a collapsed node selection is active.
|
|
114
|
+
*/
|
|
115
|
+
export class InsertDefaultNodeCommand extends Command {
|
|
116
|
+
execute(): void;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=Command.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Command.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Command.svelte.js"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH;IAOC;;;;OAIG;IACH,qBAFW,GAAG,EAIb;IAbD;;;OAGG;IACH,kBAAwC;IAQvC,aAAsB;IAGvB;;;;;OAKG;IACH,cAFa,OAAO,CAInB;IAED;;;;;;OAMG;IACH,WAFa,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;GAEG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;;GAGG;AACH;IACC,kBAEC;IAED,gBAEC;CACD;AAED;;;GAGG;AACH;IACC,0CAGC;IADA,eAA0B;IAG3B,YAAoC;IAEpC,iBAEC;IAED,kBAWC;IAED,gBAEC;CACD;AAED;;;;GAIG;AACH;IACC,kBAUC;IAED,gBAEC;CACD;AAED;;;;GAIG;AACH;IAKC,gBAKC;CACD;AAED;;;GAGG;AACH;IACC,kBAEC;IAED,gBAwGC;CACD;AAED;;;GAGG;AACH;IAUC,gBAIC;CACD"}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { insert_default_node, break_text_node } from './transforms.svelte.js';
|
|
2
|
+
import { is_selection_collapsed, is_mobile_browser, get_char_length } from './utils.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base class for commands that can be executed in response to user actions
|
|
6
|
+
* like keyboard shortcuts, menu items, or toolbar buttons.
|
|
7
|
+
*
|
|
8
|
+
* Commands are stateful and UI-aware, unlike transforms which are pure functions.
|
|
9
|
+
* They can have derived state (like is_active for toggle commands) or their own
|
|
10
|
+
* state (like form inputs for prompt-based commands).
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```js
|
|
14
|
+
* class SaveCommand extends Command {
|
|
15
|
+
* is_enabled() {
|
|
16
|
+
* return this.context.editable;
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* async execute() {
|
|
20
|
+
* await update_document(this.context.session);
|
|
21
|
+
* this.context.editable = false;
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export default class Command {
|
|
27
|
+
/**
|
|
28
|
+
* Derived state that indicates if the command is disabled.
|
|
29
|
+
* Automatically computed from is_enabled().
|
|
30
|
+
*/
|
|
31
|
+
disabled = $derived(!this.is_enabled());
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a new Command instance.
|
|
35
|
+
*
|
|
36
|
+
* @param {any} context - The context object providing access to application state
|
|
37
|
+
*/
|
|
38
|
+
constructor(context) {
|
|
39
|
+
this.context = context;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Determines if the command can currently be executed.
|
|
44
|
+
* Override this method to implement command-specific logic.
|
|
45
|
+
*
|
|
46
|
+
* @returns {boolean} true if the command can be executed, false otherwise
|
|
47
|
+
*/
|
|
48
|
+
is_enabled() {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Executes the command.
|
|
54
|
+
* Override this method to implement the command's behavior.
|
|
55
|
+
* Can be async for commands that need to perform asynchronous operations.
|
|
56
|
+
*
|
|
57
|
+
* @returns {void | Promise<void>}
|
|
58
|
+
*/
|
|
59
|
+
execute() {
|
|
60
|
+
throw new Error('Not implemented');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Command that undoes the last change to the document.
|
|
66
|
+
*/
|
|
67
|
+
export class UndoCommand extends Command {
|
|
68
|
+
is_enabled() {
|
|
69
|
+
return this.context.editable && this.context.session.can_undo;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
execute() {
|
|
73
|
+
this.context.session.undo();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Command that redoes the last undone change to the document.
|
|
79
|
+
*/
|
|
80
|
+
export class RedoCommand extends Command {
|
|
81
|
+
is_enabled() {
|
|
82
|
+
return this.context.editable && this.context.session.can_redo;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
execute() {
|
|
86
|
+
this.context.session.redo();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Command that selects the parent of the current selection.
|
|
92
|
+
* Useful for navigating up the document hierarchy.
|
|
93
|
+
*/
|
|
94
|
+
export class SelectParentCommand extends Command {
|
|
95
|
+
is_enabled() {
|
|
96
|
+
return this.context.editable && this.context.session.selection;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
execute() {
|
|
100
|
+
this.context.session.select_parent();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Generic command that toggles an annotation on the current text selection.
|
|
106
|
+
* Used for simple annotations like bold, italic, highlight, etc.
|
|
107
|
+
*/
|
|
108
|
+
export class ToggleAnnotationCommand extends Command {
|
|
109
|
+
constructor(node_type, context) {
|
|
110
|
+
super(context);
|
|
111
|
+
this.node_type = node_type;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
active = $derived(this.is_active());
|
|
115
|
+
|
|
116
|
+
is_active() {
|
|
117
|
+
return this.context.session.active_annotation(this.node_type);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
is_enabled() {
|
|
121
|
+
const { session, editable } = this.context;
|
|
122
|
+
const has_annotation = session.active_annotation(this.node_type);
|
|
123
|
+
const no_annotation_and_cursor_not_collapsed =
|
|
124
|
+
!session.active_annotation() && !is_selection_collapsed(session.selection);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
editable &&
|
|
128
|
+
session.selection?.type === 'text' &&
|
|
129
|
+
(has_annotation || no_annotation_and_cursor_not_collapsed)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
execute() {
|
|
134
|
+
this.context.session.apply(this.context.session.tr.annotate_text(this.node_type));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Command that adds a new line character at the current cursor position.
|
|
140
|
+
* Only works in text selections where newlines are allowed.
|
|
141
|
+
* Disabled on mobile browsers where Shift+Enter has different behavior.
|
|
142
|
+
*/
|
|
143
|
+
export class AddNewLineCommand extends Command {
|
|
144
|
+
is_enabled() {
|
|
145
|
+
const session = this.context.session;
|
|
146
|
+
const selection = session.selection;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
this.context.editable &&
|
|
150
|
+
!is_mobile_browser() &&
|
|
151
|
+
selection?.type === 'text' &&
|
|
152
|
+
session.inspect(selection.path).allow_newlines
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
execute() {
|
|
157
|
+
this.context.session.apply(this.context.session.tr.insert_text('\n'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Command that breaks a text node at the cursor position.
|
|
163
|
+
* Creates a new node and splits the content between the current and new node.
|
|
164
|
+
* Only works in text selections.
|
|
165
|
+
*/
|
|
166
|
+
export class BreakTextNodeCommand extends Command {
|
|
167
|
+
is_enabled() {
|
|
168
|
+
return this.context.editable && this.context.session.selection?.type === 'text';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
execute() {
|
|
172
|
+
const tr = this.context.session.tr;
|
|
173
|
+
if (break_text_node(tr)) {
|
|
174
|
+
this.context.session.apply(tr);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Command that selects all content in the current context.
|
|
181
|
+
* Progressively expands selection from text → node → parent node array.
|
|
182
|
+
*/
|
|
183
|
+
export class SelectAllCommand extends Command {
|
|
184
|
+
is_enabled() {
|
|
185
|
+
return this.context.editable && this.context.session.selection;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
execute() {
|
|
189
|
+
const session = this.context.session;
|
|
190
|
+
const selection = session.selection;
|
|
191
|
+
|
|
192
|
+
if (!selection) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (selection.type === 'text') {
|
|
197
|
+
const text_content = session.get(selection.path);
|
|
198
|
+
const text_length = get_char_length(text_content.text);
|
|
199
|
+
|
|
200
|
+
// Check if all text is already selected
|
|
201
|
+
const is_all_text_selected =
|
|
202
|
+
Math.min(selection.anchor_offset, selection.focus_offset) === 0 &&
|
|
203
|
+
Math.max(selection.anchor_offset, selection.focus_offset) === text_length;
|
|
204
|
+
|
|
205
|
+
if (!is_all_text_selected) {
|
|
206
|
+
// Select all text in the current text node
|
|
207
|
+
session.selection = {
|
|
208
|
+
type: 'text',
|
|
209
|
+
path: selection.path,
|
|
210
|
+
anchor_offset: 0,
|
|
211
|
+
focus_offset: text_length
|
|
212
|
+
};
|
|
213
|
+
} else {
|
|
214
|
+
// All text is selected, move up to select the containing node
|
|
215
|
+
const node_path = selection.path.slice(0, -1); // Remove the property name (e.g., 'content')
|
|
216
|
+
|
|
217
|
+
// Check if we have enough path segments and if we're inside a node_array
|
|
218
|
+
if (node_path.length >= 2) {
|
|
219
|
+
const is_inside_node_array =
|
|
220
|
+
session.inspect(node_path.slice(0, -1))?.type === 'node_array';
|
|
221
|
+
|
|
222
|
+
if (is_inside_node_array) {
|
|
223
|
+
const node_index = parseInt(node_path.at(-1));
|
|
224
|
+
session.selection = {
|
|
225
|
+
type: 'node',
|
|
226
|
+
path: node_path.slice(0, -1),
|
|
227
|
+
anchor_offset: node_index,
|
|
228
|
+
focus_offset: node_index + 1
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Stop expanding - text is not in a selectable node_array
|
|
233
|
+
}
|
|
234
|
+
} else if (selection.type === 'node') {
|
|
235
|
+
const node_array_path = selection.path;
|
|
236
|
+
const node_array = session.get(node_array_path);
|
|
237
|
+
|
|
238
|
+
// Check if the entire node_array is already selected
|
|
239
|
+
const is_entire_node_array_selected =
|
|
240
|
+
Math.min(selection.anchor_offset, selection.focus_offset) === 0 &&
|
|
241
|
+
Math.max(selection.anchor_offset, selection.focus_offset) === node_array.length;
|
|
242
|
+
|
|
243
|
+
if (!is_entire_node_array_selected) {
|
|
244
|
+
// Select the entire node_array
|
|
245
|
+
session.selection = {
|
|
246
|
+
type: 'node',
|
|
247
|
+
path: node_array_path,
|
|
248
|
+
anchor_offset: 0,
|
|
249
|
+
focus_offset: node_array.length
|
|
250
|
+
};
|
|
251
|
+
} else {
|
|
252
|
+
// Entire node_array is selected, try to move up to parent node_array
|
|
253
|
+
const parent_path = node_array_path.slice(0, -1);
|
|
254
|
+
|
|
255
|
+
// Check if we have enough path segments and if parent is a valid node_array
|
|
256
|
+
if (parent_path.length >= 2) {
|
|
257
|
+
const is_parent_node_array =
|
|
258
|
+
session.inspect(parent_path.slice(0, -1))?.type === 'node_array';
|
|
259
|
+
|
|
260
|
+
if (is_parent_node_array) {
|
|
261
|
+
const parent_node_index = parseInt(parent_path.at(-1));
|
|
262
|
+
session.selection = {
|
|
263
|
+
type: 'node',
|
|
264
|
+
path: parent_path.slice(0, -1),
|
|
265
|
+
anchor_offset: parent_node_index,
|
|
266
|
+
focus_offset: parent_node_index + 1
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Stop expanding - we've reached the top level
|
|
271
|
+
}
|
|
272
|
+
} else if (selection.type === 'property') {
|
|
273
|
+
// For property selections, select the containing node
|
|
274
|
+
const node_path = selection.path.slice(0, -1);
|
|
275
|
+
|
|
276
|
+
// Check if we have enough path segments and if we're inside a node_array
|
|
277
|
+
if (node_path.length >= 2) {
|
|
278
|
+
const is_inside_node_array = session.inspect(node_path.slice(0, -1))?.type === 'node_array';
|
|
279
|
+
|
|
280
|
+
if (is_inside_node_array) {
|
|
281
|
+
const node_index = parseInt(node_path.at(-1));
|
|
282
|
+
session.selection = {
|
|
283
|
+
type: 'node',
|
|
284
|
+
path: node_path.slice(0, -1),
|
|
285
|
+
anchor_offset: node_index,
|
|
286
|
+
focus_offset: node_index + 1
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Stop expanding - property is not in a selectable node_array
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Command that inserts a default node at the current cursor position.
|
|
297
|
+
* Only works when a collapsed node selection is active.
|
|
298
|
+
*/
|
|
299
|
+
export class InsertDefaultNodeCommand extends Command {
|
|
300
|
+
is_enabled() {
|
|
301
|
+
const selection = this.context.session.selection;
|
|
302
|
+
return (
|
|
303
|
+
this.context.editable &&
|
|
304
|
+
selection?.type === 'node' &&
|
|
305
|
+
selection.anchor_offset === selection.focus_offset
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
execute() {
|
|
310
|
+
const tr = this.context.session.tr;
|
|
311
|
+
insert_default_node(tr);
|
|
312
|
+
this.context.session.apply(tr);
|
|
313
|
+
}
|
|
314
|
+
}
|