├── .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 |
20 |

21 |
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 |
25 | 26 |
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 |
61 | 67 |
68 |
69 | 70 | 79 | -------------------------------------------------------------------------------- /src/routes/components/ImageGridItem.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 |
11 | 12 |
13 | {node.title.text} 18 |
19 |
20 |
21 | 22 | 27 | 28 |
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 |
60 | 61 |
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 |
21 | 26 | 31 |
32 | Star 40 |
41 |
42 |
43 |
44 | 45 | 144 | -------------------------------------------------------------------------------- /src/routes/components/Story.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 |
13 | 14 |
15 | {node.title.text} 20 |
21 |
22 |
23 | 24 | 29 | 34 | 38 |
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 |
145 | {#if show_image_input} 146 |
147 | 156 |
157 |
158 | {/if} 159 | {#if session.selection?.type === 'text'} 160 | {#if session.available_annotation_types.includes('strong')} 161 | 170 | {/if} 171 | {#if session.available_annotation_types.includes('emphasis')} 172 | 181 | {/if} 182 | {#if session.available_annotation_types.includes('highlight')} 183 | 192 | {/if} 193 | {#if session.available_annotation_types.includes('link')} 194 | 202 | {/if} 203 | {/if} 204 | {#if show_link_input} 205 |
206 | 215 |
216 |
217 | {/if} 218 | 219 | {#if layout_node?.type === 'story'} 220 | {#each layout_options as option (option.value)} 221 | 227 | {/each} 228 | {/if} 229 | {#if layout_node?.type === 'list'} 230 |
231 | {#each list_layout_options as option (option.value)} 232 | 238 | {/each} 239 | {/if} 240 | 241 | {#if is_node_cursor && allowed_node_types.length > 0} 242 |
243 | {#each allowed_node_types as node_type (node_type)} 244 | 248 | {/each} 249 | {/if} 250 | 251 | {#if session.selection?.type === 'text' || (session.selection?.type === 'node' && session.selected_node?.type === 'story') || (session.selection?.type === 'node' && session.selected_node?.type === 'list')} 252 |
253 | {/if} 254 | 255 | {#if editable} 256 | 263 | 270 | {/if} 271 | 274 |
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 | --------------------------------------------------------------------------------