svedit 0.10.0 → 0.11.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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @import { DocumentSchema } from './types.d.ts'
3
+ */
4
+
5
+ export function normalize_line_endings(text) {
6
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
7
+ }
8
+
9
+ export function dedent_plain_text(plain_text) {
10
+ const lines = normalize_line_endings(plain_text).split('\n');
11
+ if (lines.length < 2) return plain_text;
12
+
13
+ const non_empty_lines = lines.filter((line) => line.trim().length > 0);
14
+ if (non_empty_lines.length === 0) return plain_text;
15
+
16
+ const indented_non_empty_lines = non_empty_lines.filter((line) => /^[\t ]+/.test(line));
17
+ const indented_line_ratio = indented_non_empty_lines.length / non_empty_lines.length;
18
+ if (indented_line_ratio < 0.8) return plain_text;
19
+
20
+ const leading_whitespace_lengths = indented_non_empty_lines
21
+ .map((line) => line.match(/^[\t ]+/)?.[0].length || 0)
22
+ .filter(Boolean);
23
+ if (leading_whitespace_lengths.length === 0) return plain_text;
24
+
25
+ const dedent_size = Math.min(...leading_whitespace_lengths);
26
+ if (dedent_size <= 0) return plain_text;
27
+
28
+ return lines
29
+ .map((line) => {
30
+ if (line.trim().length === 0) return line;
31
+ let removable_count = 0;
32
+ while (removable_count < dedent_size && removable_count < line.length) {
33
+ const char = line[removable_count];
34
+ if (char === ' ' || char === '\t') {
35
+ removable_count += 1;
36
+ } else {
37
+ break;
38
+ }
39
+ }
40
+ return line.slice(removable_count);
41
+ })
42
+ .join('\n');
43
+ }
44
+
45
+ export function split_plain_text_paragraphs(plain_text) {
46
+ return normalize_line_endings(plain_text)
47
+ .split(/\n{2,}/)
48
+ .map((fragment) => fragment.trim())
49
+ .filter(Boolean);
50
+ }
51
+
52
+ export function normalize_plain_text_for_single_line_property(plain_text) {
53
+ return normalize_line_endings(plain_text).replace(/\s*\n+\s*/g, ' ');
54
+ }
55
+
56
+ /**
57
+ * @param {string | null | undefined} node_type
58
+ * @param {DocumentSchema} schema
59
+ * @returns {string | null}
60
+ */
61
+ export function get_text_property_name(node_type, schema) {
62
+ if (!node_type) return null;
63
+ const node_schema = schema[node_type];
64
+ if (!node_schema || node_schema.kind !== 'text') return null;
65
+ if (node_schema.properties?.content?.type === 'annotated_text') return 'content';
66
+ return (
67
+ Object.entries(node_schema.properties).find(([, property_definition]) => {
68
+ return property_definition.type === 'annotated_text';
69
+ })?.[0] || null
70
+ );
71
+ }
72
+
73
+ /**
74
+ * @param {any} node
75
+ * @param {DocumentSchema} schema
76
+ * @returns {{ text: string, annotations: any[] } | null}
77
+ */
78
+ export function get_text_content(node, schema) {
79
+ if (!node || typeof node !== 'object') return null;
80
+
81
+ const text_property_name = get_text_property_name(node.type, schema);
82
+ if (
83
+ text_property_name &&
84
+ node[text_property_name] &&
85
+ typeof node[text_property_name].text === 'string' &&
86
+ Array.isArray(node[text_property_name].annotations)
87
+ ) {
88
+ return node[text_property_name];
89
+ }
90
+
91
+ if (node.content && typeof node.content.text === 'string' && Array.isArray(node.content.annotations)) {
92
+ return node.content;
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * @param {any} node
100
+ * @param {DocumentSchema} schema
101
+ * @returns {boolean}
102
+ */
103
+ export function is_text_like_node_payload(node, schema) {
104
+ if (!node || typeof node !== 'object') return false;
105
+ if (schema[node.type]?.kind === 'text') return true;
106
+ return !!get_text_content(node, schema);
107
+ }
108
+
109
+ /**
110
+ * @param {any} node_array_property_definition
111
+ * @param {DocumentSchema} schema
112
+ * @returns {string | null}
113
+ */
114
+ export function get_default_text_node(node_array_property_definition, schema) {
115
+ if (
116
+ node_array_property_definition?.type !== 'node_array' ||
117
+ !Array.isArray(node_array_property_definition.node_types)
118
+ ) {
119
+ return null;
120
+ }
121
+
122
+ const default_node_type = node_array_property_definition.default_node_type;
123
+ if (default_node_type && schema[default_node_type]?.kind === 'text') {
124
+ return default_node_type;
125
+ }
126
+
127
+ return (
128
+ node_array_property_definition.node_types.find((node_type) => schema[node_type]?.kind === 'text') ||
129
+ null
130
+ );
131
+ }
132
+
133
+ /**
134
+ * @param {string[]} paragraph_fragments
135
+ * @param {string | null | undefined} node_type
136
+ * @param {DocumentSchema} schema
137
+ * @returns {{ main_nodes: string[], nodes: Record<string, any> } | null}
138
+ */
139
+ export function create_plain_text_nodes_payload(paragraph_fragments, node_type, schema) {
140
+ if (!Array.isArray(paragraph_fragments) || paragraph_fragments.length === 0 || !node_type) {
141
+ return null;
142
+ }
143
+
144
+ const text_property_name = get_text_property_name(node_type, schema);
145
+ if (!text_property_name) return null;
146
+
147
+ const payload = {
148
+ main_nodes: [],
149
+ nodes: {}
150
+ };
151
+
152
+ for (let i = 0; i < paragraph_fragments.length; i++) {
153
+ const fragment = paragraph_fragments[i];
154
+ const node_id = 'fragment_' + i;
155
+ payload.nodes[node_id] = {
156
+ id: node_id,
157
+ type: node_type,
158
+ [text_property_name]: {
159
+ text: fragment,
160
+ annotations: []
161
+ }
162
+ };
163
+ payload.main_nodes.push(node_id);
164
+ }
165
+
166
+ return payload;
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -5,7 +5,6 @@
5
5
  get_char_length,
6
6
  paths_equal,
7
7
  serialize_path,
8
- snake_to_pascal,
9
8
  get_selection_range
10
9
  } from './utils.js';
11
10
 
@@ -109,7 +108,6 @@
109
108
 
110
109
  return fragments;
111
110
  }
112
-
113
111
  </script>
114
112
 
115
113
  <!-- ATTENTION: The comments are needed to prevent unwanted text nodes with whitespace. -->
@@ -133,8 +131,7 @@
133
131
  class="selection-highlight"
134
132
  style="anchor-name: --selection-highlight;">{fragment.content}</span
135
133
  >{:else if fragment.type === 'annotation'}
136
- {@const AnnotationComponent =
137
- svedit.session.config.node_components[snake_to_pascal(fragment.node.type)]}
134
+ {@const AnnotationComponent = svedit.session.config.node_components[fragment.node.type]}
138
135
  <AnnotationComponent
139
136
  path={[...path, 'annotations', fragment.annotation_index, 'node_id']}
140
137
  content={fragment.content}
@@ -167,7 +164,7 @@
167
164
  caret-color: transparent !important;
168
165
  }
169
166
  :global(.svedit.editable) [placeholder].empty.focused::before {
170
- content: "";
167
+ content: '';
171
168
  /* we limit width & height to avoid layout shifts in case the text has a lower natural height */
172
169
  width: 0px;
173
170
  height: 1cap;
@@ -1,6 +1,6 @@
1
1
  import { insert_default_node, break_text_node } from './transforms.svelte.js';
2
2
  import { can_switch_annotation_type } from './doc_utils.js';
3
- import { is_selection_collapsed, is_mobile_browser, get_char_length } from './utils.js';
3
+ import { is_selection_collapsed, is_mobile_browser, get_char_length, char_slice } from './utils.js';
4
4
 
5
5
  /**
6
6
  * Base class for commands that can be executed in response to user actions
@@ -169,7 +169,26 @@ export class AddNewLineCommand extends Command {
169
169
  }
170
170
 
171
171
  execute() {
172
- this.context.session.apply(this.context.session.tr.insert_text('\n'));
172
+ const session = this.context.session;
173
+ const selection = session.selection;
174
+ if (!selection || selection.type !== 'text') return;
175
+ if (!session.inspect(selection.path).allow_newlines) return;
176
+
177
+ const tr = session.tr;
178
+ if (selection.anchor_offset !== selection.focus_offset) {
179
+ tr.delete_selection();
180
+ }
181
+
182
+ const collapsed_offset = tr.selection.anchor_offset;
183
+ const content = tr.get(tr.selection.path);
184
+ const text_before_caret = char_slice(content.text, 0, collapsed_offset);
185
+ const line_start_index = text_before_caret.lastIndexOf('\n') + 1;
186
+ const current_line_prefix = text_before_caret.slice(line_start_index);
187
+ const indentation_match = current_line_prefix.match(/^[\t ]*/);
188
+ const indentation = indentation_match ? indentation_match[0] : '';
189
+
190
+ tr.insert_text(`\n${indentation}`);
191
+ session.apply(tr);
173
192
  }
174
193
  }
175
194
 
@@ -180,7 +199,15 @@ export class AddNewLineCommand extends Command {
180
199
  */
181
200
  export class BreakTextNodeCommand extends Command {
182
201
  is_enabled() {
183
- return this.context.editable && this.context.session.selection?.type === 'text';
202
+ const session = this.context.session;
203
+ const selection = session.selection;
204
+ if (!this.context.editable || selection?.type !== 'text') return false;
205
+
206
+ const owner_node = session.get(selection.path.slice(0, -1));
207
+ const owner_node_schema = owner_node ? session.schema[owner_node.type] : null;
208
+ if (!owner_node_schema || owner_node_schema.kind !== 'text') return false;
209
+
210
+ return session.inspect(selection.path.slice(0, -2))?.type === 'node_array';
184
211
  }
185
212
 
186
213
  execute() {
@@ -1,15 +1,17 @@
1
1
  <script>
2
2
  import { getContext, setContext } from 'svelte';
3
3
  import UnknownNode from './UnknownNode.svelte';
4
- import { serialize_path, snake_to_pascal } from './utils.js';
4
+ import { serialize_path } from './utils.js';
5
5
  import DefaultNodeGap from './NodeGap.svelte';
6
6
  import DefaultNodeGapMarkers from './NodeGapMarkers.svelte';
7
7
 
8
8
  /** @import { NodeArrayPropertyProps } from './types.d.ts'; */
9
9
 
10
10
  const svedit = getContext('svedit');
11
- let NodeGap = $derived(svedit.session.config.system_components?.NodeGap ?? DefaultNodeGap);
12
- let NodeGapMarkers = $derived(svedit.session.config.system_components?.NodeGapMarkers ?? DefaultNodeGapMarkers);
11
+ let NodeGap = $derived(svedit.session.config.system_components?.node_gap ?? DefaultNodeGap);
12
+ let NodeGapMarkers = $derived(
13
+ svedit.session.config.system_components?.node_gap_markers ?? DefaultNodeGapMarkers
14
+ );
13
15
 
14
16
  /** @type {NodeArrayPropertyProps} */
15
17
  let { path, tag = 'div', class: css_class, style = '', ...rest } = $props();
@@ -26,8 +28,8 @@
26
28
  // NodeGap while the caret is in the placeholder, so its <br> isn't a
27
29
  // second arrow-key stop (issue #260).
28
30
  let is_focused = $derived(
29
- svedit.session.selection?.type === 'node'
30
- && serialize_path(svedit.session.selection.path) === path_str
31
+ svedit.session.selection?.type === 'node' &&
32
+ serialize_path(svedit.session.selection.path) === path_str
31
33
  );
32
34
 
33
35
  setContext('node_array_meta', {
@@ -35,7 +37,6 @@
35
37
  return node_ids.length;
36
38
  }
37
39
  });
38
-
39
40
  </script>
40
41
 
41
42
  <!-- we use the anchor of node_array in Overlays.svelte to position the last insertion point in a horizontal layout based on the right edge of the container -->
@@ -55,21 +56,12 @@
55
56
  data-type="node"
56
57
  style="anchor-name: --{serialize_path([...path, 0])};"
57
58
  >
58
- <NodeGap
59
- array_path={path}
60
- offset={0}
61
- count={0}
62
- empty
63
- />
59
+ <NodeGap array_path={path} offset={0} count={0} empty />
64
60
  </div>
65
61
  {/if}
66
62
  {#each nodes as node, index (index)}
67
- <NodeGap
68
- array_path={path}
69
- offset={index}
70
- count={nodes.length}
71
- />
72
- {@const Component = svedit.session.config.node_components[snake_to_pascal(node.type)]}
63
+ <NodeGap array_path={path} offset={index} count={nodes.length} />
64
+ {@const Component = svedit.session.config.node_components[node.type]}
73
65
  {#if Component}
74
66
  <Component path={[...path, index]} />
75
67
  {:else}
@@ -77,11 +69,7 @@
77
69
  {/if}
78
70
  {/each}
79
71
  {#if node_ids.length > 0}
80
- <NodeGap
81
- array_path={path}
82
- offset={node_ids.length}
83
- count={node_ids.length}
84
- />
72
+ <NodeGap array_path={path} offset={node_ids.length} count={node_ids.length} />
85
73
  {/if}
86
74
  {#if svedit.editable && NodeGapMarkers}
87
75
  <NodeGapMarkers {path} count={node_ids.length} />
@@ -89,7 +77,7 @@
89
77
  </svelte:element>
90
78
 
91
79
  <style>
92
- /* position: relative makes this the containing block for the gap's
80
+ /* position: relative makes this the containing block for the gap's
93
81
  .svedit-selectable, which fills it via inset:0. You may override to
94
82
  position: absolute (e.g. so an empty array doesn't occupy flow space)
95
83
  — still a containing block; then also set position: relative on the
@@ -102,4 +90,4 @@
102
90
  min-width: 24px;
103
91
  cursor: pointer;
104
92
  }
105
- </style>
93
+ </style>
@@ -2,7 +2,7 @@
2
2
  function cycle_z_index(/** @type {MouseEvent} */ e) {
3
3
  const el = /** @type {HTMLElement} */ (e.currentTarget);
4
4
  if (el.hasAttribute('data-sent-to-back')) {
5
- document.querySelectorAll('.svedit-selectable[data-sent-to-back]').forEach(prev => {
5
+ document.querySelectorAll('.svedit-selectable[data-sent-to-back]').forEach((prev) => {
6
6
  prev.removeAttribute('data-sent-to-back');
7
7
  });
8
8
  }
@@ -50,7 +50,7 @@
50
50
  * Row/column detection uses var(--row, 1) with the * 99999 multiplier
51
51
  * trick — no @container style() queries, works in all browsers.
52
52
  *
53
- * If you override this component via `system_components.NodeGap`, make
53
+ * If you override this component via `system_components.node_gap`, make
54
54
  * sure your custom component also handles read-only mode by rendering a
55
55
  * plain `.node-gap` placeholder without editable internals.
56
56
  */
@@ -74,7 +74,7 @@
74
74
  });
75
75
 
76
76
  let anchor_name = $derived.by(() => {
77
- if (!is_editable) return '';
77
+ if (!is_editable) return '';
78
78
  if (is_first) return `--g-${serialize_path([...array_path, 0])}-gap-before`;
79
79
  return `--g-${serialize_path([...array_path, offset - 1])}-gap-after`;
80
80
  });
@@ -93,7 +93,9 @@
93
93
  style={gap_style}
94
94
  >
95
95
  <!-- svelte-ignore a11y_no_static_element_interactions -->
96
- <div class="svedit-selectable" style="anchor-name:{anchor_name}" onpointerdown={cycle_z_index}><br /></div>
96
+ <div class="svedit-selectable" style="anchor-name:{anchor_name}" onpointerdown={cycle_z_index}>
97
+ <br />
98
+ </div>
97
99
  </div>
98
100
  {:else}
99
101
  <div class="node-gap"></div>
@@ -161,8 +163,14 @@
161
163
  --_s-r: anchor(var(--_pa) right, 9999999px);
162
164
  }
163
165
  :global(.node-gap.positioned.gap-before:not(.empty)) {
164
- --_b-t: anchor(var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) top, 0px);
165
- --_b-l: anchor(var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) left, 0px);
166
+ --_b-t: anchor(
167
+ var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) top,
168
+ 0px
169
+ );
170
+ --_b-l: anchor(
171
+ var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) left,
172
+ 0px
173
+ );
166
174
  }
167
175
  :global(.node-gap.positioned.gap-after:not(.last)) {
168
176
  --_n-t: anchor(var(--_next) top);
@@ -175,10 +183,22 @@
175
183
  --_c-b: anchor(var(--_container) bottom);
176
184
  --_c-l: anchor(var(--_container) left);
177
185
  --_c-r: anchor(var(--_container) right);
178
- --_b-b: anchor(var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom, 0px);
179
- --_b-r: anchor(var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right, 0px);
180
- --_b-bt: anchor(var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom, 9999999px);
181
- --_b-rl: anchor(var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right, 9999999px);
186
+ --_b-b: anchor(
187
+ var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom,
188
+ 0px
189
+ );
190
+ --_b-r: anchor(
191
+ var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right,
192
+ 0px
193
+ );
194
+ --_b-bt: anchor(
195
+ var(--node-caret-boundary-y, var(--node-caret-boundary, --_no-boundary)) bottom,
196
+ 9999999px
197
+ );
198
+ --_b-rl: anchor(
199
+ var(--node-caret-boundary-x, var(--node-caret-boundary, --_no-boundary)) right,
200
+ 9999999px
201
+ );
182
202
  }
183
203
 
184
204
  :global(.node-gap.positioned .svedit-selectable) {
@@ -226,10 +246,8 @@
226
246
  calc(var(--_s-l) + var(--_R) * 9999999px),
227
247
  calc(var(--_s-r) + var(--_C) * 9999999px),
228
248
  calc(
229
- (var(--_s-r) + var(--_n-l)) / 2
230
- - var(--_gm) / 2
231
- + max(0px, var(--_s-r) - var(--_n-l)) * 999
232
- + var(--_C) * 9999999px
249
+ (var(--_s-r) + var(--_n-l)) / 2 - var(--_gm) / 2 + max(0px, var(--_s-r) - var(--_n-l)) *
250
+ 999 + var(--_C) * 9999999px
233
251
  ),
234
252
  /* Safety clamp for wrap: pins gap inside CB when current/next
235
253
  wrap across rows. Disabled in nowrap/horizontal-scroll where
@@ -237,35 +255,25 @@
237
255
  the other branches position correctly and this clamp would
238
256
  wrongly force the gap to CB right minus eg. */
239
257
  calc(
240
- 100% - var(--_eg)
241
- + max(0px, var(--_n-l) - var(--_s-r) + 0.5px) * 9999
242
- + var(--_C) * 9999999px
243
- )
258
+ 100% - var(--_eg) + max(0px, var(--_n-l) - var(--_s-r) + 0.5px) * 9999 + var(--_C) *
259
+ 9999999px
260
+ )
244
261
  );
245
262
  right: min(
246
263
  calc(var(--_s-r) + var(--_R) * 9999999px),
247
264
  calc(
248
265
  max(
249
- 0px,
250
- min(
251
- var(--_n-l),
252
- calc(
253
- (var(--_s-r) + var(--_n-l)) / 2
254
- - var(--_gm) / 2
255
- ),
256
- max(
257
- min(
258
- calc(var(--_c-r) - var(--_eg)),
259
- calc(var(--_s-r) - var(--_eg))
260
- ),
261
- calc(
262
- var(--_s-r)
263
- - (var(--_n-l) - var(--_s-r)) * 999
266
+ 0px,
267
+ min(
268
+ var(--_n-l),
269
+ calc((var(--_s-r) + var(--_n-l)) / 2 - var(--_gm) / 2),
270
+ max(
271
+ min(calc(var(--_c-r) - var(--_eg)), calc(var(--_s-r) - var(--_eg))),
272
+ calc(var(--_s-r) - (var(--_n-l) - var(--_s-r)) * 999)
264
273
  )
265
274
  )
266
- )
267
- )
268
- + var(--_C) * 9999999px
275
+ ) +
276
+ var(--_C) * 9999999px
269
277
  )
270
278
  );
271
279
  min-height: calc(var(--_gm) * var(--_C));
@@ -307,23 +315,13 @@
307
315
  left: min(
308
316
  calc(var(--_s-l) + var(--_R) * 9999999px),
309
317
  calc(min(var(--_s-r), calc(var(--_b-rl) - var(--_eg))) + var(--_C) * 9999999px),
310
- calc(
311
- 100% - var(--_eg)
312
- + max(0px, var(--_s-r) - 100% + 0.5px) * 9999
313
- + var(--_C) * 9999999px
314
- )
318
+ calc(100% - var(--_eg) + max(0px, var(--_s-r) - 100% + 0.5px) * 9999 + var(--_C) * 9999999px)
315
319
  );
316
320
  right: min(
317
321
  calc(var(--_s-r) + var(--_R) * 9999999px),
318
322
  calc(
319
- max(
320
- var(--_b-r),
321
- min(
322
- calc(var(--_c-r) - var(--_eg)),
323
- calc(var(--_s-r) - var(--_eg))
324
- )
325
- )
326
- + var(--_C) * 9999999px
323
+ max(var(--_b-r), min(calc(var(--_c-r) - var(--_eg)), calc(var(--_s-r) - var(--_eg)))) +
324
+ var(--_C) * 9999999px
327
325
  )
328
326
  );
329
327
  min-height: calc(var(--_eg) * var(--_C));
@@ -333,10 +331,7 @@
333
331
  /* Before first node: col extends up, row extends left */
334
332
  :global(.node-gap.positioned.gap-before:not(.empty) .svedit-selectable) {
335
333
  top: min(
336
- calc(
337
- max(var(--_b-t), var(--_s-t) - var(--_eg))
338
- + var(--_R) * 9999999px
339
- ),
334
+ calc(max(var(--_b-t), var(--_s-t) - var(--_eg)) + var(--_R) * 9999999px),
340
335
  calc(var(--_s-t) + var(--_C) * 9999999px)
341
336
  );
342
337
  bottom: min(
@@ -345,10 +340,7 @@
345
340
  );
346
341
  left: min(
347
342
  calc(var(--_s-l) + var(--_R) * 9999999px),
348
- calc(
349
- max(var(--_b-l), var(--_s-l) - var(--_eg))
350
- + var(--_C) * 9999999px
351
- )
343
+ calc(max(var(--_b-l), var(--_s-l) - var(--_eg)) + var(--_C) * 9999999px)
352
344
  );
353
345
  right: min(
354
346
  calc(var(--_s-r) + var(--_R) * 9999999px),