├── .npmrc
├── static
├── favicon.png
├── images
│ ├── lightweight.jpg
│ ├── svedit-hero.webp
│ ├── svelte-logo.svg
│ ├── github.svg
│ ├── dom-synced.svg
│ ├── node-cursors.svg
│ ├── lightweight.svg
│ ├── cjk.svg
│ ├── editable.svg
│ ├── annotations.svg
│ └── extendable.svg
├── fonts
│ ├── JetBrainsMono[wght].woff2
│ ├── JetBrainsMono-Italic[wght].woff2
│ └── OFL.txt
└── icons
│ ├── disc.svg
│ ├── square.svg
│ ├── arrow-up-tail.svg
│ ├── arrow-down-tail.svg
│ ├── italic.svg
│ ├── image-placeholder.svg
│ ├── list-upper-roman.svg
│ ├── image-right.svg
│ ├── image-left.svg
│ ├── image-at-top.svg
│ ├── external-link.svg
│ ├── rotate-left.svg
│ ├── rotate-right.svg
│ ├── bold.svg
│ ├── list-task.svg
│ ├── list-decimal.svg
│ ├── list-icon.svg
│ ├── link.svg
│ ├── list-upper-latin.svg
│ ├── list-lower-roman.svg
│ ├── list-lower-latin.svg
│ ├── highlight.svg
│ └── list-decimal-leading-zero.svg
├── src
├── routes
│ ├── styles
│ │ ├── shadows.css
│ │ ├── colors.css
│ │ ├── typography.css
│ │ └── spacing.css
│ ├── +layout.svelte
│ ├── components
│ │ ├── Emphasis.svelte
│ │ ├── Strong.svelte
│ │ ├── Layout.svelte
│ │ ├── Link.svelte
│ │ ├── Page.svelte
│ │ ├── Highlight.svelte
│ │ ├── List.svelte
│ │ ├── Icon.svelte
│ │ ├── Button.svelte
│ │ ├── ImageGrid.svelte
│ │ ├── ListItem.svelte
│ │ ├── Text.svelte
│ │ ├── ImageGridItem.svelte
│ │ ├── Overlays.svelte
│ │ ├── NodeCursorTrap.svelte
│ │ ├── Hero.svelte
│ │ ├── Story.svelte
│ │ └── Toolbar.svelte
│ ├── nanoid.js
│ ├── app_utils.js
│ ├── +page.svelte
│ └── commands.svelte.js
├── lib
│ ├── UnknownNode.svelte
│ ├── CustomProperty.svelte
│ ├── index.js
│ ├── Node.svelte
│ ├── NodeArrayProperty.svelte
│ ├── KeyMapper.svelte.js
│ ├── transforms.svelte.js
│ ├── AnnotatedTextProperty.svelte
│ ├── Command.svelte.js
│ └── types.d.ts
├── test
│ ├── testing_components
│ │ └── SveditTest.svelte
│ ├── create_test_session.js
│ └── Session.svelte.test.js
├── app.d.ts
└── app.html
├── .prettierignore
├── vite.config.js
├── .prettierrc
├── .gitignore
├── jsconfig.json
├── vitest.config.js
├── svelte.config.js
├── LICENSE
├── package.json
├── eslint.config.js
└── CLAUDE.md
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michael/svedit/HEAD/static/favicon.png
--------------------------------------------------------------------------------
/static/images/lightweight.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michael/svedit/HEAD/static/images/lightweight.jpg
--------------------------------------------------------------------------------
/static/images/svedit-hero.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michael/svedit/HEAD/static/images/svedit-hero.webp
--------------------------------------------------------------------------------
/static/fonts/JetBrainsMono[wght].woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michael/svedit/HEAD/static/fonts/JetBrainsMono[wght].woff2
--------------------------------------------------------------------------------
/static/fonts/JetBrainsMono-Italic[wght].woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michael/svedit/HEAD/static/fonts/JetBrainsMono-Italic[wght].woff2
--------------------------------------------------------------------------------
/src/routes/styles/shadows.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --shadow-2: 0 0 1px oklch(0 0 0 / 0.3), 0 0 2px oklch(0 0 0 / 0.1), 0 0 10px oklch(0 0 0 / 0.05);
3 | }
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Package Managers
2 | package-lock.json
3 | pnpm-lock.yaml
4 | yarn.lock
5 | bun.lock
6 | bun.lockb
7 |
8 | # Miscellaneous
9 | /static/
10 |
--------------------------------------------------------------------------------
/static/icons/disc.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/static/icons/square.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 | {@render children()}
9 |
10 |
--------------------------------------------------------------------------------
/static/icons/arrow-up-tail.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/static/icons/arrow-down-tail.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite';
2 | import { defineConfig } from 'vite';
3 |
4 | export default defineConfig({
5 | plugins: [sveltekit()],
6 | build: {
7 | sourcemap: true
8 | },
9 | css: {
10 | devSourcemap: true
11 | }
12 | });
13 |
--------------------------------------------------------------------------------
/src/routes/components/Emphasis.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 | {content}
9 |
--------------------------------------------------------------------------------
/src/routes/components/Strong.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 | {content}
9 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 100,
6 | "plugins": ["prettier-plugin-svelte"],
7 | "overrides": [
8 | {
9 | "files": "*.svelte",
10 | "options": {
11 | "parser": "svelte"
12 | }
13 | }
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/static/icons/italic.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/routes/nanoid.js:
--------------------------------------------------------------------------------
1 | import { customAlphabet } from 'nanoid';
2 |
3 | const _nanoid = customAlphabet('ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz', 23);
4 |
5 | // A custom nanoid of length 23 with no numbers and no "_" or "-" to be used as HTML ids
6 | export default function nanoid() {
7 | return _nanoid();
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/UnknownNode.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 | Unknown: {node.type}.
12 |
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | # Output
4 | .output
5 | .vercel
6 | .netlify
7 | .wrangler
8 | /.svelte-kit
9 | /build
10 | /dist
11 |
12 | # OS
13 | .DS_Store
14 | Thumbs.db
15 |
16 | # Env
17 | .env
18 | .env.*
19 | !.env.example
20 | !.env.test
21 |
22 | # Vite
23 | vite.config.js.timestamp-*
24 | vite.config.ts.timestamp-*
25 |
--------------------------------------------------------------------------------
/src/test/testing_components/SveditTest.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/app.d.ts:
--------------------------------------------------------------------------------
1 | // See https://svelte.dev/docs/kit/types#app.d.ts
2 | // for information about these interfaces
3 | declare global {
4 | namespace App {
5 | // interface Error {}
6 | // interface Locals {}
7 | // interface PageData {}
8 | // interface PageState {}
9 | // interface Platform {}
10 | }
11 | }
12 |
13 | export {};
14 |
--------------------------------------------------------------------------------
/src/routes/components/Layout.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 | {@render children()}
13 |
14 |
--------------------------------------------------------------------------------
/static/icons/image-placeholder.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/static/icons/list-upper-roman.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/routes/styles/colors.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --primary-fill-color: oklch(65.51% 0.2334 34.36);
3 | --canvas-fill-color: oklch(100% 0 0);
4 | --secondary-fill-color: oklch(97% 0 0);
5 | --primary-text-color: oklch(32.11% 0 0);
6 | --stroke-color: oklch(0 0 0 / 0.1);
7 | --editing-fill-color: oklch(59.71% 0.22 283 / 0.1);
8 | --editing-stroke-color: oklch(59.71% 0.22 283);
9 | }
10 |
--------------------------------------------------------------------------------
/static/icons/image-right.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/routes/components/Link.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 | {content}
14 |
--------------------------------------------------------------------------------
/static/icons/image-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | %sveltekit.head%
8 |
9 |
10 | %sveltekit.body%
11 |
12 |
13 |
--------------------------------------------------------------------------------
/static/icons/image-at-top.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "sourceMap": true,
11 | "strict": false,
12 | "moduleResolution": "bundler",
13 | "declarationMap": true,
14 | "declaration": true
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/routes/components/Page.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
21 |
--------------------------------------------------------------------------------
/src/routes/components/Highlight.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 | {content}
9 |
10 |
16 |
--------------------------------------------------------------------------------
/src/routes/components/List.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
18 |
--------------------------------------------------------------------------------
/static/icons/external-link.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/static/icons/rotate-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/static/icons/rotate-right.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/routes/components/Icon.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
20 |
--------------------------------------------------------------------------------
/vitest.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 | import { preview } from '@vitest/browser-preview';
3 | import { sveltekit } from '@sveltejs/kit/vite';
4 |
5 | export default defineConfig({
6 | plugins: [sveltekit()],
7 | test: {
8 | browser: {
9 | provider: preview(),
10 | enabled: true,
11 | // at least one instance is required
12 | instances: [{ browser: 'chromium' }]
13 | }
14 | },
15 | clearMocks: true,
16 | include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
17 | exclude: ['src/lib/server/**']
18 | });
19 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-auto';
2 |
3 | /** @type {import('@sveltejs/kit').Config} */
4 | const config = {
5 | kit: {
6 | alias: {
7 | svedit: 'src/lib'
8 | },
9 | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
10 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
11 | // See https://svelte.dev/docs/kit/adapters for more information about adapters.
12 | adapter: adapter()
13 | }
14 | };
15 |
16 | export default config;
17 |
--------------------------------------------------------------------------------
/static/icons/bold.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/static/icons/list-task.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/lib/CustomProperty.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
18 | {#if svedit.editable}
19 |
22 | {/if}
23 |
24 | {@render children()}
25 |
26 |
27 |
41 |
--------------------------------------------------------------------------------
/static/icons/list-decimal.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/static/icons/list-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/routes/components/Button.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
16 |
17 |
18 |
19 |
20 |
36 |
--------------------------------------------------------------------------------
/src/routes/components/ImageGrid.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
43 |
--------------------------------------------------------------------------------
/src/lib/index.js:
--------------------------------------------------------------------------------
1 | // Main exports for the svedit library
2 | export { default as Svedit } from './Svedit.svelte';
3 | export { default as AnnotatedTextProperty } from './AnnotatedTextProperty.svelte';
4 | export { default as CustomProperty } from './CustomProperty.svelte';
5 | export { default as Node } from './Node.svelte';
6 | export { default as NodeArrayProperty } from './NodeArrayProperty.svelte';
7 |
8 | // Core classes and utilities
9 | export { default as Session } from './Session.svelte.js';
10 | export { default as Transaction } from './Transaction.svelte.js';
11 |
12 | // Document utilities
13 | export {
14 | define_document_schema,
15 | is_primitive_type,
16 | get_default_node_type,
17 | validate_document_schema,
18 | validate_node
19 | } from './doc_utils.js';
20 |
21 | // Command system
22 | export { default as Command } from './Command.svelte.js';
23 | export * from './Command.svelte.js';
24 |
25 | // Keyboard handling
26 | export { KeyMapper, define_keymap } from './KeyMapper.svelte.js';
27 |
28 | // Transforms and utilities
29 | export * from './transforms.svelte.js';
30 | export * from './utils.js';
31 |
--------------------------------------------------------------------------------
/static/icons/link.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Michael Aufreiter, Johannes Mutter.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/static/icons/list-upper-latin.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/routes/app_utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Get the layout node for the current selection.
3 | * A layout node is a node that has a `layout` property.
4 | *
5 | * @param {import('svedit').Session} session - The session instance
6 | * @returns {object|null} The layout node or null if none found
7 | */
8 | export function get_layout_node(session) {
9 | if (!session.selected_node) return null;
10 |
11 | // The selected node already is a layout node
12 | if (session.selected_node.layout) {
13 | return session.selected_node;
14 | }
15 |
16 | // We resolve the parent node if available, and return it if it's a layout node.
17 | // NOTE: We only support one level atm, we may want to implement this recursively
18 | if (session.selection.type === 'node') {
19 | const parent_node = session.get(session.selection.path.slice(0, -1));
20 | return parent_node.layout ? parent_node : null;
21 | } else {
22 | // We are either in a text or property (=custom) selection
23 | const parent_node_path = session.selection?.path?.slice(0, -3);
24 | if (!parent_node_path) return null;
25 | const parent_node = session.get(parent_node_path);
26 | return parent_node.layout ? parent_node : null;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/lib/Node.svelte:
--------------------------------------------------------------------------------
1 |
21 |
22 |
30 | {#if svedit.editable && is_first_node_array_child}
31 |
32 | {/if}
33 | {@render children()}
34 | {#if svedit.editable && is_inside_node_array}
35 |
36 | {/if}
37 |
38 |
39 |
44 |
--------------------------------------------------------------------------------
/static/images/svelte-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/routes/components/ListItem.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
27 |
28 |
29 |
51 |
--------------------------------------------------------------------------------
/static/images/github.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/static/icons/list-lower-roman.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/lib/NodeArrayProperty.svelte:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 | {#if nodes.length === 0 && svedit.editable}
23 |
29 |
37 |
38 |
39 | {/if}
40 | {#each nodes as node, index (index)}
41 | {@const Component = svedit.session.config.node_components[snake_to_pascal(node.type)]}
42 | {#if Component}
43 |
44 | {:else}
45 |
46 | {/if}
47 | {/each}
48 |
49 |
--------------------------------------------------------------------------------
/static/icons/list-lower-latin.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "svedit",
3 | "version": "0.6.1",
4 | "license": "MIT",
5 | "scripts": {
6 | "dev": "vite dev",
7 | "build": "vite build",
8 | "preview": "vite preview",
9 | "prepare": "svelte-kit sync || echo ''",
10 | "prepack": "svelte-kit sync && svelte-package && publint",
11 | "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
13 | "format": "prettier --write .",
14 | "lint": "prettier --check . && eslint .",
15 | "test:unit": "vitest",
16 | "test": "npm run test:unit -- --run"
17 | },
18 | "files": [
19 | "dist",
20 | "!dist/**/*.test.*",
21 | "!dist/**/*.spec.*",
22 | "src/lib",
23 | "!src/lib/**/*.test.*",
24 | "!src/lib/**/*.spec.*"
25 | ],
26 | "sideEffects": [
27 | "**/*.css"
28 | ],
29 | "svelte": "./dist/index.js",
30 | "types": "./dist/index.d.ts",
31 | "type": "module",
32 | "exports": {
33 | ".": {
34 | "types": "./dist/index.d.ts",
35 | "svelte": "./dist/index.js"
36 | }
37 | },
38 | "peerDependencies": {
39 | "svelte": "^5.0.0"
40 | },
41 | "devDependencies": {
42 | "@eslint/compat": "^1.4.1",
43 | "@eslint/js": "^9.39.1",
44 | "@sveltejs/adapter-auto": "^7.0.0",
45 | "@sveltejs/kit": "^2.48.4",
46 | "@sveltejs/package": "^2.5.4",
47 | "@sveltejs/vite-plugin-svelte": "^6.2.1",
48 | "@typescript-eslint/eslint-plugin": "^8.46.3",
49 | "@typescript-eslint/parser": "^8.46.3",
50 | "@vitest/browser": "^4.0.8",
51 | "@vitest/browser-preview": "^4.0.8",
52 | "eslint": "^9.39.1",
53 | "eslint-config-prettier": "^10.1.8",
54 | "eslint-plugin-svelte": "^3.13.0",
55 | "globals": "^16.5.0",
56 | "nanoid": "^5.1.6",
57 | "prettier": "^3.6.2",
58 | "prettier-plugin-svelte": "^3.4.0",
59 | "publint": "^0.3.15",
60 | "svelte": "^5.43.4",
61 | "svelte-check": "^4.3.3",
62 | "vite": "^7.2.2",
63 | "typescript": "^5.9.3",
64 | "vitest": "^4.0.8",
65 | "vitest-browser-svelte": "^2.0.1"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/routes/components/Text.svelte:
--------------------------------------------------------------------------------
1 |
58 |
59 |
60 |
68 |
69 |
70 |
79 |
--------------------------------------------------------------------------------
/src/routes/components/ImageGridItem.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
18 |
19 |
20 |
29 |
30 |
31 |
32 |
70 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import prettier from 'eslint-config-prettier';
2 | import { includeIgnoreFile } from '@eslint/compat';
3 | import js from '@eslint/js';
4 | import svelte from 'eslint-plugin-svelte';
5 | import typescript from '@typescript-eslint/eslint-plugin';
6 | import typescriptParser from '@typescript-eslint/parser';
7 | import globals from 'globals';
8 | import { fileURLToPath } from 'node:url';
9 | import svelteConfig from './svelte.config.js';
10 |
11 | const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
12 |
13 | /** @type {import('eslint').Linter.Config[]} */
14 | export default [
15 | includeIgnoreFile(gitignorePath),
16 | js.configs.recommended,
17 | ...svelte.configs.recommended,
18 | prettier,
19 | ...svelte.configs.prettier,
20 | {
21 | languageOptions: {
22 | globals: { ...globals.browser, ...globals.node }
23 | }
24 | },
25 | {
26 | files: ['**/*.svelte', '**/*.svelte.js'],
27 | languageOptions: { parserOptions: { svelteConfig } },
28 | rules: {
29 | 'svelte/no-navigation-without-resolve': 'off'
30 | }
31 | },
32 | // TypeScript configuration for .d.ts files
33 | {
34 | files: ['**/*.d.ts'],
35 | languageOptions: {
36 | parser: typescriptParser,
37 | parserOptions: {
38 | project: './jsconfig.json',
39 | ecmaVersion: 2022,
40 | sourceType: 'module'
41 | }
42 | },
43 | plugins: {
44 | '@typescript-eslint': typescript
45 | },
46 | rules: {
47 | // Enable TypeScript-specific rules for .d.ts files
48 | '@typescript-eslint/no-unused-vars': 'error',
49 | '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
50 | '@typescript-eslint/no-explicit-any': 'warn',
51 | '@typescript-eslint/prefer-namespace-keyword': 'error',
52 | '@typescript-eslint/triple-slash-reference': 'error',
53 | '@typescript-eslint/no-var-requires': 'off', // Often needed in .d.ts files
54 | '@typescript-eslint/ban-types': 'error',
55 | '@typescript-eslint/no-duplicate-enum-values': 'error',
56 | '@typescript-eslint/no-empty-interface': 'error',
57 | '@typescript-eslint/no-inferrable-types': 'off',
58 | '@typescript-eslint/no-misused-new': 'error',
59 | '@typescript-eslint/no-namespace': 'error',
60 | '@typescript-eslint/no-this-alias': 'error'
61 | }
62 | }
63 | ];
64 |
--------------------------------------------------------------------------------
/static/icons/highlight.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/static/images/dom-synced.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/static/icons/list-decimal-leading-zero.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
73 |
74 |
75 | Svedit - A tiny library for building editable websites in Svelte
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | {#if editable}
84 |
85 |
Selection:
86 |
{JSON.stringify(session.selection || {}, null, ' ')}
87 |
Nodes:
88 |
{JSON.stringify(session.doc, null, ' ')}
89 |
90 | {/if}
91 |
92 |
93 |
94 |
95 |
105 |
--------------------------------------------------------------------------------
/src/routes/components/Overlays.svelte:
--------------------------------------------------------------------------------
1 |
42 |
43 | {#if svedit.session.selection?.type === 'property'}
44 |
48 | {/if}
49 |
50 |
51 | {#if node_array_selection_paths}
52 |
53 | {#each node_array_selection_paths as path (path.join('-'))}
54 |
55 | {/each}
56 | {/if}
57 |
58 | {#if selected_link_path}
59 |
62 | {/if}
63 |
64 |
99 |
--------------------------------------------------------------------------------
/src/routes/components/NodeCursorTrap.svelte:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
38 |
39 |
40 |
41 | {#if is_focused}
​
{/if}
42 |
43 |
44 |
134 |
--------------------------------------------------------------------------------
/src/routes/styles/typography.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'JetBrains Mono';
3 | src: url('/fonts/JetBrainsMono-Italic[wght].woff2') format('woff2');
4 | font-weight: 100 800;
5 | font-style: italic;
6 | font-display: fallback;
7 | }
8 | @font-face {
9 | font-family: 'JetBrains Mono';
10 | src: url('/fonts/JetBrainsMono[wght].woff2') format('woff2');
11 | font-weight: 100 800;
12 | font-style: normal;
13 | font-display: fallback;
14 | }
15 |
16 | :root {
17 | --base-size: 1rem;
18 | --scale-ratio: 1.25;
19 | }
20 |
21 | body {
22 | font-family:
23 | 'JetBrains Mono',
24 | Verdana,
25 | system-ui,
26 | -apple-system,
27 | BlinkMacSystemFont,
28 | 'Segoe UI',
29 | Roboto,
30 | 'Helvetica Neue',
31 | Arial,
32 | sans-serif;
33 | font-weight: 400;
34 | font-stretch: 100%;
35 | font-style: normal;
36 | font-variation-settings: 'wght' 400;
37 | font-size: var(--base-size);
38 | line-height: 1.5;
39 | }
40 |
41 | strong,
42 | b,
43 | .bold {
44 | font-weight: 700;
45 | font-variation-settings: 'wght' 700;
46 | }
47 |
48 | em,
49 | i,
50 | .italic {
51 | font-style: italic;
52 | font-variation-settings: 'wght' 400;
53 | }
54 |
55 | .condensed {
56 | font-stretch: 100%;
57 | font-variation-settings: 'wght' 400;
58 | }
59 |
60 | .expanded {
61 | font-stretch: 125%;
62 | font-variation-settings: 'wght' 400;
63 | }
64 |
65 | /* Typography classes */
66 | .heading1 {
67 | font-size: calc(var(--base-size) * var(--scale-ratio) * var(--scale-ratio) * var(--scale-ratio));
68 | font-weight: 700;
69 | font-variation-settings: 'wght' 700;
70 | line-height: 1.2;
71 | --text-wrap: balance;
72 | }
73 |
74 | .heading2 {
75 | font-size: calc(var(--base-size) * var(--scale-ratio) * var(--scale-ratio));
76 | font-weight: 700;
77 | font-variation-settings: 'wght' 700;
78 | line-height: 1.3;
79 | --text-wrap: balance;
80 | }
81 |
82 | .heading3 {
83 | font-size: calc(var(--base-size) * var(--scale-ratio));
84 | font-weight: 700;
85 | font-variation-settings: "wght" 700;
86 | line-height: 1.4;
87 | --text-wrap: balance;
88 | }
89 |
90 | .body {
91 | font-size: var(--base-size);
92 | font-weight: 400;
93 | line-height: 1.5;
94 | --text-wrap: pretty;
95 | }
96 |
97 | .caption {
98 | font-size: calc(var(--base-size) / var(--scale-ratio));
99 | font-weight: 400;
100 | line-height: 1.4;
101 | }
102 |
103 | .icon {
104 | width: 24px;
105 | height: 24px;
106 | }
107 |
108 | button {
109 | border-radius: 9999px;
110 | font-size: calc(var(--base-size) / var(--scale-ratio));
111 | background-color: var(--canvas-fill-color);
112 | font-weight: 400;
113 | padding-inline: var(--s-3);
114 | min-height: 44px;
115 | display: flex;
116 | align-items: center;
117 | justify-content: center;
118 | transition: background-color 0.1s ease-in-out;
119 | &:focus,
120 | &:focus-visible {
121 | outline: none;
122 | }
123 | &:focus-visible,
124 | &:active {
125 | background-color: oklch(0 0 0 / 0.1);
126 | }
127 | &:disabled {
128 | opacity: 0.4;
129 | cursor: not-allowed;
130 | }
131 | &:not(:disabled):hover {
132 | background-color: oklch(from var(--canvas-fill-color) calc(l - 0.05) c h);
133 | }
134 | &.small {
135 | min-height: 36px;
136 | }
137 | }
138 |
139 | .flex-column,
140 | .flex-row {
141 | display: flex;
142 | }
143 | .flex-column {
144 | flex-direction: column;
145 | }
146 | .flex-row {
147 | flex-direction: row;
148 | }
149 | .items-center {
150 | align-items: center;
151 | }
152 | .flex-wrap {
153 | flex-wrap: wrap;
154 | }
155 |
156 | a {
157 | text-decoration: underline;
158 | color: var(--primary-text-color);
159 | text-underline-offset: 0.3em;
160 | }
161 |
--------------------------------------------------------------------------------
/src/routes/components/Hero.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
17 | {#if has_image}
18 |
19 | {/if}
20 |
42 |
43 |
44 |
45 |
144 |
--------------------------------------------------------------------------------
/src/routes/components/Story.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
20 |
21 |
22 |
39 |
40 |
41 |
42 |
144 |
--------------------------------------------------------------------------------
/static/fonts/OFL.txt:
--------------------------------------------------------------------------------
1 | Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
2 |
3 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 | This license is copied below, and is also available with a FAQ at:
5 | https://scripts.sil.org/OFL
6 |
7 |
8 | -----------------------------------------------------------
9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 | -----------------------------------------------------------
11 |
12 | PREAMBLE
13 | The goals of the Open Font License (OFL) are to stimulate worldwide
14 | development of collaborative font projects, to support the font creation
15 | efforts of academic and linguistic communities, and to provide a free and
16 | open framework in which fonts may be shared and improved in partnership
17 | with others.
18 |
19 | The OFL allows the licensed fonts to be used, studied, modified and
20 | redistributed freely as long as they are not sold by themselves. The
21 | fonts, including any derivative works, can be bundled, embedded,
22 | redistributed and/or sold with any software provided that any reserved
23 | names are not used by derivative works. The fonts and derivatives,
24 | however, cannot be released under any other type of license. The
25 | requirement for fonts to remain under this license does not apply
26 | to any document created using the fonts or their derivatives.
27 |
28 | DEFINITIONS
29 | "Font Software" refers to the set of files released by the Copyright
30 | Holder(s) under this license and clearly marked as such. This may
31 | include source files, build scripts and documentation.
32 |
33 | "Reserved Font Name" refers to any names specified as such after the
34 | copyright statement(s).
35 |
36 | "Original Version" refers to the collection of Font Software components as
37 | distributed by the Copyright Holder(s).
38 |
39 | "Modified Version" refers to any derivative made by adding to, deleting,
40 | or substituting -- in part or in whole -- any of the components of the
41 | Original Version, by changing formats or by porting the Font Software to a
42 | new environment.
43 |
44 | "Author" refers to any designer, engineer, programmer, technical
45 | writer or other person who contributed to the Font Software.
46 |
47 | PERMISSION & CONDITIONS
48 | Permission is hereby granted, free of charge, to any person obtaining
49 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 | redistribute, and sell modified and unmodified copies of the Font
51 | Software, subject to the following conditions:
52 |
53 | 1) Neither the Font Software nor any of its individual components,
54 | in Original or Modified Versions, may be sold by itself.
55 |
56 | 2) Original or Modified Versions of the Font Software may be bundled,
57 | redistributed and/or sold with any software, provided that each copy
58 | contains the above copyright notice and this license. These can be
59 | included either as stand-alone text files, human-readable headers or
60 | in the appropriate machine-readable metadata fields within text or
61 | binary files as long as those fields can be easily viewed by the user.
62 |
63 | 3) No Modified Version of the Font Software may use the Reserved Font
64 | Name(s) unless explicit written permission is granted by the corresponding
65 | Copyright Holder. This restriction only applies to the primary font name as
66 | presented to the users.
67 |
68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 | Software shall not be used to promote, endorse or advertise any
70 | Modified Version, except to acknowledge the contribution(s) of the
71 | Copyright Holder(s) and the Author(s) or with their explicit written
72 | permission.
73 |
74 | 5) The Font Software, modified or unmodified, in part or in whole,
75 | must be distributed entirely under this license, and must not be
76 | distributed under any other license. The requirement for fonts to
77 | remain under this license does not apply to any document created
78 | using the Font Software.
79 |
80 | TERMINATION
81 | This license becomes null and void if any of the above conditions are
82 | not met.
83 |
84 | DISCLAIMER
85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 | OTHER DEALINGS IN THE FONT SOFTWARE.
94 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Commands
6 |
7 | **Development:**
8 | - `npm run dev` - Start development server
9 | - `npm run build` - Build for production
10 | - `npm run preview` - Preview production build
11 |
12 | **Testing:**
13 | - `npm run test:unit` - Run unit tests with Vitest
14 | - `npm run test:e2e` - Run end-to-end tests with Playwright
15 | - `npm run test` - Run both unit and e2e tests
16 |
17 | **Testing Guidelines:**
18 | - DO NOT run tests automatically (test:unit, test:e2e, test, etc.)
19 | - The user prefers to run all tests manually
20 | - Focus on implementing code changes and let the user handle testing
21 |
22 | **Implementation Guidelines:**
23 | - Do exactly what the user asks for - one step at a time
24 | - Do NOT think 4 steps ahead or add extra features/improvements
25 | - Only implement the specific change requested
26 | - You can suggest what the next step could be, but don't implement it
27 |
28 | **Refactoring Guidelines:**
29 | - During refactors, make ONLY the minimal changes needed (e.g., renaming APIs)
30 | - Do NOT "improve" or restructure logic while refactoring
31 | - If you see something that could be improved, note it separately for a future task
32 | - Refactoring and improving are two separate activities - never combine them
33 |
34 | **Code Style:**
35 | - Use snake_case for all variable names, function names, and identifiers
36 | - This applies to JavaScript/TypeScript code, test files, and any new code written
37 |
38 | **What to NOT change (keep camelCase):**
39 | - `window.getSelection()` - native API
40 | - `document.activeElement` - native API
41 | - `navigator.clipboard` - native API
42 | - `addEventListener` - native API
43 | - `preventDefault()` - native API
44 | - `stopPropagation()` - native API
45 | - `getRangeAt()` - native API
46 | - Svelte event handlers: `onclick`, `onmousedown`, etc.
47 | - DOM properties: `innerHTML`, `textContent`, `nodeType`, etc.
48 |
49 | **Pattern**: If it's a web platform API or Svelte API, keep camelCase. If it's our custom variable/function name, use snake_case.
50 |
51 | **File Extensions:**
52 | - Files using Svelte runes (`$state`, `$derived`, `$effect`, etc.) must use `.svelte.js` or `.svelte.ts` extension
53 |
54 | **Documentation Style:**
55 | - Use sentence case for all headings in documentation (README.md, etc.)
56 | - Use sentence case for code comments
57 | - Sentence case means: capitalize only the first word and proper nouns
58 | - **Exception**: "Svedit" is always capitalized as it's a proper noun (the product name)
59 | - Examples:
60 | - ✓ "Getting started" (not "Getting Started")
61 | - ✓ "Why Svedit?" (not "Why svedit?") - Svedit is a proper noun
62 | - ✓ "Developing Svedit" (not "Developing svedit") - Svedit is a proper noun
63 | - ✓ "Document-scoped commands" (not "Document-Scoped Commands")
64 | - ✓ "Create a new user" (not "Create a New User")
65 | - ✓ "API reference" (not "API Reference")
66 | - This applies to: markdown headings, JSDoc comments, inline comments, commit messages
67 |
68 | ## Architecture
69 |
70 | Svedit is a rich content editor template built with Svelte 5 that uses a graph-based data model.
71 |
72 | ### Core Components
73 |
74 | **Document Model:**
75 | - `Document` - Central document class with state management, transactions, and history
76 | - `Tras` - Handles atomic operations on the document
77 | - Documents are represented as graphs of nodes with properties and references
78 |
79 | **Selection System:**
80 | - Supports text, node, and property selections
81 | - Maps between internal selection model and DOM selection
82 | - Handles complex selection scenarios like backwards selections and multi-node selections
83 |
84 | **Key Components:**
85 | - `Svedit.svelte` - Main editor component with event handling and selection management
86 | - `NodeArrayProperty.svelte` - Renders containers that hold sequences of nodes
87 | - `AnnotatedTextProperty.svelte` - Handles annotated text rendering and editing
88 | - Node components (`Story`, `List`, etc.) - Render specific content types
89 |
90 | ### Schema System
91 |
92 | Content is defined through schemas that specify:
93 | - Node types and their properties
94 | - Property types: `string`, `integer`, `boolean`, `string_array`, `annotated_text`, `node`, `node_array`
95 | - Reference relationships between nodes
96 | - Default types for node arrays
97 |
98 | ### Data Flow
99 |
100 | 1. Raw document data is loaded into `Session`
101 | 2. Changes are made through transactions for undo/redo support
102 | 3. Selection state is synchronized between internal model and DOM
103 | 4. Components render content based on document state and schema definitions
104 |
--------------------------------------------------------------------------------
/src/lib/KeyMapper.svelte.js:
--------------------------------------------------------------------------------
1 | const MODIFIER_KEYS = ['meta', 'ctrl', 'alt', 'shift'];
2 | const MODIFIER_EVENT_KEYS = {
3 | meta: 'metaKey',
4 | ctrl: 'ctrlKey',
5 | alt: 'altKey',
6 | shift: 'shiftKey'
7 | };
8 |
9 | /**
10 | * Validates and defines a keymap.
11 | * Throws an error if any key combo is invalid.
12 | * Valid formats: 'meta+e,ctrl+e' or 'meta+shift+a'
13 | * Invalid: 'meta+e+a' (only one non-modifier key allowed)
14 | */
15 | export function define_keymap(keymap) {
16 | for (const [key_combo] of Object.entries(keymap)) {
17 | const alternatives = key_combo.split(',');
18 |
19 | for (const alternative of alternatives) {
20 | const parts = alternative.trim().toLowerCase().split('+');
21 | const non_modifiers = parts.filter((part) => !MODIFIER_KEYS.includes(part));
22 |
23 | if (non_modifiers.length !== 1) {
24 | throw new Error(
25 | `Invalid key combo: "${alternative}". Must have exactly one non-modifier key. Found: ${non_modifiers.length}`
26 | );
27 | }
28 | }
29 | }
30 | return keymap;
31 | }
32 |
33 | /**
34 | * Matches a keyboard event against a key combo string.
35 | * Example: 'meta+e,ctrl+e' matches either (metaKey && key==='e') OR (ctrlKey && key==='e')
36 | */
37 | function matches_key_combo(key_combo, event) {
38 | const alternatives = key_combo.split(',');
39 |
40 | return alternatives.some((alternative) => {
41 | const parts = alternative.trim().toLowerCase().split('+');
42 | const modifiers = parts.filter((part) => MODIFIER_KEYS.includes(part));
43 | const non_modifier = parts.find((part) => !MODIFIER_KEYS.includes(part));
44 |
45 | // Check if all specified modifiers are pressed
46 | const modifiers_match = modifiers.every((mod) => event[MODIFIER_EVENT_KEYS[mod]]);
47 |
48 | // Check if no unspecified modifiers are pressed
49 | const no_extra_modifiers = MODIFIER_KEYS.every((mod) => {
50 | if (modifiers.includes(mod)) return true; // This modifier is expected
51 | return !event[MODIFIER_EVENT_KEYS[mod]]; // This modifier should NOT be pressed
52 | });
53 |
54 | // Check if the key matches
55 | const key_matches = event.key.toLowerCase() === non_modifier;
56 | return modifiers_match && no_extra_modifiers && key_matches;
57 | });
58 | }
59 |
60 | /**
61 | * Handles a key map by matching keyboard event against registered key combos
62 | * and executing the first enabled command.
63 | *
64 | * Supports both sync and async commands. For async commands, errors are logged
65 | * but don't crash the application.
66 | */
67 | function handle_key_map(key_map, event) {
68 | for (const [key_combo, commands] of Object.entries(key_map)) {
69 | if (matches_key_combo(key_combo, event)) {
70 | // Find the first enabled command and execute it
71 | const enabled_command = commands.find((cmd) => cmd.is_enabled());
72 | if (enabled_command) {
73 | event.preventDefault();
74 |
75 | // Execute command (may be sync or async)
76 | const result = enabled_command.execute();
77 |
78 | // If it's a promise, handle errors (fire-and-forget)
79 | if (result instanceof Promise) {
80 | result.catch((err) => {
81 | console.error('Command execution failed:', err);
82 | });
83 | }
84 |
85 | return true;
86 | }
87 | }
88 | }
89 | return false;
90 | }
91 |
92 | /**
93 | * KeyMapper manages keyboard shortcuts using a stack-based scope system.
94 | *
95 | * Scopes are tried from top to bottom (most specific to most general).
96 | * This makes it completely general-purpose - no knowledge of specific contexts.
97 | *
98 | * Usage:
99 | * const mapper = new KeyMapper();
100 | * mapper.push_scope(app_keymap); // Base layer
101 | * mapper.push_scope(editor_keymap); // When editor gains focus
102 | * mapper.pop_scope(); // When editor loses focus
103 | */
104 | export class KeyMapper {
105 | constructor() {
106 | this.scope_stack = [];
107 | this.skip_onkeydown = false;
108 | }
109 |
110 | /**
111 | * Push a new scope onto the stack (becomes highest priority)
112 | */
113 | push_scope(keymap) {
114 | // console.log('pushed keymap', keymap);
115 | this.scope_stack.push(keymap);
116 | }
117 |
118 | /**
119 | * Pop the most recent scope from the stack
120 | */
121 | pop_scope() {
122 | const keymap = this.scope_stack.pop();
123 | // console.log('popped keymap', keymap);
124 | return keymap;
125 | }
126 |
127 | /**
128 | * Handle keyboard event by trying scopes from top to bottom
129 | */
130 | handle_keydown(event) {
131 | // Key handling temporarily disabled (e.g. while character composition takes place)
132 | if (this.skip_onkeydown) return;
133 | // console.log('KeyMapper.handle_keydown', event);
134 | // Try from most specific (top of stack) to most general (bottom)
135 | for (let i = this.scope_stack.length - 1; i >= 0; i--) {
136 | if (handle_key_map(this.scope_stack[i], event)) {
137 | return;
138 | }
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/routes/commands.svelte.js:
--------------------------------------------------------------------------------
1 | import Command from '$lib/Command.svelte.js';
2 | import { is_selection_collapsed } from '$lib/utils.js';
3 | import { get_layout_node } from './app_utils.js';
4 |
5 | /**
6 | * Command that cycles through available layouts for a node.
7 | * Direction can be 'next' or 'previous'.
8 | */
9 | export class CycleLayoutCommand extends Command {
10 | layout_node = $derived(get_layout_node(this.context.session));
11 |
12 | constructor(direction, context) {
13 | super(context);
14 | this.direction = direction;
15 | }
16 |
17 | is_enabled() {
18 | if (!this.context.editable || !this.layout_node) return false;
19 |
20 | const layout_count = this.context.session.config.node_layouts?.[this.layout_node.type];
21 | return layout_count > 1 && this.layout_node?.layout;
22 | }
23 |
24 | execute() {
25 | const session = this.context.session;
26 | const node = this.layout_node;
27 | const layout_count = session.config.node_layouts[node.type];
28 |
29 | let new_layout;
30 | if (this.direction === 'next') {
31 | new_layout = (node.layout % layout_count) + 1;
32 | } else {
33 | new_layout = ((node.layout - 2 + layout_count) % layout_count) + 1;
34 | }
35 |
36 | const tr = session.tr;
37 | tr.set([node.id, 'layout'], new_layout);
38 | session.apply(tr);
39 | }
40 | }
41 |
42 | /**
43 | * Command that cycles through available node types in a node array.
44 | * Direction can be 'next' or 'previous'.
45 | */
46 | export class CycleNodeTypeCommand extends Command {
47 | constructor(direction, context) {
48 | super(context);
49 | this.direction = direction;
50 | }
51 |
52 | is_enabled() {
53 | const session = this.context.session;
54 |
55 | if (!this.context.editable || !session.selection) return false;
56 |
57 | // Need to check if we have a node selection or can select parent
58 | let selection = session.selection;
59 | if (selection.type !== 'node') {
60 | // Would need to select parent first
61 | return true; // Let execute handle this
62 | }
63 |
64 | const node_array_schema = session.inspect(selection.path);
65 | if (node_array_schema.type !== 'node_array') return false;
66 |
67 | // Need at least 2 types to cycle
68 | return node_array_schema.node_types?.length > 1;
69 | }
70 |
71 | execute() {
72 | const session = this.context.session;
73 |
74 | // Ensure we have a node selection
75 | if (session.selection.type !== 'node') {
76 | session.select_parent();
77 | }
78 |
79 | const node = session.selected_node;
80 | const old_selection = structuredClone(session.selection);
81 | const node_array_schema = session.inspect(session.selection.path);
82 |
83 | // If we are not dealing with a node selection in a container, return
84 | if (node_array_schema.type !== 'node_array') return;
85 |
86 | const current_type_index = node_array_schema.node_types.indexOf(node.type);
87 | let new_type_index;
88 |
89 | if (this.direction === 'next') {
90 | new_type_index = (current_type_index + 1) % node_array_schema.node_types.length;
91 | } else {
92 | new_type_index =
93 | (current_type_index - 1 + node_array_schema.node_types.length) %
94 | node_array_schema.node_types.length;
95 | }
96 |
97 | const new_type = node_array_schema.node_types[new_type_index];
98 | const tr = session.tr;
99 | session.config.inserters[new_type](tr);
100 | tr.set_selection(old_selection);
101 | session.apply(tr);
102 | }
103 | }
104 |
105 | export class ResetImageCommand extends Command {
106 | is_enabled() {
107 | const session = this.context.session;
108 | if (!this.context.editable || session.selection.type !== 'property') return false;
109 | const property_definition = session.inspect(session.selection.path);
110 | return property_definition.name === 'image';
111 | }
112 |
113 | execute() {
114 | const session = this.context.session;
115 | const tr = session.tr;
116 | tr.set(session.selection.path, '');
117 | session.apply(tr);
118 | }
119 | }
120 |
121 | /**
122 | * Command that toggles link annotations on text selections.
123 | * Prompts user for URL when creating a link.
124 | */
125 | export class ToggleLinkCommand extends Command {
126 | active = $derived(this.is_active());
127 |
128 | is_active() {
129 | return this.context.session.active_annotation('link');
130 | }
131 |
132 | is_enabled() {
133 | const { session, editable } = this.context;
134 |
135 | const can_remove_link = session.active_annotation('link');
136 | const can_create_link =
137 | !session.active_annotation() && !is_selection_collapsed(session.selection);
138 | return editable && session.selection?.type === 'text' && (can_remove_link || can_create_link);
139 | }
140 |
141 | execute() {
142 | const session = this.context.session;
143 | const can_remove_link = session.active_annotation('link');
144 |
145 | if (can_remove_link) {
146 | // Delete link
147 | session.apply(session.tr.annotate_text('link'));
148 | } else {
149 | // Create link
150 | const href = window.prompt('Enter the URL', 'https://example.com');
151 | if (href) {
152 | session.apply(session.tr.annotate_text('link', { href }));
153 | }
154 | }
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/static/images/node-cursors.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/lib/transforms.svelte.js:
--------------------------------------------------------------------------------
1 | import { split_annotated_text, join_annotated_text, get_char_length } from './utils.js';
2 | import { get_default_node_type } from './doc_utils.js';
3 |
4 | export function break_text_node(tr) {
5 | // Keep a reference of the original selection (before any transforms are applied)
6 | const selection = tr.selection;
7 | // First we need to ensure we have a text selection
8 | if (selection.type !== 'text') return false;
9 |
10 | // Next, we need to determine if the enclosing node is a pure text node (e.g. paragraph),
11 | // which is wrapped inside a node_array (e.g. page.body)
12 |
13 | // Owner of the text property (e.g. paragraph)
14 | const node = tr.get(selection.path.slice(0, -1));
15 | if (tr.kind(node) !== 'text') return false;
16 | const is_inside_node_array = tr.inspect(selection.path.slice(0, -2))?.type === 'node_array';
17 | // console.log('is_inside_node_array', is_inside_node_array);
18 | if (!is_inside_node_array) return false; // Do nothing if we're not inside a node_array
19 | const node_array_prop = selection.path.at(-3);
20 | // console.log('node_array_prop', node_array_prop);
21 | // Get the node that owns the node_array property (e.g. a page.body)
22 | const node_array_node = tr.get(selection.path.slice(0, -3));
23 | // console.log('node_array_node', $state.snapshot(node_array_node));
24 |
25 | // Delete selection unless collapsed
26 | if (selection.anchor_offset !== selection.focus_offset) {
27 | tr.delete_selection();
28 | }
29 |
30 | const split_at_position = tr.selection.anchor_offset;
31 | const content = tr.get(selection.path);
32 | const [left_text, right_text] = split_annotated_text(content, split_at_position);
33 |
34 | tr.set([node.id, 'content'], left_text);
35 |
36 | const node_insert_position = {
37 | type: 'node',
38 | path: tr.selection.path.slice(0, -2),
39 | anchor_offset: parseInt(tr.selection.path.at(-2), 10) + 1,
40 | focus_offset: parseInt(tr.selection.path.at(-2), 10) + 1
41 | };
42 |
43 | // TODO: Only use default_node_type when cursor is at the end of
44 | const node_array_property_definition =
45 | tr.schema[node_array_node.type].properties[node_array_prop];
46 | const target_node_type = get_default_node_type(node_array_property_definition);
47 |
48 | if (!target_node_type) {
49 | console.warn(
50 | 'Cannot determine target node type for break_text_node - no default_ref_type and multiple node_types'
51 | );
52 | return false;
53 | }
54 |
55 | tr.set_selection(node_insert_position);
56 |
57 | tr.config.inserters[target_node_type](tr, right_text);
58 | return true;
59 | }
60 |
61 | export function join_text_node(tr) {
62 | // Keep a reference of the original selection (before any transforms are applied)
63 | const selection = tr.selection;
64 | // First we need to ensure we have a text selection
65 | if (selection.type !== 'text') return false;
66 |
67 | const node = tr.get(selection.path.slice(0, -1));
68 | if (tr.kind(node) !== 'text') return false;
69 | const is_inside_node_array = tr.inspect(selection.path.slice(0, -2))?.type === 'node_array';
70 | // console.log('is_inside_node_array', is_inside_node_array);
71 | if (!is_inside_node_array) return false; // Do nothing if we're not inside a node_array
72 |
73 | const node_index = parseInt(tr.selection.path.at(-2), 10);
74 |
75 | // Determine if we can join with the previous node
76 | let can_join = false;
77 | let predecessor_node = null;
78 |
79 | if (node_index > 0) {
80 | const previous_text_path = [...tr.selection.path.slice(0, -2), node_index - 1];
81 | predecessor_node = tr.get(previous_text_path);
82 | can_join = tr.kind(predecessor_node) === 'text';
83 | }
84 |
85 | // Special behavior: if we can't join and current node is empty, delete it
86 | if (!can_join && node.content.text === '') {
87 | tr.set_selection({
88 | type: 'node',
89 | path: tr.selection.path.slice(0, -2),
90 | anchor_offset: node_index,
91 | focus_offset: node_index + 1
92 | });
93 | tr.delete_selection();
94 | return true;
95 | }
96 |
97 | // If we can't join for any reason, return false
98 | if (!can_join) {
99 | return false;
100 | }
101 |
102 | // Normal joining logic - both nodes are text nodes
103 | const previous_text_path = [...tr.selection.path.slice(0, -2), node_index - 1];
104 | const joined_text = join_annotated_text(predecessor_node.content, node.content);
105 |
106 | // Calculate cursor position based on original predecessor content length
107 | const cursor_position = get_char_length(predecessor_node.content.text);
108 |
109 | // First set the joined content on the predecessor node (preserves annotations)
110 | tr.set([predecessor_node.id, 'content'], joined_text);
111 |
112 | // Then delete the current node
113 | tr.set_selection({
114 | type: 'node',
115 | path: tr.selection.path.slice(0, -2),
116 | anchor_offset: node_index,
117 | focus_offset: node_index + 1
118 | });
119 |
120 | tr.delete_selection();
121 |
122 | // Finally set the cursor position at the join point using the pre-calculated position
123 | tr.set_selection({
124 | type: 'text',
125 | path: [...previous_text_path, 'content'],
126 | anchor_offset: cursor_position,
127 | focus_offset: cursor_position
128 | });
129 | return true;
130 | }
131 |
132 | export function insert_default_node(tr) {
133 | const selection = tr.selection;
134 |
135 | // Only work with collapsed node selections
136 | if (selection?.type !== 'node' || selection.anchor_offset !== selection.focus_offset) {
137 | return false;
138 | }
139 |
140 | const path = selection.path;
141 | const node_array_node = tr.get(path.slice(0, -1));
142 | const property_name = path.at(-1);
143 |
144 | // Get the definition for this property
145 | const property_definition = tr.schema[node_array_node.type].properties[property_name];
146 | const default_type = get_default_node_type(property_definition);
147 |
148 | // Use the inserter function if available
149 | if (tr.config?.inserters?.[default_type]) {
150 | tr.config.inserters[default_type](tr);
151 | return true;
152 | } else {
153 | throw new Error(`No inserter function available for default node type '${default_type}'`);
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/lib/AnnotatedTextProperty.svelte:
--------------------------------------------------------------------------------
1 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
116 | {#each fragments as fragment, index (index)}
117 | {#if typeof fragment === 'string'}{fragment}{:else if fragment.type === 'selection_highlight'}{fragment.content} {:else if fragment.type === 'annotation'}
121 | {@const AnnotationComponent =
122 | svedit.session.config.node_components[snake_to_pascal(fragment.node.type)]}
123 |
127 | {/if}
128 | {/each}{#if !is_focused || !is_empty} {/if}
130 |
131 |
132 |
171 |
--------------------------------------------------------------------------------
/static/images/lightweight.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/test/create_test_session.js:
--------------------------------------------------------------------------------
1 | import Session from '../lib/Session.svelte.js';
2 | import { define_document_schema } from '../lib/doc_utils.js';
3 | import nanoid from '../routes/nanoid.js';
4 |
5 | // System components
6 | import Overlays from '../routes/components/Overlays.svelte';
7 | import NodeCursorTrap from '../routes/components/NodeCursorTrap.svelte';
8 |
9 | // Node components
10 | import Page from '../routes/components/Page.svelte';
11 | import Story from '../routes/components/Story.svelte';
12 | import Button from '../routes/components/Button.svelte';
13 | import Text from '../routes/components/Text.svelte';
14 | import List from '../routes/components/List.svelte';
15 | import ListItem from '../routes/components/ListItem.svelte';
16 |
17 | const document_schema = define_document_schema({
18 | page: {
19 | kind: 'document',
20 | properties: {
21 | body: {
22 | type: 'node_array',
23 | node_types: ['text', 'story', 'list'],
24 | default_node_type: 'text'
25 | },
26 | keywords: {
27 | type: 'string_array'
28 | },
29 | daily_visitors: {
30 | type: 'integer_array'
31 | },
32 | created_at: {
33 | type: 'datetime'
34 | }
35 | }
36 | },
37 | button: {
38 | kind: 'block',
39 | properties: {
40 | label: { type: 'annotated_text', allow_newlines: false },
41 | href: { type: 'string' }
42 | }
43 | },
44 | text: {
45 | kind: 'text',
46 | properties: {
47 | layout: { type: 'integer' },
48 | content: { type: 'annotated_text', allow_newlines: true }
49 | }
50 | },
51 | story: {
52 | kind: 'block',
53 | properties: {
54 | layout: { type: 'integer' },
55 | title: { type: 'annotated_text', allow_newlines: false },
56 | description: { type: 'annotated_text', allow_newlines: true },
57 | buttons: { type: 'node_array', node_types: ['button'], default_node_type: 'button' },
58 | image: { type: 'string' }
59 | }
60 | },
61 | list_item: {
62 | kind: 'text',
63 | properties: {
64 | content: { type: 'annotated_text', allow_newlines: true }
65 | }
66 | },
67 | list: {
68 | kind: 'block',
69 | properties: {
70 | layout: { type: 'integer' },
71 | list_items: {
72 | type: 'node_array',
73 | node_types: ['list_item'],
74 | default_node_type: 'list_item'
75 | }
76 | }
77 | }
78 | });
79 |
80 | const doc = {
81 | document_id: 'page_1',
82 | nodes: {
83 | button_1: {
84 | id: 'button_1',
85 | type: 'button',
86 | label: { text: 'Get started', annotations: [] },
87 | href: 'https://github.com/michael/svedit'
88 | },
89 | story_1: {
90 | id: 'story_1',
91 | type: 'story',
92 | layout: 1,
93 | image:
94 | 'https://images.unsplash.com/photo-1511044568932-338cba0ad803?q=80&w=400&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
95 | title: { text: 'First story', annotations: [] },
96 | buttons: ['button_1'],
97 | description: { text: 'First story description.', annotations: [] }
98 | },
99 | list_item_1: {
100 | id: 'list_item_1',
101 | type: 'list_item',
102 | content: { text: 'first list item', annotations: [] }
103 | },
104 | list_item_2: {
105 | id: 'list_item_2',
106 | type: 'list_item',
107 | content: { text: 'second list item', annotations: [] }
108 | },
109 | list_1: {
110 | id: 'list_1',
111 | type: 'list',
112 | layout: 1,
113 | list_items: ['list_item_1', 'list_item_2']
114 | },
115 | page_1: {
116 | id: 'page_1',
117 | type: 'page',
118 | body: ['story_1', 'story_1', 'list_1'],
119 | keywords: ['svelte', 'editor', 'rich content'],
120 | daily_visitors: [10, 20, 30, 100],
121 | created_at: '2025-05-30T10:39:59.987Z'
122 | }
123 | }
124 | };
125 |
126 | const session_config = {
127 | generate_id: nanoid,
128 | system_components: {
129 | NodeCursorTrap,
130 | Overlays
131 | },
132 | node_components: {
133 | Page,
134 | Button,
135 | Text,
136 | Story,
137 | List,
138 | ListItem
139 | },
140 | node_layouts: {
141 | text: 4,
142 | story: 3,
143 | list: 5,
144 | list_item: 1
145 | },
146 | inserters: {
147 | button: function (tr) {
148 | const new_button = {
149 | id: nanoid(),
150 | type: 'button',
151 | label: { text: '', annotations: [] },
152 | href: 'https://editable.website'
153 | };
154 | tr.create(new_button);
155 | tr.insert_nodes([new_button.id]);
156 | tr.set_selection({
157 | type: 'node',
158 | path: [...tr.selection.path],
159 | anchor_offset: tr.selection.focus_offset,
160 | focus_offset: tr.selection.focus_offset
161 | });
162 | },
163 | text: function (tr, content) {
164 | const text_content = content || { text: '', annotations: [] };
165 | const new_text = {
166 | id: nanoid(),
167 | type: 'text',
168 | layout: 1,
169 | content: text_content
170 | };
171 | tr.create(new_text);
172 | tr.insert_nodes([new_text.id]);
173 | tr.set_selection({
174 | type: 'text',
175 | path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'],
176 | anchor_offset: 0,
177 | focus_offset: 0
178 | });
179 | },
180 | story: function (tr) {
181 | const new_button = {
182 | id: nanoid(),
183 | type: 'button',
184 | label: { text: '', annotations: [] },
185 | href: 'https://editable.website'
186 | };
187 | tr.create(new_button);
188 | const new_story = {
189 | id: nanoid(),
190 | type: 'story',
191 | layout: 1,
192 | image: '',
193 | title: { text: '', annotations: [] },
194 | description: { text: '', annotations: [] },
195 | buttons: [new_button.id]
196 | };
197 | tr.create(new_story);
198 | tr.insert_nodes([new_story.id]);
199 | },
200 | list: function (tr) {
201 | const new_list_item = {
202 | id: nanoid(),
203 | type: 'list_item',
204 | content: { text: '', annotations: [] }
205 | };
206 | tr.create(new_list_item);
207 | const new_list = {
208 | id: nanoid(),
209 | type: 'list',
210 | list_items: [new_list_item.id],
211 | layout: 3
212 | };
213 | tr.create(new_list);
214 | tr.insert_nodes([new_list.id]);
215 | },
216 | list_item: function (tr, content) {
217 | const item_content = content || { text: '', annotations: [] };
218 | const new_list_item = {
219 | id: nanoid(),
220 | type: 'list_item',
221 | content: item_content
222 | };
223 | tr.create(new_list_item);
224 | tr.insert_nodes([new_list_item.id]);
225 | tr.set_selection({
226 | type: 'text',
227 | path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'],
228 | anchor_offset: 0,
229 | focus_offset: 0
230 | });
231 | }
232 | }
233 | };
234 |
235 | export default function create_test_session() {
236 | const session = new Session(document_schema, doc, session_config);
237 | return session;
238 | }
239 |
--------------------------------------------------------------------------------
/src/test/Session.svelte.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import create_test_session from './create_test_session.js';
3 |
4 | describe('Session.svelte.js', () => {
5 | it('should be traversable', () => {
6 | const session = create_test_session();
7 |
8 | // Resolve node by id
9 | const page_1 = session.get('page_1');
10 | expect(page_1.id).toBe('page_1');
11 | expect(page_1.type).toBe('page');
12 |
13 | // Resolve node_array property
14 | const body = session.get(['page_1', 'body']);
15 | expect(body).toEqual(['story_1', 'story_1', 'list_1']);
16 |
17 | // Access an element of a node_array property
18 | const first_story = session.get(['page_1', 'body', 0]);
19 | expect(first_story.id).toBe('story_1');
20 | expect(first_story.type).toBe('story');
21 |
22 | // Resolve annotated_text property
23 | const fist_story_title = session.get(['page_1', 'body', 0, 'title']);
24 | expect(fist_story_title).toEqual({ text: 'First story', annotations: [] });
25 |
26 | // Resolve integer_array
27 | const daily_visitors = session.get(['page_1', 'daily_visitors']);
28 | expect(daily_visitors).toEqual([10, 20, 30, 100]);
29 |
30 | // Resolve integer_array element
31 | const daily_visitors_first_day = session.get(['page_1', 'daily_visitors', 1]);
32 | expect(daily_visitors_first_day).toBe(20);
33 |
34 | // Resolve string_array
35 | const keywords = session.get(['page_1', 'keywords']);
36 | expect(keywords).toEqual(['svelte', 'editor', 'rich content']);
37 |
38 | // Resolve string_array element
39 | const first_keyword = session.get(['page_1', 'keywords', 2]);
40 | expect(first_keyword).toBe('rich content');
41 |
42 | // Resolve hierarchy using node_array
43 | const list_items_of_first_list = session.get(['page_1', 'body', 2, 'list_items']);
44 | expect(list_items_of_first_list).toEqual(['list_item_1', 'list_item_2']);
45 |
46 | // Resolve hierarchy using node_array and accessing an annotated_text property
47 | const first_list_item_content = session.get(['page_1', 'body', 2, 'list_items', 0, 'content']);
48 | expect(first_list_item_content).toEqual({ text: 'first list item', annotations: [] });
49 | });
50 |
51 | describe('Deletion scenarios', () => {
52 | it('should delete unreferenced nodes and their children when deleting from node_array', () => {
53 | const session = create_test_session();
54 |
55 | // Initial state: body has ['story_1, 'story_1, 'list_1]
56 | expect(session.get(['page_1', 'body'])).toEqual(['story_1', 'story_1', 'list_1']);
57 | expect(session.get('list_1')).toBeDefined();
58 | expect(session.get('list_item_1')).toBeDefined();
59 | expect(session.get('list_item_2')).toBeDefined();
60 |
61 | // Delete the list (index 2) - it has no other references
62 | session.selection = {
63 | type: 'node',
64 | path: ['page_1', 'body'],
65 | anchor_offset: 2,
66 | focus_offset: 3
67 | };
68 |
69 | const tr = session.tr;
70 | tr.delete_selection();
71 | session.apply(tr);
72 |
73 | // Body should no longer contain the list
74 | expect(session.get(['page_1', 'body'])).toEqual(['story_1', 'story_1']);
75 |
76 | // List and its children should be deleted since no other references exist
77 | expect(session.get('list_1')).toBeUndefined();
78 | expect(session.get('list_item_1')).toBeUndefined();
79 | expect(session.get('list_item_2')).toBeUndefined();
80 | });
81 |
82 | it('should only remove reference when deleting multiply-referenced nodes', () => {
83 | const session = create_test_session();
84 |
85 | // Initial state: story is referenced twice
86 | expect(session.get(['page_1', 'body'])).toEqual(['story_1', 'story_1', 'list_1']);
87 | expect(session.get('story_1')).toBeDefined();
88 |
89 | // Delete first story reference (index 0)
90 | session.selection = {
91 | type: 'node',
92 | path: ['page_1', 'body'],
93 | anchor_offset: 0,
94 | focus_offset: 1
95 | };
96 |
97 | const tr = session.tr;
98 | tr.delete_selection();
99 | session.apply(tr);
100 |
101 | // Body should only have one story reference now
102 | expect(session.get(['page_1', 'body'])).toEqual(['story_1', 'list_1']);
103 |
104 | // Story node should still exist since it's still referenced
105 | expect(session.get('story_1')).toBeDefined();
106 | });
107 |
108 | it('should delete nodes when all references are removed', () => {
109 | const session = create_test_session();
110 |
111 | // Initial state: story is referenced twice
112 | expect(session.get(['page_1', 'body'])).toEqual(['story_1', 'story_1', 'list_1']);
113 | expect(session.get('story_1')).toBeDefined();
114 |
115 | // Delete both story references (index 0 and 1)
116 | session.selection = {
117 | type: 'node',
118 | path: ['page_1', 'body'],
119 | anchor_offset: 0,
120 | focus_offset: 2
121 | };
122 |
123 | const tr = session.tr;
124 | tr.delete_selection();
125 | session.apply(tr);
126 |
127 | // Body should only contain the list
128 | expect(session.get(['page_1', 'body'])).toEqual(['list_1']);
129 |
130 | // Story node should be deleted since no references remain
131 | expect(session.get('story_1')).toBeUndefined();
132 | });
133 |
134 | it('should properly restore deleted nodes and references on undo', () => {
135 | const session = create_test_session();
136 |
137 | // Delete the list
138 | session.selection = {
139 | type: 'node',
140 | path: ['page_1', 'body'],
141 | anchor_offset: 2,
142 | focus_offset: 3
143 | };
144 |
145 | const tr = session.tr;
146 | tr.delete_selection();
147 | session.apply(tr);
148 |
149 | // Verify deletion
150 | expect(session.get(['page_1', 'body'])).toEqual(['story_1', 'story_1']);
151 | expect(session.get('list_1')).toBeUndefined();
152 | expect(session.get('list_item_1')).toBeUndefined();
153 | expect(session.get('list_item_2')).toBeUndefined();
154 |
155 | // Undo the deletion
156 | session.undo();
157 |
158 | // Everything should be restored
159 | expect(session.get(['page_1', 'body'])).toEqual(['story_1', 'story_1', 'list_1']);
160 | expect(session.get('list_1')).toBeDefined();
161 | expect(session.get('list_item_1')).toBeDefined();
162 | expect(session.get('list_item_2')).toBeDefined();
163 | expect(session.get('list_1').list_items).toEqual(['list_item_1', 'list_item_2']);
164 | });
165 |
166 | it('should handle complex nested deletion scenarios', () => {
167 | const session = create_test_session();
168 |
169 | // Delete one story reference first
170 | session.selection = {
171 | type: 'node',
172 | path: ['page_1', 'body'],
173 | anchor_offset: 0,
174 | focus_offset: 1
175 | };
176 |
177 | let tr = session.tr;
178 | tr.delete_selection();
179 | session.apply(tr);
180 |
181 | expect(session.get(['page_1', 'body'])).toEqual(['story_1', 'list_1']);
182 | expect(session.get('story_1')).toBeDefined(); // Should still exist
183 |
184 | // Now delete the remaining story and list
185 | session.selection = {
186 | type: 'node',
187 | path: ['page_1', 'body'],
188 | anchor_offset: 0,
189 | focus_offset: 2
190 | };
191 |
192 | tr = session.tr;
193 | tr.delete_selection();
194 | session.apply(tr);
195 |
196 | expect(session.get(['page_1', 'body'])).toEqual([]);
197 | expect(session.get('story_1')).toBeUndefined(); // Now should be deleted
198 | expect(session.get('list_1')).toBeUndefined();
199 | expect(session.get('list_item_1')).toBeUndefined();
200 | expect(session.get('list_item_2')).toBeUndefined();
201 | });
202 | });
203 | });
204 |
--------------------------------------------------------------------------------
/static/images/cjk.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/static/images/editable.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/routes/styles/spacing.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --s-05: 0.125rem; /* 2px */
3 | --s-1: 0.25rem; /* 4px */
4 | --s-2: 0.5rem; /* 8px */
5 | --s-3: 0.75rem; /* 12px */
6 | --s-4: 1rem; /* 16px */
7 | --s-5: 1.25rem; /* 20px */
8 | --s-6: 1.5rem; /* 24px */
9 | --s-7: 1.75rem; /* 28px */
10 | --s-8: 2rem; /* 32px */
11 | --s-9: 2.25rem; /* 36px */
12 | --s-10: 2.5rem; /* 40px */
13 | }
14 |
15 | /****************** Margin Utility Classes (Tailwind compatible) ******************/
16 |
17 | .m-05 { --m: var(--s-05)} .ms-05 { --ms: var(--s-05);} .me-05 { --me: var(--s-05); } .mbs-05 { --mbs: var(--s-05); } .mbe-05 { --mbe: var(--s-05); } .mx-05 { --mx: var(--s-05); } .my-05 { --my: var(--s-05); }
18 | .m-1 { --m: var(--s-1) } .ms-1 { --ms: var(--s-1); } .me-1 { --me: var(--s-1); } .mbs-1 { --mbs: var(--s-1); } .mbe-1 { --mbe: var(--s-1); } .mx-1 { --mx: var(--s-1); } .my-1 { --my: var(--s-1); }
19 | .m-2 { --m: var(--s-2) } .ms-2 { --ms: var(--s-2); } .me-2 { --me: var(--s-2); } .mbs-2 { --mbs: var(--s-2); } .mbe-2 { --mbe: var(--s-2); } .mx-2 { --mx: var(--s-2); } .my-2 { --my: var(--s-2); }
20 | .m-3 { --m: var(--s-3) } .ms-3 { --ms: var(--s-3); } .me-3 { --me: var(--s-3); } .mbs-3 { --mbs: var(--s-3); } .mbe-3 { --mbe: var(--s-3); } .mx-3 { --mx: var(--s-3); } .my-3 { --my: var(--s-3); }
21 | .m-4 { --m: var(--s-4) } .ms-4 { --ms: var(--s-4); } .me-4 { --me: var(--s-4); } .mbs-4 { --mbs: var(--s-4); } .mbe-4 { --mbe: var(--s-4); } .mx-4 { --mx: var(--s-4); } .my-4 { --my: var(--s-4); }
22 | .m-5 { --m: var(--s-5) } .ms-5 { --ms: var(--s-5); } .me-5 { --me: var(--s-5); } .mbs-5 { --mbs: var(--s-5); } .mbe-5 { --mbe: var(--s-5); } .mx-5 { --mx: var(--s-5); } .my-5 { --my: var(--s-5); }
23 | .m-6 { --m: var(--s-6) } .ms-6 { --ms: var(--s-6); } .me-6 { --me: var(--s-6); } .mbs-6 { --mbs: var(--s-6); } .mbe-6 { --mbe: var(--s-6); } .mx-6 { --mx: var(--s-6); } .my-6 { --my: var(--s-6); }
24 | .m-7 { --m: var(--s-7) } .ms-7 { --ms: var(--s-7); } .me-7 { --me: var(--s-7); } .mbs-7 { --mbs: var(--s-7); } .mbe-7 { --mbe: var(--s-7); } .mx-7 { --mx: var(--s-7); } .my-7 { --my: var(--s-7); }
25 | .m-8 { --m: var(--s-8) } .ms-8 { --ms: var(--s-8); } .me-8 { --me: var(--s-8); } .mbs-8 { --mbs: var(--s-8); } .mbe-8 { --mbe: var(--s-8); } .mx-8 { --mx: var(--s-8); } .my-8 { --my: var(--s-8); }
26 | .m-9 { --m: var(--s-9) } .ms-9 { --ms: var(--s-9); } .me-9 { --me: var(--s-9); } .mbs-9 { --mbs: var(--s-9); } .mbe-9 { --mbe: var(--s-9); } .mx-9 { --mx: var(--s-9); } .my-9 { --my: var(--s-9); }
27 | .m-10 { --m: var(--s-10)} .ms-10 { --ms: var(--s-10);} .me-10 { --me: var(--s-10); } .mbs-10 { --mbs: var(--s-10); } .mbe-10 { --mbe: var(--s-10); } .mx-10 { --mx: var(--s-10); } .my-10 { --my: var(--s-10); }
28 | .m-auto { margin: auto; }
29 | .mx-auto { margin-inline: auto; }
30 | .my-auto { margin-block: auto; }
31 | .ms-auto { margin-inline-start: auto; }
32 | .me-auto { margin-inline-end: auto; }
33 | .mbs-auto { margin-block-start: auto; }
34 | .mbe-auto { margin-block-end: auto; }
35 | .m-0 { margin: 0; }
36 |
37 |
38 | /* for less than ~10 spacing values, we use the following approach */
39 | .m-05, .m-1, .m-2, .m-3, .m-4, .m-5, .m-6, .m-7, .m-8, .m-9, .m-10 { margin: var(--m); }
40 | .mx-05, .mx-1, .mx-2, .mx-3, .mx-4, .mx-5, .mx-6, .mx-7, .mx-8, .mx-9, .mx-10 { margin-inline: var(--mx); }
41 | .my-05, .my-1, .my-2, .my-3, .my-4, .my-5, .my-6, .my-7, .my-8, .my-9, .my-10 { margin-block: var(--my); }
42 | .ms-05, .ms-1, .ms-2, .ms-3, .ms-4, .ms-5, .ms-6, .ms-7, .ms-8, .ms-9, .ms-10 { margin-inline-start: var(--ms); }
43 | .me-05, .me-1, .me-2, .me-3, .me-4, .me-5, .me-6, .me-7, .me-8, .me-9, .me-10 { margin-inline-end: var(--me); }
44 | .mbs-05, .mbs-1, .mbs-2, .mbs-3, .mbs-4, .mbs-5, .mbs-6, .mbs-7, .mbs-8, .mbs-9, .mbs-10 { margin-block-start: var(--mbs); }
45 | .mbe-05, .mbe-1, .mbe-2, .mbe-3, .mbe-4, .mbe-5, .mbe-6, .mbe-7, .mbe-8, .mbe-9, .mbe-10 { margin-block-end: var(--mbe); }
46 |
47 | /* for more than 10 spacing values, the following approach will be more efficient */
48 | /* Selector explanation: Start with prefix (^=), prefix preceded by a space (*=" "), end with the specified prefix ($=) */
49 | /* Note: we can't use [class*="m-"] because it's too greedy and matches too many classes */
50 |
51 | /* [class*=" m-"], [class^="m-"], [class$="m-"] { margin: var(--m); }
52 | [class*=" mx-"], [class^="mx-"], [class$="mx-"] { margin-inline: var(--mx); }
53 | [class*=" my-"], [class^="my-"], [class$="my-"] { margin-block: var(--my); }
54 | [class*=" ms-"], [class^="ms-"], [class$="ms-"] { margin-inline-start: var(--ms); }
55 | [class*=" me-"], [class^="me-"], [class$="me-"] { margin-inline-end: var(--me); }
56 | [class*=" mbs-"], [class^="mbs-"], [class$="mbs-"] { margin-block-start: var(--mbs); }
57 | [class*=" mbe-"], [class^="mbe-"], [class$="mbe-"] { margin-block-end: var(--mbe); } */
58 |
59 |
60 | /****************** Padding Utility Classes (Tailwind compatible) ******************/
61 |
62 | .p-05 { --p: var(--s-05)} .ps-05 { --ps: var(--s-05);} .pe-05 { --pe: var(--s-05); } .pbs-05 { --pbs: var(--s-05); } .pbe-05 { --pbe: var(--s-05); } .px-05 { --px: var(--s-05); } .py-05 { --py: var(--s-05); }
63 | .p-1 { --p: var(--s-1) } .ps-1 { --ps: var(--s-1); } .pe-1 { --pe: var(--s-1); } .pbs-1 { --pbs: var(--s-1); } .pbe-1 { --pbe: var(--s-1); } .px-1 { --px: var(--s-1); } .py-1 { --py: var(--s-1); }
64 | .p-2 { --p: var(--s-2) } .ps-2 { --ps: var(--s-2); } .pe-2 { --pe: var(--s-2); } .pbs-2 { --pbs: var(--s-2); } .pbe-2 { --pbe: var(--s-2); } .px-2 { --px: var(--s-2); } .py-2 { --py: var(--s-2); }
65 | .p-3 { --p: var(--s-3) } .ps-3 { --ps: var(--s-3); } .pe-3 { --pe: var(--s-3); } .pbs-3 { --pbs: var(--s-3); } .pbe-3 { --pbe: var(--s-3); } .px-3 { --px: var(--s-3); } .py-3 { --py: var(--s-3); }
66 | .p-4 { --p: var(--s-4) } .ps-4 { --ps: var(--s-4); } .pe-4 { --pe: var(--s-4); } .pbs-4 { --pbs: var(--s-4); } .pbe-4 { --pbe: var(--s-4); } .px-4 { --px: var(--s-4); } .py-4 { --py: var(--s-4); }
67 | .p-5 { --p: var(--s-5) } .ps-5 { --ps: var(--s-5); } .pe-5 { --pe: var(--s-5); } .pbs-5 { --pbs: var(--s-5); } .pbe-5 { --pbe: var(--s-5); } .px-5 { --px: var(--s-5); } .py-5 { --py: var(--s-5); }
68 | .p-6 { --p: var(--s-6) } .ps-6 { --ps: var(--s-6); } .pe-6 { --pe: var(--s-6); } .pbs-6 { --pbs: var(--s-6); } .pbe-6 { --pbe: var(--s-6); } .px-6 { --px: var(--s-6); } .py-6 { --py: var(--s-6); }
69 | .p-7 { --p: var(--s-7) } .ps-7 { --ps: var(--s-7); } .pe-7 { --pe: var(--s-7); } .pbs-7 { --pbs: var(--s-7); } .pbe-7 { --pbe: var(--s-7); } .px-7 { --px: var(--s-7); } .py-7 { --py: var(--s-7); }
70 | .p-8 { --p: var(--s-8) } .ps-8 { --ps: var(--s-8); } .pe-8 { --pe: var(--s-8); } .pbs-8 { --pbs: var(--s-8); } .pbe-8 { --pbe: var(--s-8); } .px-8 { --px: var(--s-8); } .py-8 { --py: var(--s-8); }
71 | .p-9 { --p: var(--s-9) } .ps-9 { --ps: var(--s-9); } .pe-9 { --pe: var(--s-9); } .pbs-9 { --pbs: var(--s-9); } .pbe-9 { --pbe: var(--s-9); } .px-9 { --px: var(--s-9); } .py-9 { --py: var(--s-9); }
72 | .p-10 { --p: var(--s-10)} .ps-10 { --ps: var(--s-10);} .pe-10 { --pe: var(--s-10); } .pbs-10 { --pbs: var(--s-10); } .pbe-10 { --pbe: var(--s-10); } .px-10 { --px: var(--s-10); } .py-10 { --py: var(--s-10); }
73 |
74 | .p-05, .p-1, .p-2, .p-3, .p-4, .p-5, .p-6, .p-7, .p-8, .p-9, .p-10 { padding: var(--p); }
75 | .px-05, .px-1, .px-2, .px-3, .px-4, .px-5, .px-6, .px-7, .px-8, .px-9, .px-10 { padding-inline: var(--px); }
76 | .py-05, .py-1, .py-2, .py-3, .py-4, .py-5, .py-6, .py-7, .py-8, .py-9, .py-10 { padding-block: var(--py); }
77 | .ps-05, .ps-1, .ps-2, .ps-3, .ps-4, .ps-5, .ps-6, .ps-7, .ps-8, .ps-9, .ps-10 { padding-inline-start: var(--ps); }
78 | .pe-05, .pe-1, .pe-2, .pe-3, .pe-4, .pe-5, .pe-6, .pe-7, .pe-8, .pe-9, .pe-10 { padding-inline-end: var(--pe); }
79 | .pbs-05, .pbs-1, .pbs-2, .pbs-3, .pbs-4, .pbs-5, .pbs-6, .pbs-7, .pbs-8, .pbs-9, .pbs-10 { padding-block-start: var(--pbs); }
80 | .pbe-05, .pbe-1, .pbe-2, .pbe-3, .pbe-4, .pbe-5, .pbe-6, .pbe-7, .pbe-8, .pbe-9, .pbe-10 { padding-block-end: var(--pbe); }
81 |
82 | /****************** Gap Utility Classes (Tailwind compatible) ******************/
83 |
84 | .gap-05 { --g: var(--s-05); } .gap-x-05 { --gx: var(--s-05); } .gap-y-05 { --gy: var(--s-05); }
85 | .gap-1 { --g: var(--s-1); } .gap-x-1 { --gx: var(--s-1); } .gap-y-1 { --gy: var(--s-1); }
86 | .gap-2 { --g: var(--s-2); } .gap-x-2 { --gx: var(--s-2); } .gap-y-2 { --gy: var(--s-2); }
87 | .gap-3 { --g: var(--s-3); } .gap-x-3 { --gx: var(--s-3); } .gap-y-3 { --gy: var(--s-3); }
88 | .gap-4 { --g: var(--s-4); } .gap-x-4 { --gx: var(--s-4); } .gap-y-4 { --gy: var(--s-4); }
89 | .gap-5 { --g: var(--s-5); } .gap-x-5 { --gx: var(--s-5); } .gap-y-5 { --gy: var(--s-5); }
90 | .gap-6 { --g: var(--s-6); } .gap-x-6 { --gx: var(--s-6); } .gap-y-6 { --gy: var(--s-6); }
91 | .gap-7 { --g: var(--s-7); } .gap-x-7 { --gx: var(--s-7); } .gap-y-7 { --gy: var(--s-7); }
92 | .gap-8 { --g: var(--s-8); } .gap-x-8 { --gx: var(--s-8); } .gap-y-8 { --gy: var(--s-8); }
93 | .gap-9 { --g: var(--s-9); } .gap-x-9 { --gx: var(--s-9); } .gap-y-9 { --gy: var(--s-9); }
94 | .gap-10 { --g: var(--s-10); } .gap-x-10 { --gx: var(--s-10); } .gap-y-10 { --gy: var(--s-10); }
95 |
96 | .gap-05, .gap-1, .gap-2, .gap-3, .gap-4, .gap-5, .gap-6, .gap-7, .gap-8, .gap-9, .gap-10 { gap: var(--g); }
97 | .gap-x-05, .gap-x-1, .gap-x-2, .gap-x-3, .gap-x-4, .gap-x-5, .gap-x-6, .gap-x-7, .gap-x-8, .gap-x-9, .gap-x-10 { column-gap: var(--gx); }
98 | .gap-y-05, .gap-y-1, .gap-y-2, .gap-y-3, .gap-y-4, .gap-y-5, .gap-y-6, .gap-y-7, .gap-y-8, .gap-y-9, .gap-y-10 { row-gap: var(--gy); }
99 |
100 |
101 |
102 | /* Max-Width */
103 | .max-w-prose { max-width: 65ch; }
104 | .max-w-screen-sm { max-width: 640px; }
105 | .max-w-screen-md { max-width: 768px; }
106 | .max-w-screen-lg { max-width: 1024px; }
107 | .max-w-screen-xl { max-width: 1280px; }
108 | .max-w-screen-2xl { max-width: 1536px; }
109 |
110 | /* Width */
111 | .w-full { width: 100%; }
--------------------------------------------------------------------------------
/src/lib/Command.svelte.js:
--------------------------------------------------------------------------------
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}
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 | }
315 |
--------------------------------------------------------------------------------
/static/images/annotations.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/routes/components/Toolbar.svelte:
--------------------------------------------------------------------------------
1 |
142 |
143 |
144 |
275 |
276 |
366 |
--------------------------------------------------------------------------------
/static/images/extendable.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/lib/types.d.ts:
--------------------------------------------------------------------------------
1 | import type Session from './Session.svelte.js';
2 | import type Svedit from './Svedit.svelte';
3 |
4 | // ===== SVELTE TYPE IMPORTS =====
5 |
6 | /**
7 | * Import Svelte's Snippet type for properly typing children in components
8 | */
9 | import type { Component, Snippet } from 'svelte';
10 |
11 | // ===== SELECTION TYPE DEFINITIONS =====
12 |
13 | /**
14 | * A unique node identifier (ideally UUID)
15 | */
16 | export type NodeId = string;
17 |
18 | /**
19 | * Array of IDs, property names (strings), or indexes (integers) that identify a node or property in the document
20 | */
21 | export type DocumentPath = Array;
22 |
23 | /**
24 | * Text selection within a text property
25 | */
26 | export type TextSelection = {
27 | type: 'text';
28 | path: DocumentPath;
29 | anchor_offset: number;
30 | focus_offset: number;
31 | };
32 |
33 | /**
34 | * Node selection within a node array
35 | */
36 | export type NodeSelection = {
37 | type: 'node';
38 | path: DocumentPath;
39 | anchor_offset: number;
40 | focus_offset: number;
41 | };
42 |
43 | /**
44 | * Property selection within a node
45 | */
46 | export type PropertySelection = {
47 | type: 'property';
48 | path: DocumentPath;
49 | };
50 |
51 | /**
52 | * Union type for all possible selection types
53 | */
54 | export type Selection = TextSelection | NodeSelection | PropertySelection;
55 |
56 | /**
57 | * Represents only the range (no direction and payload) of a NodeSelection or TextSelection
58 | */
59 | export type SelectionRange = {
60 | start_offset: number;
61 | end_offset: number;
62 | };
63 |
64 | // ===== SCHEMA TYPE DEFINITIONS =====
65 |
66 | /**
67 | * Basic scalar types supported by the schema system.
68 | */
69 | export type ScalarType = 'string' | 'number' | 'boolean' | 'integer' | 'datetime';
70 |
71 | /**
72 | * Array types for collections of scalar values.
73 | */
74 | export type ArrayType = 'string_array' | 'number_array' | 'boolean_array' | 'integer_array';
75 |
76 | /**
77 | * Special types for rich content.
78 | */
79 | export type RichType = 'annotated_text';
80 |
81 | /**
82 | * Node reference types for linking to other nodes.
83 | */
84 | export type ReferenceType = 'node' | 'node_array';
85 |
86 | /**
87 | * All primitive types that can be used in property definitions.
88 | */
89 | export type PrimitiveType = ScalarType | ArrayType | RichType;
90 |
91 | /**
92 | * All possible property types in schemas.
93 | */
94 | export type PropertyType = PrimitiveType | ReferenceType;
95 |
96 | /**
97 | * Document schema primitive types - all possible property types in document schemas.
98 | */
99 | export type DocumentSchemaPrimitive =
100 | | 'string'
101 | | 'number'
102 | | 'boolean'
103 | | 'integer'
104 | | 'datetime'
105 | | 'string_array'
106 | | 'number_array'
107 | | 'boolean_array'
108 | | 'integer_array'
109 | | 'annotated_text'
110 | | 'node'
111 | | 'node_array';
112 |
113 | /**
114 | * Maps document schema types to their JavaScript runtime types.
115 | */
116 | export type DocumentSchemaValueToJs = T extends 'string'
117 | ? string
118 | : T extends 'number'
119 | ? number
120 | : T extends 'boolean'
121 | ? boolean
122 | : T extends 'integer'
123 | ? number
124 | : T extends 'datetime'
125 | ? string
126 | : T extends 'string_array'
127 | ? Array
128 | : T extends 'number_array'
129 | ? Array
130 | : T extends 'boolean_array'
131 | ? Array
132 | : T extends 'integer_array'
133 | ? Array
134 | : T extends 'annotated_text'
135 | ? AnnotatedText
136 | : T extends 'node'
137 | ? string
138 | : T extends 'node_array'
139 | ? Array
140 | : never;
141 |
142 | /**
143 | * Converts a document node schema definition to its inferred JS type.
144 | * Handles the {type: "..."} wrapper structure used in document schemas.
145 | */
146 | export type DocumentNodeToJs = { id: string; type: string } & {
147 | [K in keyof S['properties']]: DocumentSchemaValueToJs;
148 | };
149 |
150 | /**
151 | * A property that stores an annotated text with required allow_newlines setting.
152 | */
153 | export type AnnotatedTextProperty = {
154 | type: 'annotated_text';
155 | node_types?: string[];
156 | allow_newlines: boolean;
157 | };
158 |
159 | /**
160 | * A property that stores a string value.
161 | */
162 | export type StringProperty = {
163 | type: 'string';
164 | default?: string;
165 | };
166 |
167 | /**
168 | * A property that stores a number value.
169 | */
170 | export type NumberProperty = {
171 | type: 'number';
172 | default?: number;
173 | };
174 |
175 | /**
176 | * A property that stores a boolean value.
177 | */
178 | export type BooleanProperty = {
179 | type: 'boolean';
180 | default?: boolean;
181 | };
182 |
183 | /**
184 | * A property that stores an integer value.
185 | */
186 | export type IntegerProperty = {
187 | type: 'integer';
188 | default?: number;
189 | };
190 |
191 | /**
192 | * A property that stores a datetime value.
193 | */
194 | export type DatetimeProperty = {
195 | type: 'datetime';
196 | default?: string;
197 | };
198 |
199 | /**
200 | * A property that stores an array of strings.
201 | */
202 | export type StringArrayProperty = {
203 | type: 'string_array';
204 | default?: string[];
205 | };
206 |
207 | /**
208 | * A property that stores an array of numbers.
209 | */
210 | export type NumberArrayProperty = {
211 | type: 'number_array';
212 | default?: number[];
213 | };
214 |
215 | /**
216 | * A property that stores an array of booleans.
217 | */
218 | export type BooleanArrayProperty = {
219 | type: 'boolean_array';
220 | default?: boolean[];
221 | };
222 |
223 | /**
224 | * A property that stores an array of integers.
225 | */
226 | export type IntegerArrayProperty = {
227 | type: 'integer_array';
228 | default?: number[];
229 | };
230 |
231 | /**
232 | * A property that stores a primitive value (excluding annotated_text).
233 | */
234 | export type PrimitiveProperty =
235 | | StringProperty
236 | | NumberProperty
237 | | BooleanProperty
238 | | IntegerProperty
239 | | DatetimeProperty
240 | | StringArrayProperty
241 | | NumberArrayProperty
242 | | BooleanArrayProperty
243 | | IntegerArrayProperty;
244 |
245 | /**
246 | * A property that stores a reference to a single node.
247 | */
248 | export type NodeProperty = {
249 | type: 'node';
250 | node_types: string[];
251 | default_node_type?: string;
252 | };
253 |
254 | /**
255 | * A property that stores an array of node references.
256 | */
257 | export type NodeArrayProperty = {
258 | type: 'node_array';
259 | node_types: string[];
260 | default_node_type?: string;
261 | };
262 |
263 | /**
264 | * Union type for all possible property definitions.
265 | */
266 | export type PropertyDefinition =
267 | | PrimitiveProperty
268 | | AnnotatedTextProperty
269 | | NodeProperty
270 | | NodeArrayProperty;
271 |
272 | /**
273 | * Node kind values for different types of content nodes
274 | */
275 | export type NodeKind = 'document' | 'block' | 'text' | 'annotation';
276 |
277 | /**
278 | * Schema for text nodes - must have a content property of type annotated_text
279 | */
280 | export type TextNodeSchema = {
281 | kind: 'text';
282 | properties: {
283 | content: AnnotatedTextProperty;
284 | } & Record;
285 | };
286 |
287 | /**
288 | * Schema for non-text nodes
289 | */
290 | export type NonTextNodeSchema = {
291 | kind: 'document' | 'block' | 'annotation';
292 | properties: Record;
293 | };
294 |
295 | /**
296 | * A node schema defines the structure of a specific node type.
297 | * Contains a kind and properties object that maps property names to their definitions.
298 | */
299 | export type NodeSchema = TextNodeSchema | NonTextNodeSchema;
300 |
301 | /**
302 | * A document schema defines all node types available in a document.
303 | * Maps node type names to their schemas.
304 | */
305 | export type DocumentSchema = Record;
306 |
307 | /**
308 | * A node in the document.
309 | * Must have id and type properties, with other properties defined by the schema.
310 | */
311 | export type DocumentNode = {
312 | id: string;
313 | type: string;
314 | [key: string]: any;
315 | };
316 |
317 | /**
318 | * The document format - an object with document_id and nodes.
319 | * The nodes object maps node IDs to their node data.
320 | */
321 | export type Document = {
322 | document_id: string;
323 | nodes: {
324 | [key: string]: DocumentNode;
325 | };
326 | };
327 |
328 | /**
329 | * Props for the AnnotatedTextProperty component
330 | */
331 | export type AnnotatedTextPropertyProps = {
332 | /** The full path to the property */
333 | path: DocumentPath;
334 | /** Optional custom HTML tag */
335 | tag?: string;
336 | /** The `class` attribute on the content element */
337 | class?: string;
338 | /** A placeholder to be rendered for empty content */
339 | placeholder?: string;
340 | };
341 |
342 | /**
343 | * Props for the CustomProperty component
344 | */
345 | export type CustomPropertyProps = {
346 | /** The full path to the property */
347 | path: DocumentPath;
348 | /** Optional custom HTML tag */
349 | tag?: string;
350 | /** The `class` attribute on the content element */
351 | class?: string;
352 | /** The content of the custom property (e.g. an image) */
353 | children: Snippet;
354 | };
355 |
356 | /**
357 | * Props for the NodeArray component
358 | */
359 | export type NodeArrayPropertyProps = {
360 | /** The full path to the property */
361 | path: DocumentPath;
362 | /** Optional custom HTML tag */
363 | tag?: string;
364 | /** Optional custom HTML tag */
365 | tag?: string;
366 | /** The `class` attribute on the container element */
367 | class?: string;
368 | };
369 |
370 | /**
371 | * Props for the Node component
372 | */
373 | export type NodeProps = {
374 | /** The full path to the node */
375 | path: DocumentPath;
376 | /** Optional custom HTML tag */
377 | tag?: string;
378 | /** Optional string of CSS classes */
379 | class?: string;
380 | /** The type-specific content of the node */
381 | children: Snippet;
382 | };
383 |
384 | /**
385 | * Props for the CustomProperty component
386 | */
387 | export type CustomPropertyProps = {
388 | /** The full path to the property */
389 | path: DocumentPath;
390 | /** The `class` attribute on the content element */
391 | class?: string;
392 | /** The content of the custom property (e.g. an image) */
393 | children: Snippet;
394 | };
395 |
396 | /**
397 | * Props for the CustomProperty component
398 | */
399 | export type SveditProps = {
400 | /** The session instance */
401 | session: Session;
402 | /** Determines wether the document should be editable or read-only. */
403 | editable?: boolean;
404 | /** The path to the root element (e.g. ['page_1']) */
405 | path: DocumentPath;
406 | /** The `class` attribute on the canvas element */
407 | class?: string;
408 | /** The `autocapitalize` attribute on the canvas element */
409 | autocapitalize?: 'on' | 'off';
410 | /** The `spellcheck` attribute on the canvas element */
411 | spellcheck?: 'true' | 'false';
412 | };
413 |
414 | /**
415 | * Represents an annotation in an annotated string
416 | */
417 | export type Annotation = {
418 | start_offset: number;
419 | end_offset: number;
420 | node_id: NodeId;
421 | };
422 |
423 | /**
424 | * Represents an annotated text with text and annotations
425 | */
426 | export type AnnotatedText = {
427 | text: string;
428 | annotations: Array;
429 | };
430 |
431 | /**
432 | * Represents a selection highlight fragment for non-annotated text selections
433 | */
434 | export type SelectionHighlightFragment = {
435 | type: 'selection_highlight';
436 | content: string;
437 | };
438 |
439 | /**
440 | * Represents an annotation fragment in annotated text content
441 | */
442 | export type AnnotationFragment = {
443 | type: 'annotation';
444 | /** NodeId that has annotation type and details */
445 | node: any;
446 | /** The text content of the annotation */
447 | content: string;
448 | /** Index of the annotation in the original array */
449 | annotation_index: number;
450 | };
451 |
452 | /**
453 | * Represents a fragment of annotated text content
454 | */
455 | export type Fragment = string | AnnotationFragment | SelectionHighlightFragment;
456 |
--------------------------------------------------------------------------------