svedit 0.8.0 → 0.10.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 +137 -4
- package/dist/AnnotatedTextProperty.svelte +41 -8
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.d.ts +0 -1
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +18 -3
- package/dist/CustomProperty.svelte +20 -9
- package/dist/CustomProperty.svelte.d.ts +2 -1
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/Node.svelte +7 -5
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +65 -42
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeCaret.svelte +0 -7
- package/dist/NodeGap.svelte +179 -81
- package/dist/NodeGap.svelte.d.ts +0 -2
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/NodeGapMarkers.svelte +251 -84
- package/dist/NodeGapMarkers.svelte.d.ts +2 -0
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
- package/dist/NodeSelectionMarkers.svelte +4 -3
- package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.d.ts +12 -1
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +40 -3
- package/dist/Svedit.svelte +340 -147
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +24 -5
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +103 -61
- package/dist/doc_utils.d.ts +64 -3
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +276 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -1
- package/dist/node_visibility.svelte.d.ts +9 -0
- package/dist/node_visibility.svelte.d.ts.map +1 -0
- package/dist/node_visibility.svelte.js +718 -0
- package/dist/styles/svedit-animations.css +8 -0
- package/dist/styles/svedit-colors.css +1 -0
- package/dist/types.d.ts +46 -3
- package/dist/utils.d.ts +52 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +109 -3
- package/package.json +3 -2
- package/src/lib/AnnotatedTextProperty.svelte +41 -8
- package/src/lib/Command.svelte.js +18 -3
- package/src/lib/CustomProperty.svelte +20 -9
- package/src/lib/Node.svelte +7 -5
- package/src/lib/NodeArrayProperty.svelte +65 -42
- package/src/lib/NodeCaret.svelte +0 -7
- package/src/lib/NodeGap.svelte +179 -81
- package/src/lib/NodeGapMarkers.svelte +251 -84
- package/src/lib/NodeSelectionMarkers.svelte +4 -3
- package/src/lib/Session.svelte.js +40 -3
- package/src/lib/Svedit.svelte +340 -147
- package/src/lib/Transaction.svelte.js +103 -61
- package/src/lib/doc_utils.js +276 -10
- package/src/lib/index.js +5 -1
- package/src/lib/node_visibility.svelte.js +718 -0
- package/src/lib/styles/svedit-animations.css +8 -0
- package/src/lib/styles/svedit-colors.css +1 -0
- package/src/lib/types.d.ts +46 -3
- package/src/lib/utils.js +109 -3
- package/dist/node_gap_computation.svelte.d.ts +0 -5
- package/dist/node_gap_computation.svelte.d.ts.map +0 -1
- package/dist/node_gap_computation.svelte.js +0 -621
- package/src/lib/node_gap_computation.svelte.js +0 -621
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Svedit
|
|
2
2
|
|
|
3
|
-
Svedit (think Svelte Edit) is a tiny library for building editable websites in Svelte.
|
|
3
|
+
Svedit (think Svelte Edit) is a tiny library for building editable websites in Svelte. Model content in JSON, render it with custom components, and let site owners **edit directly in the layout** — no CMS needed.
|
|
4
4
|
|
|
5
5
|
- Try the [Svedit demo](https://svedit.dev) — watch the debug output at the bottom of the page
|
|
6
6
|
- Try [Editable Website](https://editable.website) — utilizes Svedit to enable CMS-free editable websites
|
|
@@ -32,6 +32,8 @@ You can also install Svedit into an existing SvelteKit project with `npm install
|
|
|
32
32
|
|
|
33
33
|
**Chromeless canvas:** Svedit keeps the editing canvas chromeless, meaning there are no UI elements like toolbars or menus mingled with the content. You can interact with text directly, but everything else happens via tools shown in separate overlays or in the fixed toolbar.
|
|
34
34
|
|
|
35
|
+
**Native-first:** Svedit favors standardized native browser solutions over custom ones whenever possible — using browser-based rendering and the browser's selection APIs underneath, rather than reimplementing them (e.g. by building on [pretext](https://github.com/chenglou/pretext)). When we hit browser bugs, we try to get them fixed upstream (in collaboration with the W3C editing working group). Workarounds we add are intended to be temporary, until the underlying issue is fixed by browser vendors. And where we believe there's a better way to solve a problem than what's currently specified, we lobby for it to become a new (or improved) web standard.
|
|
36
|
+
|
|
35
37
|
## How it works
|
|
36
38
|
|
|
37
39
|
Svedit connects eight key pieces:
|
|
@@ -48,7 +50,7 @@ Svedit connects eight key pieces:
|
|
|
48
50
|
**The flow:**
|
|
49
51
|
- Define a schema → create a session → provide config → render with `<Svedit>` component
|
|
50
52
|
- User interactions trigger commands → commands create transactions → which run transforms to modify the document → session applies the transaction → Svelte's reactivity updates the UI
|
|
51
|
-
-
|
|
53
|
+
- Native DOM selections are mapped to Svedit's internal selection model
|
|
52
54
|
|
|
53
55
|
## Schema
|
|
54
56
|
|
|
@@ -255,10 +257,52 @@ const doc = {
|
|
|
255
257
|
};
|
|
256
258
|
```
|
|
257
259
|
|
|
260
|
+
### Document schema changes
|
|
261
|
+
|
|
262
|
+
Svedit validates documents against the current schema when you create a `Session`. If you change the schema, you are responsible for migrating existing documents before loading them.
|
|
263
|
+
|
|
264
|
+
For simple additive changes, defaults can help. When you add a new property, you can call `fill_document_defaults` before creating the session:
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
import { Session, fill_document_defaults } from 'svedit';
|
|
268
|
+
|
|
269
|
+
const document_schema = {
|
|
270
|
+
paragraph: {
|
|
271
|
+
kind: 'text',
|
|
272
|
+
properties: {
|
|
273
|
+
layout: { type: 'integer', default: 1 },
|
|
274
|
+
content: {
|
|
275
|
+
type: 'annotated_text',
|
|
276
|
+
allow_newlines: true
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const migrated_doc = fill_document_defaults(existing_doc, document_schema);
|
|
283
|
+
const session = new Session(document_schema, migrated_doc, config);
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
This only fills properties that are missing. Explicit schema `default` values are used when present; otherwise Svedit uses built-in defaults for value types such as strings, numbers, booleans, arrays, `node_array`, and `annotated_text`. Existing values are preserved, and the original document object is not mutated.
|
|
287
|
+
|
|
288
|
+
Defaults make it safe to add new defaultable properties, but they are not a replacement for real document migrations. If you rename a property, remove a property, split one property into several, change node types, or need to transform existing data, write your own migration first and use `fill_document_defaults` only as a helper where appropriate.
|
|
289
|
+
|
|
290
|
+
`tr.create` and `tr.build` also fill omitted defaults for newly-created nodes. For document migrations, declare explicit defaults whenever the built-in type default is not the value you want. Do not rely on default filling for schema changes that need a real migration.
|
|
291
|
+
|
|
258
292
|
## Config
|
|
259
293
|
|
|
260
294
|
Documents need a config object that tells Svedit how to render and manipulate your content. See the full example in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js).
|
|
261
295
|
|
|
296
|
+
Two optional hooks are especially useful when integrating custom media workflows:
|
|
297
|
+
|
|
298
|
+
- `handle_media_paste(session, pasted_media)`
|
|
299
|
+
Called when media is pasted. You can upload/process files, replace an existing media property, or return a node payload to insert new content.
|
|
300
|
+
See example implementation in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js).
|
|
301
|
+
|
|
302
|
+
- `handle_property_deletion(session, path)`
|
|
303
|
+
Called when deleting/cutting a property selection. Use it to define app-specific reset behavior (for example clearing an image property or resetting a referenced media node).
|
|
304
|
+
See example implementation in [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js).
|
|
305
|
+
|
|
262
306
|
```js
|
|
263
307
|
const session_config = {
|
|
264
308
|
// ID generator for creating new nodes
|
|
@@ -297,6 +341,7 @@ const session_config = {
|
|
|
297
341
|
- **`inserters`** - Functions that create blank nodes of each type and set up the selection
|
|
298
342
|
- **`create_commands_and_keymap`** - Factory function that creates commands and keybindings for an editor instance
|
|
299
343
|
- **`handle_image_paste`** - Optional handler for image paste events
|
|
344
|
+
- **`view_classes`** - Viewport visibility classes. Every node element gets `.in-view`, `.seen`, `.fully-in-view`, `.visible-top`, `.visible-bottom` toggled as it scrolls through the viewport. Useful for scrollytelling and reveal animations driven from CSS. **On by default**. Set `view_classes: false` to opt out — saves ~5 classList ops per IO entry (measurable scroll-FPS win at 200+ nodes) and narrows the IntersectionObserver thresholds from `[0, 0.98]` to `[0]` (halves callback frequency during scroll). Disable if your app doesn't read these classes from CSS.
|
|
300
345
|
|
|
301
346
|
The config is accessible throughout your app via `session.config`.
|
|
302
347
|
|
|
@@ -336,6 +381,18 @@ session.can_insert('paragraph') // Check if node type can be inserted
|
|
|
336
381
|
session.available_annotation_types // Annotation types allowed at current selection (derived)
|
|
337
382
|
```
|
|
338
383
|
|
|
384
|
+
#### Native selection as source of truth
|
|
385
|
+
|
|
386
|
+
Every selection that can exist in Svedit must have a DOM representation. This is a core design decision: the browser's native selection determines where the selection is, and `session.selection` is derived from it — not the other way around. If the DOM selection stops being the source of truth, selection mapping becomes ambiguous and Svedit loses the simplicity and determinism that the editor is built on.
|
|
387
|
+
|
|
388
|
+
This applies to all selection types:
|
|
389
|
+
|
|
390
|
+
- Text selections are native selections inside an `annotated_text` property.
|
|
391
|
+
- Node selections are native selections whose anchor and focus land inside the selected node range.
|
|
392
|
+
- Property selections are native selections inside the DOM representation of that property.
|
|
393
|
+
|
|
394
|
+
For example, a multi-node selection is still represented as a native browser selection that starts somewhere inside one node and ends somewhere inside another. Svedit maps that DOM selection to an internal node selection, hides the browser's visual selection, and renders `NodeSelectionMarkers` instead. The native selection remains in the DOM behind the scenes because `contenteditable` still needs it to do its job: pressing an arrow key should move the cursor from the current focus point to the closest valid next position, and Shift+Arrow should expand the selection from that same focus point. Without a native selection rendered at all times, the browser has no reliable starting point for cursor movement, selection expansion, or other editing behavior.
|
|
395
|
+
|
|
339
396
|
### Making changes
|
|
340
397
|
|
|
341
398
|
```js
|
|
@@ -345,6 +402,20 @@ tr.insert_nodes(['new_node_id']);
|
|
|
345
402
|
session.apply(tr); // Apply the transaction
|
|
346
403
|
```
|
|
347
404
|
|
|
405
|
+
#### Batching history entries
|
|
406
|
+
|
|
407
|
+
By default, every `session.apply(tr)` creates a new undo/redo entry. Pass `{ batch: true }` to merge the transaction into the previous history entry instead — useful for continuous interactions like dragging, where you want the entire gesture to undo as one step.
|
|
408
|
+
|
|
409
|
+
```js
|
|
410
|
+
session.apply(tr, { batch: true });
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Batched transactions merge into the current history entry as long as they arrive within a 2-second window of the batch start. After 2 seconds of inactivity, the next `apply` starts a fresh entry. To force a new entry immediately (e.g. on pointer up), reset the batch timer:
|
|
414
|
+
|
|
415
|
+
```js
|
|
416
|
+
session.last_batch_started = undefined;
|
|
417
|
+
```
|
|
418
|
+
|
|
348
419
|
### History
|
|
349
420
|
|
|
350
421
|
```js
|
|
@@ -924,6 +995,16 @@ Svedit provides specialized components for rendering different property types:
|
|
|
924
995
|
/>
|
|
925
996
|
```
|
|
926
997
|
|
|
998
|
+
> **One path = one DOM mount.** Within a single Svedit document, each node path
|
|
999
|
+
> may be mounted exactly once. Mounting the same path twice (e.g. rendering the
|
|
1000
|
+
> same `nav_items` array in both header and footer) breaks anchor positioning,
|
|
1001
|
+
> intersection tracking, id uniqueness, and selection mapping.
|
|
1002
|
+
> Svedit logs an error if it detects a duplicate mount.
|
|
1003
|
+
>
|
|
1004
|
+
> If you need to render shared content in multiple places, model it as distinct
|
|
1005
|
+
> node_arrays (`header_nav`, `footer_nav`) in the same document, or render it
|
|
1006
|
+
> from a separate Svedit instance.
|
|
1007
|
+
|
|
927
1008
|
**`<CustomProperty>`** - For custom properties like images or other non-text content:
|
|
928
1009
|
|
|
929
1010
|
```svelte
|
|
@@ -1053,8 +1134,59 @@ Further things to consider:
|
|
|
1053
1134
|
- Never apply `position: relative` to the direct parent of `<AnnotatedTextProperty>`, it will cause a [weird Safari bug](https://bsky.app/profile/michaelaufreiter.com/post/3lxvdqyxc622s) to destroy the DOM.
|
|
1054
1135
|
- Never apply `position: relative`, `position: absolute`, `position: fixed` to `<Node.svelte>` (data-type="node") in edit mode. Only `position: static` is permitted to allow css anchor positioning queries to resolve correctly.
|
|
1055
1136
|
- Never use an `<a>` tag inside a `contenteditable="true"` element, as it will cause unexpected behavior. Make it a `<div>` while editing, and an `<a>` in read-only mode (when `svedit.editable` is `false` ).
|
|
1056
|
-
- Avoid
|
|
1057
|
-
|
|
1137
|
+
- Avoid adding css `margin` to nodes inside node arrays when using flex or grid layouts. Use `gap` on the container instead. This ensures `NodeGap` and `NodeGapMarkers` render consistently. If you need to add a margin, add it to a child element of Node.
|
|
1138
|
+
|
|
1139
|
+
### Consistent DOM structure across modes
|
|
1140
|
+
|
|
1141
|
+
`<NodeArrayProperty>` always renders a `.node-gap` element before each node, regardless of whether the editor is in edit or read-only mode. In edit mode, this is the full `<NodeGap>` component with anchor positioning, hit areas, and caret support. In read-only mode, it's a plain `<div class="node-gap"></div>`.
|
|
1142
|
+
|
|
1143
|
+
This means the DOM structure is identical in both modes:
|
|
1144
|
+
|
|
1145
|
+
```html
|
|
1146
|
+
<!-- Same structure in edit and read-only mode -->
|
|
1147
|
+
<div data-type="node_array">
|
|
1148
|
+
<div class="node-gap">...</div><!-- First element is a node-gap -->
|
|
1149
|
+
<div data-type="node"><!-- first node --></div>
|
|
1150
|
+
<div class="node-gap">...</div>
|
|
1151
|
+
<div data-type="node"><!-- second node --></div>
|
|
1152
|
+
...
|
|
1153
|
+
<div class="node-gap">...</div><!-- Last element is a node-gap -->
|
|
1154
|
+
</div>
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
The `.node-gap` element uses `display: contents`, so it has no layout impact — it doesn't generate a box, doesn't affect flex/grid item counts, and doesn't consume space. It only serves as a DOM placeholder for consistent selector targeting.
|
|
1158
|
+
|
|
1159
|
+
Because `.node-gap` is always the actual first and last child, do not use `:first-child` or `:last-child` on nodes — they will never match. Use `:nth-child(1 of [data-type="node"])` and `:nth-last-child(1 of [data-type="node"])` instead.
|
|
1160
|
+
|
|
1161
|
+
CSS sibling selectors that target node adjacency only need one form:
|
|
1162
|
+
|
|
1163
|
+
```css
|
|
1164
|
+
/* Works in both edit and read-only mode */
|
|
1165
|
+
.node-text + .node-gap + .node-media {
|
|
1166
|
+
margin-block-start: 1rem;
|
|
1167
|
+
}
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
For `:nth-child` selectors, use the `of <selector>` syntax to skip over `.node-gap` elements and count only nodes:
|
|
1171
|
+
|
|
1172
|
+
```css
|
|
1173
|
+
/* Even nodes */
|
|
1174
|
+
.list-node-array > :nth-child(2n of [data-type="node"]) {
|
|
1175
|
+
background: var(--zebra-stripe);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/* Odd nodes */
|
|
1179
|
+
.list-node-array > :nth-child(2n-1 of [data-type="node"]) {
|
|
1180
|
+
background: var(--zebra-stripe);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/* Last node */
|
|
1184
|
+
.list-node-array > :nth-last-child(1 of [data-type="node"]) {
|
|
1185
|
+
border-bottom: none;
|
|
1186
|
+
}
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
Without the `of` filter, plain `:nth-child(even)` would count `.node-gap` divs and produce incorrect results. The [`of <selector>`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:nth-child#syntax) form is Baseline 2023 and supported in all modern browsers.
|
|
1058
1190
|
|
|
1059
1191
|
### Node array CSS tokens
|
|
1060
1192
|
|
|
@@ -1132,6 +1264,7 @@ For per-axis control, use `--node-caret-boundary-x` (left/right) and `--node-car
|
|
|
1132
1264
|
}
|
|
1133
1265
|
```
|
|
1134
1266
|
|
|
1267
|
+
|
|
1135
1268
|
## Beyond the README
|
|
1136
1269
|
|
|
1137
1270
|
The source code is compact and readable — less than 3000 LOC across a handful of files. We encourage you to explore it. The files in [`src/lib`](./src/lib) are the library code, while the files in [`src/routes`](./src/routes) are example code you can copy and adapt to your needs.
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { getContext } from 'svelte';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
char_slice,
|
|
5
|
+
get_char_length,
|
|
6
|
+
paths_equal,
|
|
7
|
+
serialize_path,
|
|
8
|
+
snake_to_pascal,
|
|
9
|
+
get_selection_range
|
|
10
|
+
} from './utils.js';
|
|
4
11
|
|
|
5
12
|
/** @import { AnnotatedTextPropertyProps, Annotation, Fragment, SelectionRange } from './types.d.ts'; */
|
|
6
13
|
|
|
@@ -9,10 +16,11 @@
|
|
|
9
16
|
/** @type {AnnotatedTextPropertyProps} */
|
|
10
17
|
let { path, class: css_class, placeholder = '', tag = 'div', style = '', ...rest } = $props();
|
|
11
18
|
|
|
19
|
+
let path_str = $derived(serialize_path(path));
|
|
20
|
+
|
|
12
21
|
let is_focused = $derived.by(() => {
|
|
13
22
|
return (
|
|
14
|
-
svedit.session.selection?.type === 'text' &&
|
|
15
|
-
path.join('.') === svedit.session.selection?.path.join('.')
|
|
23
|
+
svedit.session.selection?.type === 'text' && paths_equal(path, svedit.session.selection.path)
|
|
16
24
|
);
|
|
17
25
|
});
|
|
18
26
|
|
|
@@ -101,6 +109,7 @@
|
|
|
101
109
|
|
|
102
110
|
return fragments;
|
|
103
111
|
}
|
|
112
|
+
|
|
104
113
|
</script>
|
|
105
114
|
|
|
106
115
|
<!-- ATTENTION: The comments are needed to prevent unwanted text nodes with whitespace. -->
|
|
@@ -111,12 +120,11 @@
|
|
|
111
120
|
<svelte:element
|
|
112
121
|
this={tag}
|
|
113
122
|
data-type="text"
|
|
114
|
-
data-path={
|
|
115
|
-
style="anchor-name: --{
|
|
123
|
+
data-path={path_str}
|
|
124
|
+
style="anchor-name: --{path_str};{style}"
|
|
116
125
|
class="text svedit-selectable {css_class}"
|
|
117
126
|
class:empty={is_empty}
|
|
118
127
|
class:focused={is_focused}
|
|
119
|
-
class:editable={svedit.editable}
|
|
120
128
|
{placeholder}
|
|
121
129
|
{...rest}
|
|
122
130
|
>
|
|
@@ -137,11 +145,13 @@
|
|
|
137
145
|
</svelte:element>
|
|
138
146
|
|
|
139
147
|
<style>
|
|
140
|
-
|
|
148
|
+
/* Editable text base layout; :where() allows easy override without specificity conflicts. */
|
|
149
|
+
:where(.text) {
|
|
141
150
|
white-space: pre-wrap;
|
|
142
151
|
overflow-wrap: anywhere;
|
|
143
152
|
box-sizing: content-box;
|
|
144
153
|
}
|
|
154
|
+
|
|
145
155
|
/* We switch from ::before to ::after when the element is focused. So the the caret is always before the placeholder. */
|
|
146
156
|
[placeholder].empty:not(.focused)::before,
|
|
147
157
|
[placeholder].empty.focused::after {
|
|
@@ -150,13 +160,36 @@
|
|
|
150
160
|
color: color-mix(in oklch, currentcolor 50%, transparent);
|
|
151
161
|
}
|
|
152
162
|
|
|
163
|
+
/* A virtual caret: to fix the caret vertical alignment issue in Chrome and Firefox for empty focused contenteditable with placeholders */
|
|
164
|
+
/* Browser BUG: iOS Safari only considers the caret color set on the top contenteditable element, not on nested elements (e.g. the second selector doesn't work in iOS Safari) */
|
|
165
|
+
:global(.svedit.editable .svedit-canvas:has([placeholder].empty.focused)),
|
|
166
|
+
:global(.svedit.editable) [placeholder].empty.focused {
|
|
167
|
+
caret-color: transparent !important;
|
|
168
|
+
}
|
|
169
|
+
:global(.svedit.editable) [placeholder].empty.focused::before {
|
|
170
|
+
content: "";
|
|
171
|
+
/* we limit width & height to avoid layout shifts in case the text has a lower natural height */
|
|
172
|
+
width: 0px;
|
|
173
|
+
height: 1cap;
|
|
174
|
+
display: inline-block;
|
|
175
|
+
/* we use box-shadow to draw the caret shape, matching the native caret */
|
|
176
|
+
box-shadow:
|
|
177
|
+
0 -0.4cap 0 0.65px var(--svedit-caret-color, AccentColor),
|
|
178
|
+
0 0 0 0.65px var(--svedit-caret-color, AccentColor),
|
|
179
|
+
0 0.4cap 0 0.65px var(--svedit-caret-color, AccentColor);
|
|
180
|
+
animation: var(
|
|
181
|
+
--node-caret-animation,
|
|
182
|
+
node-caret-blink var(--node-caret-blink-duration, 1.1s) ease-in-out infinite
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
153
186
|
/* Hide flickering: in Chrome, the caret jumps from end of placeholder string to start of text property when we focus */
|
|
154
187
|
.text:not(.focused) {
|
|
155
188
|
caret-color: transparent;
|
|
156
189
|
}
|
|
157
190
|
|
|
158
191
|
/* Disable text-transform when editable and focused so users see original text */
|
|
159
|
-
.
|
|
192
|
+
:global(.svedit.editable) .text.focused {
|
|
160
193
|
text-transform: none !important;
|
|
161
194
|
}
|
|
162
195
|
|
|
@@ -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":";;;;;AA2IA,2HAAoE"}
|
package/dist/Command.svelte.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Command.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Command.svelte.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Command.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Command.svelte.js"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;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;IASC,gBAEC;CACD;AAED;;;GAGG;AACH;IACC,0CAGC;IADA,eAA0B;IAG3B,YAAoC;IAEpC,iBAEC;IAED,kBAqBC;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"}
|
package/dist/Command.svelte.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { insert_default_node, break_text_node } from './transforms.svelte.js';
|
|
2
|
+
import { can_switch_annotation_type } from './doc_utils.js';
|
|
2
3
|
import { is_selection_collapsed, is_mobile_browser, get_char_length } from './utils.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -93,7 +94,11 @@ export class RedoCommand extends Command {
|
|
|
93
94
|
*/
|
|
94
95
|
export class SelectParentCommand extends Command {
|
|
95
96
|
is_enabled() {
|
|
96
|
-
return
|
|
97
|
+
return (
|
|
98
|
+
this.context.editable &&
|
|
99
|
+
this.context.session.selection &&
|
|
100
|
+
this.context.session.selection.path.length > 3
|
|
101
|
+
);
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
execute() {
|
|
@@ -119,14 +124,24 @@ export class ToggleAnnotationCommand extends Command {
|
|
|
119
124
|
|
|
120
125
|
is_enabled() {
|
|
121
126
|
const { session, editable } = this.context;
|
|
127
|
+
const active_annotation = session.active_annotation();
|
|
122
128
|
const has_annotation = session.active_annotation(this.node_type);
|
|
129
|
+
const can_switch_annotation =
|
|
130
|
+
active_annotation &&
|
|
131
|
+
!has_annotation &&
|
|
132
|
+
session.available_annotation_types.includes(this.node_type) &&
|
|
133
|
+
can_switch_annotation_type(
|
|
134
|
+
session.schema,
|
|
135
|
+
session.get(active_annotation.node_id),
|
|
136
|
+
this.node_type
|
|
137
|
+
);
|
|
123
138
|
const no_annotation_and_caret_not_collapsed =
|
|
124
|
-
!
|
|
139
|
+
!active_annotation && !is_selection_collapsed(session.selection);
|
|
125
140
|
|
|
126
141
|
return (
|
|
127
142
|
editable &&
|
|
128
143
|
session.selection?.type === 'text' &&
|
|
129
|
-
(has_annotation || no_annotation_and_caret_not_collapsed)
|
|
144
|
+
(has_annotation || can_switch_annotation || no_annotation_and_caret_not_collapsed)
|
|
130
145
|
);
|
|
131
146
|
}
|
|
132
147
|
|
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import {
|
|
2
|
+
import { serialize_path } from './utils.js';
|
|
3
3
|
|
|
4
4
|
/** @import { CustomPropertyProps } from './types.d.ts'; */
|
|
5
|
-
const svedit = getContext('svedit');
|
|
6
5
|
|
|
7
6
|
/** @type {CustomPropertyProps} */
|
|
8
7
|
let { path, tag = 'div', class: css_class, children, style, ...rest } = $props();
|
|
8
|
+
let path_str = $derived(serialize_path(path));
|
|
9
|
+
|
|
9
10
|
</script>
|
|
10
11
|
|
|
11
12
|
<svelte:element
|
|
12
13
|
this={tag}
|
|
13
14
|
class={css_class}
|
|
14
15
|
data-type="property"
|
|
15
|
-
data-path={
|
|
16
|
-
style="anchor-name: --{
|
|
16
|
+
data-path={path_str}
|
|
17
|
+
style="anchor-name: --{path_str};{style ? ` ${style}` : ''}"
|
|
17
18
|
{...rest}
|
|
18
19
|
>
|
|
19
|
-
|
|
20
|
-
<div class="
|
|
21
|
-
|
|
22
|
-
</div>
|
|
23
|
-
{/if}
|
|
20
|
+
<div class="property-selectable">
|
|
21
|
+
<div class="svedit-selectable"><br /></div>
|
|
22
|
+
</div>
|
|
24
23
|
|
|
25
24
|
{@render children()}
|
|
26
25
|
</svelte:element>
|
|
@@ -37,5 +36,17 @@
|
|
|
37
36
|
bottom: 0;
|
|
38
37
|
z-index: 1;
|
|
39
38
|
outline: none;
|
|
39
|
+
/* Position the hidden selectable element at the bottom so the
|
|
40
|
+
browser's native scroll-to-caret ensures the full property
|
|
41
|
+
is visible, not just the top edge. */
|
|
42
|
+
display: none;
|
|
43
|
+
align-items: flex-end;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
}
|
|
46
|
+
:global(.svedit.editable) .property-selectable {
|
|
47
|
+
display: flex;
|
|
48
|
+
}
|
|
49
|
+
.svedit-selectable {
|
|
50
|
+
caret-color: transparent;
|
|
40
51
|
}
|
|
41
52
|
</style>
|
|
@@ -3,5 +3,6 @@ type CustomProperty = {
|
|
|
3
3
|
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
4
|
$set?(props: Partial<CustomPropertyProps>): void;
|
|
5
5
|
};
|
|
6
|
-
declare const CustomProperty: import("svelte").Component<
|
|
6
|
+
declare const CustomProperty: import("svelte").Component<CustomPropertyProps, {}, "">;
|
|
7
|
+
import type { CustomPropertyProps } from './types.d.ts';
|
|
7
8
|
//# sourceMappingURL=CustomProperty.svelte.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CustomProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/CustomProperty.svelte.js"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"CustomProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/CustomProperty.svelte.js"],"names":[],"mappings":";;;;;AA4BA,sFAA6D;yCApBnB,cAAc"}
|
package/dist/Node.svelte
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { getContext } from 'svelte';
|
|
3
|
+
import { serialize_path } from './utils.js';
|
|
3
4
|
|
|
4
5
|
/** @import { NodeProps } from './types.d.ts'; */
|
|
5
6
|
|
|
@@ -9,6 +10,7 @@
|
|
|
9
10
|
let { path, children, tag = 'div', class: css_class, style = '', ...rest } = $props();
|
|
10
11
|
|
|
11
12
|
let node = $derived(svedit.session.get(path));
|
|
13
|
+
let path_str = $derived(serialize_path(path));
|
|
12
14
|
|
|
13
15
|
const node_array_meta = getContext('node_array_meta');
|
|
14
16
|
let child_index = $derived(node_array_meta ? parseInt(String(path.at(-1)), 10) : -1);
|
|
@@ -17,13 +19,13 @@
|
|
|
17
19
|
</script>
|
|
18
20
|
|
|
19
21
|
<svelte:element
|
|
20
|
-
id={node.id}
|
|
21
22
|
this={tag}
|
|
22
|
-
|
|
23
|
+
id={node.id}
|
|
24
|
+
class="node-{node.type} {css_class}{is_first ? ' first' : ''}{is_last ? ' last' : ''}"
|
|
23
25
|
data-node-id={node.id}
|
|
24
|
-
data-path={
|
|
26
|
+
data-path={path_str}
|
|
25
27
|
data-type="node"
|
|
26
|
-
style="anchor-name: --{
|
|
28
|
+
style="anchor-name: --{path_str};{style}"
|
|
27
29
|
{...rest}
|
|
28
30
|
>
|
|
29
31
|
{@render children()}
|
|
@@ -35,4 +37,4 @@
|
|
|
35
37
|
/* For developers who need to position their node with `position: absolute` or `position: relative`, they need to wrap their node in a div */
|
|
36
38
|
position: static !important;
|
|
37
39
|
}
|
|
38
|
-
</style>
|
|
40
|
+
</style>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Node.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Node.svelte.js"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"Node.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Node.svelte.js"],"names":[],"mappings":";;;;;AAkCA,yFAAmD"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { getContext, setContext } from 'svelte';
|
|
3
3
|
import UnknownNode from './UnknownNode.svelte';
|
|
4
|
-
import { snake_to_pascal } from './utils.js';
|
|
4
|
+
import { serialize_path, snake_to_pascal } from './utils.js';
|
|
5
5
|
import DefaultNodeGap from './NodeGap.svelte';
|
|
6
6
|
import DefaultNodeGapMarkers from './NodeGapMarkers.svelte';
|
|
7
7
|
|
|
@@ -14,53 +14,61 @@
|
|
|
14
14
|
/** @type {NodeArrayPropertyProps} */
|
|
15
15
|
let { path, tag = 'div', class: css_class, style = '', ...rest } = $props();
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
// Pre-joined once per path change; reused by data-path and by every
|
|
18
|
+
// NodeGap's should_position_gap call to avoid N+1 joins per render.
|
|
19
|
+
let path_str = $derived(serialize_path(path));
|
|
20
|
+
|
|
21
|
+
let node_ids = $derived(svedit.session.get(path));
|
|
22
|
+
let nodes = $derived(node_ids.map((id) => svedit.session.get(id)));
|
|
23
|
+
|
|
24
|
+
// Mirrors the AnnotatedTextProperty pattern: a `focused` class follows
|
|
25
|
+
// the model selection. Used by the CSS rule below to hide the empty-array
|
|
26
|
+
// NodeGap while the caret is in the placeholder, so its <br> isn't a
|
|
27
|
+
// second arrow-key stop (issue #260).
|
|
28
|
+
let is_focused = $derived(
|
|
29
|
+
svedit.session.selection?.type === 'node'
|
|
30
|
+
&& serialize_path(svedit.session.selection.path) === path_str
|
|
21
31
|
);
|
|
22
32
|
|
|
23
33
|
setContext('node_array_meta', {
|
|
24
|
-
get length() {
|
|
34
|
+
get length() {
|
|
35
|
+
return node_ids.length;
|
|
36
|
+
}
|
|
25
37
|
});
|
|
38
|
+
|
|
26
39
|
</script>
|
|
40
|
+
|
|
27
41
|
<!-- 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 -->
|
|
28
|
-
<svelte:element
|
|
29
|
-
this={tag}
|
|
30
|
-
class={css_class}
|
|
31
|
-
|
|
32
|
-
data-
|
|
33
|
-
|
|
42
|
+
<svelte:element
|
|
43
|
+
this={tag}
|
|
44
|
+
class={css_class}
|
|
45
|
+
class:focused={is_focused}
|
|
46
|
+
data-type="node_array"
|
|
47
|
+
data-path={path_str}
|
|
48
|
+
style="anchor-name: --{path_str};{style ? ` ${style}` : ''}"
|
|
49
|
+
{...rest}
|
|
34
50
|
>
|
|
35
|
-
{#if
|
|
36
|
-
<!--
|
|
37
|
-
Experimental: We'll let .empty-node-array act like a node, so the existing
|
|
38
|
-
code paths for selection mapping will work as expected.
|
|
39
|
-
|
|
40
|
-
TODO: Need to figure out a way to make .empty-node-array customizable.
|
|
41
|
-
-->
|
|
51
|
+
{#if node_ids.length === 0 && svedit.editable}
|
|
42
52
|
<div
|
|
43
|
-
class="
|
|
44
|
-
data-path={[...path, 0]
|
|
53
|
+
class="empty-node-placeholder"
|
|
54
|
+
data-path={serialize_path([...path, 0])}
|
|
45
55
|
data-type="node"
|
|
46
|
-
style="anchor-name: --{[...path, 0]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
style="anchor-name: --{serialize_path([...path, 0])};"
|
|
57
|
+
>
|
|
58
|
+
<NodeGap
|
|
59
|
+
array_path={path}
|
|
60
|
+
offset={0}
|
|
61
|
+
count={0}
|
|
62
|
+
empty
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
54
65
|
{/if}
|
|
55
66
|
{#each nodes as node, index (index)}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
positioned={svedit.is_near_viewport?.([...path, index]) ?? true}
|
|
62
|
-
/>
|
|
63
|
-
{/if}
|
|
67
|
+
<NodeGap
|
|
68
|
+
array_path={path}
|
|
69
|
+
offset={index}
|
|
70
|
+
count={nodes.length}
|
|
71
|
+
/>
|
|
64
72
|
{@const Component = svedit.session.config.node_components[snake_to_pascal(node.type)]}
|
|
65
73
|
{#if Component}
|
|
66
74
|
<Component path={[...path, index]} />
|
|
@@ -68,15 +76,30 @@
|
|
|
68
76
|
<UnknownNode path={[...path, index]} />
|
|
69
77
|
{/if}
|
|
70
78
|
{/each}
|
|
71
|
-
{#if
|
|
79
|
+
{#if node_ids.length > 0}
|
|
72
80
|
<NodeGap
|
|
73
81
|
array_path={path}
|
|
74
|
-
offset={
|
|
75
|
-
count={
|
|
76
|
-
positioned={svedit.is_near_viewport?.([...path, nodes.length - 1]) ?? true}
|
|
82
|
+
offset={node_ids.length}
|
|
83
|
+
count={node_ids.length}
|
|
77
84
|
/>
|
|
78
85
|
{/if}
|
|
79
86
|
{#if svedit.editable && NodeGapMarkers}
|
|
80
|
-
<NodeGapMarkers {path} />
|
|
87
|
+
<NodeGapMarkers {path} count={node_ids.length} />
|
|
81
88
|
{/if}
|
|
82
89
|
</svelte:element>
|
|
90
|
+
|
|
91
|
+
<style>
|
|
92
|
+
/* position: relative makes this the containing block for the gap's
|
|
93
|
+
.svedit-selectable, which fills it via inset:0. You may override to
|
|
94
|
+
position: absolute (e.g. so an empty array doesn't occupy flow space)
|
|
95
|
+
— still a containing block; then also set position: relative on the
|
|
96
|
+
parent node-array container so this placeholder's inset:0 resolves
|
|
97
|
+
against it. */
|
|
98
|
+
:where(.empty-node-placeholder) {
|
|
99
|
+
position: relative;
|
|
100
|
+
inset: 0;
|
|
101
|
+
min-height: 40px;
|
|
102
|
+
min-width: 24px;
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
}
|
|
105
|
+
</style>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NodeArrayProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/NodeArrayProperty.svelte.js"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"NodeArrayProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/NodeArrayProperty.svelte.js"],"names":[],"mappings":";;;;;AA4EA,mHAAgE"}
|
package/dist/NodeCaret.svelte
CHANGED
|
@@ -11,13 +11,6 @@
|
|
|
11
11
|
<div class="caret" role="none"></div>
|
|
12
12
|
|
|
13
13
|
<style>
|
|
14
|
-
@keyframes node-caret-blink {
|
|
15
|
-
0%, 60% { opacity: 1; }
|
|
16
|
-
68% { opacity: 0; }
|
|
17
|
-
88% { opacity: 0; }
|
|
18
|
-
100% { opacity: 1; }
|
|
19
|
-
}
|
|
20
|
-
|
|
21
14
|
.caret {
|
|
22
15
|
--_R: var(--row, 1);
|
|
23
16
|
--_C: calc(1 - var(--row, 1));
|