├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── eslint.config.js ├── package-lock.json ├── package.json ├── playwright.config.js ├── src ├── app.html ├── index.test.js ├── lib │ ├── Container.svelte │ ├── EntrySession.svelte.js │ ├── Icon.svelte │ ├── ListBlock.svelte │ ├── ListItemBlock.svelte │ ├── StoryBlock.svelte │ ├── Svedit.svelte │ ├── Text.svelte │ ├── TextToolBar.svelte │ ├── UnknownBlock.svelte │ └── styles │ │ ├── colors.css │ │ ├── reset.css │ │ ├── shadows.css │ │ ├── spacing.css │ │ └── typography.css └── routes │ ├── +layout.svelte │ └── +page.svelte ├── static ├── favicon.png ├── fonts │ ├── JetBrainsMono-Italic[wght].woff2 │ ├── JetBrainsMono[wght].woff2 │ └── OFL.txt ├── icons │ ├── arrow-down-tail.svg │ ├── arrow-up-tail.svg │ ├── bold.svg │ ├── disc.svg │ ├── external-link.svg │ ├── image-at-top.svg │ ├── image-left.svg │ ├── image-right.svg │ ├── italic.svg │ ├── link.svg │ ├── list-decimal-leading-zero.svg │ ├── list-decimal.svg │ ├── list-icon.svg │ ├── list-lower-latin.svg │ ├── list-lower-roman.svg │ ├── list-task.svg │ ├── list-upper-latin.svg │ ├── list-upper-roman.svg │ ├── rotate-left.svg │ ├── rotate-right.svg │ └── square.svg └── images │ ├── container-cursors.svg │ ├── editable.svg │ ├── extendable.svg │ ├── github.svg │ ├── lightweight.jpg │ ├── lightweight.svg │ ├── nested-blocks-illustration.svg │ └── svelte-logo.svg ├── svelte.config.js ├── tests └── test.js └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | /.svelte-kit 7 | /build 8 | 9 | # OS 10 | .DS_Store 11 | Thumbs.db 12 | 13 | # Env 14 | .env 15 | .env.* 16 | !.env.example 17 | !.env.test 18 | 19 | # Vite 20 | vite.config.js.timestamp-* 21 | vite.config.ts.timestamp-* 22 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svedit 2 | 3 | Svedit (think Svelte Edit) is a template for building rich content editors with Svelte 5. You can model your content in JSON, render it with custom Svelte components, and edit it directly in the layout. 4 | 5 | Try the [demo](https://svedit.vercel.app). 6 | 7 | ## Quick intro 8 | 9 | Pass a piece of JSON to the `EntrySession` contructor. There are only a few rules for the format, such as a specific notation for annotated text (see the value of `subtitle`) and containers of blocks, where you need an array of objects, each featuring a `type` property (see `body`). Otherwise the shape is completely up to you. You can nest containers into blocks to create hiearchy (see ``). 10 | 11 | ```js 12 | let entry_session = new EntrySession({ 13 | type: 'page', 14 | title: ['Svedit', []], 15 | subtitle: ['A template for building rich content editors with Svelte 5', [ 16 | [24, 44, 'emphasis'] 17 | ]], 18 | body: [ 19 | { type: 'story', layout: 1, title: ['First title', []], description: ['First description', []] }, 20 | { type: 'story', layout: 2, title: ['Second title', []], description: ['Second description', []] }, 21 | { 22 | type: 'list', 23 | list_style: 'decimal-leading-zero', 24 | items: [ 25 | { type: 'list_item', description: ['List item 1', []] }, 26 | { type: 'list_item', description: ['List item 2', []] }, 27 | ] 28 | }, 29 | ] 30 | }); 31 | ``` 32 | 33 | Now you can start making your Svelte pages in-place editable by wrapping your design inside the `` component. The `` component can be used to render and edit annotated text. 34 | 35 | ```js 36 |
37 | 38 | 39 | 40 |
41 | 42 | 43 |
44 | 45 | {#snippet block(block, path)} 46 | {#if block.type === 'story'} 47 | 48 | {:else if block.type === 'list'} 49 | 50 | {:else} 51 | 52 | {/if} 53 | {/snippet} 54 | 55 |
56 |
57 | ``` 58 | 59 | Is there more documentation? No. Just read the code (it's only a couple of files with less than 1500LOC in total), copy and paste it to your app. Change it. This is not a library that tries to cover every possible use-case. This is just a starting point for you to adjust to your needs. Enjoy! 60 | 61 | ## Developing 62 | 63 | Once you've created a project and installed dependencies with `npm install`, start a development server: 64 | 65 | ```bash 66 | npm run dev 67 | ``` 68 | 69 | ## Building 70 | 71 | To create a production version of your app: 72 | 73 | ```bash 74 | npm run build 75 | ``` 76 | 77 | You can preview the production build with `npm run preview`. 78 | 79 | ## Contributing 80 | 81 | At the very moment, the best way to help is to donate or sponsor us, so we can buy time to work on this exclusively for a couple of more months. Please get in touch personally. 82 | 83 | Find my contact details [here](https://editable.website). 84 | 85 | 86 | ## Alpha version 87 | 88 | It's very early. Expect bugs. Expect missing features. Expect the need for more work on your part to make this work for your use case. 89 | 90 | ## Credits 91 | 92 | Svedit is a co-creation of [Michael Aufreiter](https://michaelaufreiter.com) and [Johannes Mutter](https://mutter.co). -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import svelte from 'eslint-plugin-svelte'; 3 | import prettier from 'eslint-config-prettier'; 4 | import globals from 'globals'; 5 | 6 | /** @type {import('eslint').Linter.Config[]} */ 7 | export default [ 8 | js.configs.recommended, 9 | ...svelte.configs['flat/recommended'], 10 | prettier, 11 | ...svelte.configs['flat/prettier'], 12 | { 13 | languageOptions: { 14 | globals: { 15 | ...globals.browser, 16 | ...globals.node 17 | } 18 | } 19 | }, 20 | { 21 | ignores: ['build/', '.svelte-kit/', 'dist/'] 22 | } 23 | ]; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svedit", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "test": "npm run test:integration && npm run test:unit", 10 | "lint": "prettier --check . && eslint .", 11 | "format": "prettier --write .", 12 | "test:integration": "playwright test", 13 | "test:unit": "vitest" 14 | }, 15 | "devDependencies": { 16 | "@playwright/test": "^1.28.1", 17 | "@sveltejs/adapter-auto": "^3.0.0", 18 | "@sveltejs/kit": "^2.0.0", 19 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 20 | "@types/eslint": "^9.6.0", 21 | "eslint": "^9.0.0", 22 | "eslint-config-prettier": "^9.1.0", 23 | "eslint-plugin-svelte": "^2.36.0", 24 | "globals": "^15.0.0", 25 | "prettier": "^3.1.1", 26 | "prettier-plugin-svelte": "^3.1.2", 27 | "svelte": "^5.0.0-next.1", 28 | "vite": "^5.0.3", 29 | "vitest": "^2.0.0" 30 | }, 31 | "type": "module" 32 | } 33 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@playwright/test').PlaywrightTestConfig} */ 2 | const config = { 3 | webServer: { 4 | command: 'npm run build && npm run preview', 5 | port: 4173 6 | }, 7 | testDir: 'tests', 8 | testMatch: /(.+\.)?(test|spec)\.[jt]s/ 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('sum test', () => { 4 | it('adds 1 + 2 to equal 3', () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/Container.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 | {#each svedit.entry_session.get(path) as _block, index} 14 | {@render block(_block, [...path, index], index)} 15 | {/each} 16 |
-------------------------------------------------------------------------------- /src/lib/EntrySession.svelte.js: -------------------------------------------------------------------------------- 1 | export default class EntrySession { 2 | selection = $state(); 3 | entry = $state(); 4 | history = $state(); 5 | future = $state(); 6 | 7 | selected_block_path = $derived(this.get_selected_block_path()); 8 | selected_block = $derived(this.get_selected_block()); 9 | 10 | // Two types of selections are possible: 11 | // ContainerSelection: 12 | // { 13 | // type: 'container', 14 | // anchor_offset: 2, 15 | // focus_offset: 5, 16 | // } 17 | 18 | // Text selection (text selection inside a block or root field) 19 | // { 20 | // type: 'text', 21 | // path: ['body', 3, 'title'], 22 | // anchor_offset: 3, 23 | // focus_offset: 5, 24 | // } 25 | 26 | constructor(entry) { 27 | this.selection = undefined; 28 | this.entry = entry; 29 | this.history = []; 30 | this.future = []; 31 | } 32 | 33 | get_selected_block_path() { 34 | let sel = this.selection; 35 | if (sel?.type === 'container') { 36 | const { start, end } = this.get_selection_range(); 37 | if (start + 1 === end) { 38 | return [...sel.path, start]; 39 | } 40 | } 41 | } 42 | 43 | get_selected_block() { 44 | if (this.selected_block_path) { 45 | return this.get(this.selected_block_path); 46 | } 47 | } 48 | 49 | 50 | get(path) { 51 | let current = this.entry; 52 | for (let key of path) { 53 | if (current == null) { 54 | return undefined; 55 | } 56 | current = current[key]; 57 | } 58 | return current; 59 | } 60 | 61 | set(path, value) { 62 | // Clone current state for history before updating 63 | const history_copy = { 64 | entry: structuredClone($state.snapshot(this.entry)), 65 | selection: structuredClone($state.snapshot(this.selection)), 66 | }; 67 | 68 | if (path.length === 0) { 69 | return value; 70 | } 71 | 72 | let current = this.entry; 73 | for (let i = 0; i < path.length - 1; i++) { 74 | if (current[path[i]] === undefined) { 75 | current[path[i]] = typeof path[i + 1] === 'number' ? [] : {}; 76 | } 77 | current = current[path[i]]; 78 | } 79 | 80 | current[path[path.length - 1]] = value; 81 | this.history = [...this.history, history_copy]; 82 | this.future = []; 83 | 84 | return current; 85 | } 86 | 87 | active_annotation(annotation_type) { 88 | if (this.selection?.type !== 'text') return null; 89 | 90 | const { start, end } = this.get_selection_range(); 91 | const annotated_text = this.get(this.selection.path); 92 | const annotations = annotated_text[1]; 93 | 94 | const active_annotation = annotations.find(([anno_start, anno_end, type]) => 95 | (anno_start <= start && anno_end > start) || 96 | (anno_start < end && anno_end >= end) || 97 | (anno_start >= start && anno_end <= end) 98 | ) || null; 99 | 100 | if (annotation_type) { 101 | return active_annotation?.[2] === annotation_type; 102 | } else { 103 | return active_annotation; 104 | } 105 | } 106 | 107 | // TODO: think about ways how we can also turn a container 108 | // selection into plain text. 109 | get_selected_plain_text() { 110 | if (this.selection?.type !== 'text') return null; 111 | 112 | const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset); 113 | const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset); 114 | const annotated_text = this.get(this.selection.path); 115 | return annotated_text[0].slice(start, end); 116 | } 117 | 118 | get_selected_blocks() { 119 | if (this.selection?.type !== 'container') return null; 120 | 121 | const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset); 122 | const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset); 123 | const container = this.get(this.selection.path); 124 | return $state.snapshot(container.slice(start, end)); 125 | } 126 | 127 | move_container_cursor(direction) { 128 | if (this.selection?.type !== 'container') return; 129 | const container = this.get(this.selection.path); // container is an array of blocks 130 | 131 | const { start, end } = this.get_selection_range(); 132 | 133 | if (this.selection.anchor_offset !== this.selection.focus_offset) { 134 | // If selection is not collapsed, collapse it to the right or the left 135 | if (direction === 'forward') { 136 | this.selection.focus_offset = end; 137 | this.selection.anchor_offset = end; 138 | } else if (direction === 'backward') { 139 | this.selection.focus_offset = start; 140 | this.selection.anchor_offset = start; 141 | } 142 | } else if (direction === 'forward' && end < container.length) { 143 | this.selection.focus_offset = end + 1; 144 | this.selection.anchor_offset = end + 1; 145 | } else if (direction === 'backward' && start > 0) { 146 | this.selection.focus_offset = start - 1; 147 | this.selection.anchor_offset = start - 1; 148 | } 149 | } 150 | 151 | expand_container_selection(direction) { 152 | if (this.selection.type !== 'container') return; 153 | const container = this.get(this.selection.path); 154 | 155 | if (direction === 'forward') { 156 | this.selection.focus_offset = Math.min(this.selection.focus_offset + 1, container.length); 157 | } else if (direction === 'backward') { 158 | this.selection.focus_offset = Math.max(this.selection.focus_offset - 1, 0); 159 | } 160 | } 161 | 162 | annotate_text(annotation_type, annotation_data) { 163 | if (this.selection.type !== 'text') return; 164 | 165 | const { start, end } = this.get_selection_range(); 166 | 167 | const annotated_text = structuredClone($state.snapshot(this.get(this.selection.path))); 168 | const annotations = annotated_text[1]; 169 | const existing_annotations = this.active_annotation(); 170 | 171 | 172 | // Special annotation type handling should probably be done in a separate function. 173 | // The goal is to keep the core logic simple and allow developer to extend and pick only what they need. 174 | // It could also be abstracted to not check for type (e.g. "link") but for a special attribute 175 | // e.g. "zero-range-updatable" for annotations that are updatable without a range selection change. 176 | 177 | 178 | // Special handling for links when there's no selection range 179 | // Links should be updatable by just clicking on them without a range selection 180 | if (annotation_type === 'link' && start === end && existing_annotations) { 181 | 182 | // Use findIndex for deep comparison of annotation properties (comparison of annotation properties rather than object reference via indexOf) 183 | const index = annotations.findIndex(anno => 184 | anno[0] === existing_annotations[0] && 185 | anno[1] === existing_annotations[1] && 186 | anno[2] === existing_annotations[2] 187 | ); 188 | // const index = annotations.indexOf(existing_annotations); 189 | 190 | if (index !== -1) { 191 | if (annotation_data.href === '') { 192 | // Remove the annotation if the href is empty 193 | annotations.splice(index, 1); 194 | } else { 195 | annotations[index] = [ 196 | existing_annotations[0], 197 | existing_annotations[1], 198 | 'link', 199 | { ...existing_annotations[3], ...annotation_data } 200 | ]; 201 | } 202 | 203 | this.set(this.selection.path, annotated_text); 204 | return; 205 | } 206 | } 207 | 208 | // Regular annotation handling 209 | if (start === end) { 210 | // For non-link annotations: You can not annotate text if the selection is collapsed. 211 | return; 212 | } 213 | 214 | if (existing_annotations) { 215 | // If there's an existing annotation of the same type, remove it 216 | if (existing_annotations[2] === annotation_type) { 217 | const index = annotations.findIndex(anno => 218 | anno[0] === existing_annotations[0] && 219 | anno[1] === existing_annotations[1] && 220 | anno[2] === existing_annotations[2] 221 | ); 222 | if (index !== -1) { 223 | annotations.splice(index, 1); 224 | } 225 | } else { 226 | // If there's an annotation of a different type, don't add a new one 227 | return; 228 | } 229 | } else { 230 | // If there's no existing annotation, add the new one 231 | annotations.push([start, end, annotation_type, annotation_data]); 232 | } 233 | 234 | // Update the annotated text 235 | this.set(this.selection.path, annotated_text); 236 | this.selection = { ...this.selection }; 237 | } 238 | 239 | delete() { 240 | if (!this.selection) return; 241 | const path = this.selection.path; 242 | // Get the start and end indices for the selection 243 | let start = Math.min(this.selection.anchor_offset, this.selection.focus_offset); 244 | let end = Math.max(this.selection.anchor_offset, this.selection.focus_offset); 245 | 246 | // If selection is collapsed we delete the previous block 247 | if (start === end) { 248 | if (start > 0) { 249 | start = start - 1; 250 | } else { 251 | return; // cursor is at the very beginning, do nothing. 252 | } 253 | } 254 | 255 | if (this.selection.type === 'container') { 256 | const container = [...this.get(path)]; // container is an array of blocks 257 | 258 | // Remove the selected blocks from the container 259 | container.splice(start, end - start); 260 | 261 | // Update the container in the entry 262 | this.set(path, container); 263 | 264 | // Update the selection to point to the start of the deleted range 265 | this.selection = { 266 | type: 'container', 267 | path, 268 | anchor_offset: start, 269 | focus_offset: start 270 | }; 271 | } else if (this.selection.type === 'text') { 272 | const path = this.selection.path; 273 | let text = structuredClone($state.snapshot(this.get(path))); 274 | 275 | text[0] = text[0].slice(0, start) + text[0].slice(end); 276 | this.set(path, text); 277 | 278 | this.selection = { 279 | type: 'text', 280 | path, 281 | anchor_offset: start, 282 | focus_offset: start 283 | }; 284 | } 285 | } 286 | 287 | insert_blocks(blocks) { 288 | if (this.selection.type !== 'container') return; 289 | 290 | const path = this.selection.path; 291 | const container = [...this.get(path)]; 292 | 293 | // Get the start and end indices for the selection 294 | let start = Math.min(this.selection.anchor_offset, this.selection.focus_offset); 295 | let end = Math.max(this.selection.anchor_offset, this.selection.focus_offset); 296 | 297 | if (start !== end) { 298 | // Remove the selected blocks from the container 299 | container.splice(start, end - start); 300 | } 301 | 302 | container.splice(start, 0, ...blocks); 303 | 304 | // Update the container in the entry 305 | this.set(path, container); 306 | 307 | this.selection = { 308 | type: 'container', 309 | // NOTE: we hard code this temporarily as both story and list-item have a description property 310 | path: [...this.selection.path], 311 | anchor_offset: start, 312 | focus_offset: start + blocks.length 313 | }; 314 | } 315 | 316 | // TODO: we need to also support annotations attached to replaced_text. This is needed to 317 | // support copy&paste including annotations. Currently the annotations are lost on paste. 318 | insert_text(replaced_text) { 319 | if (this.selection.type !== 'text') return; 320 | 321 | const annotated_text = structuredClone($state.snapshot(this.get(this.selection.path))); 322 | const { start, end } = this.get_selection_range(); 323 | 324 | // Transform the plain text string. 325 | annotated_text[0] = annotated_text[0].slice(0, start) + replaced_text + annotated_text[0].slice(end); 326 | 327 | // Transform the annotations (annotated_text[1]) 328 | // NOTE: Annotations are stored as [start_offset, end_offset, type] 329 | // Cover the following cases for all annotations: 330 | // 1. text inserted before the annotation (the annotation should be shifted by replaced_text.length - (end - start)) 331 | // 2. text inserted inside an annotation (start>=annotation.start_offset und end <=annotation.end_offset) 332 | // 3. text inserted after an annotation (the annotation should be unchanged) 333 | // 4. the annotation is wrapped in start and end (the annotation should be removed) 334 | // 5. the annotation is partly selected towards right (e.g. start > annotation.start_offset && start < annotation.end_offset && end > annotation.end_offset): annotation_end_offset should be updated 335 | // 6. the annotation is partly selected towards left (e.g. start < annotation.start_offset && end > annotation.start_offset && end < annotation.end_offset): annotation_start_offset and end_offset should be updated 336 | 337 | const delta = replaced_text.length - (end - start); 338 | const new_annotations = annotated_text[1].map(annotation => { 339 | const [anno_start, anno_end, type, anno_data] = annotation; 340 | 341 | // Case 4: annotation is wrapped in start and end (remove it) 342 | if (start <= anno_start && end >= anno_end) { 343 | return false; 344 | } 345 | 346 | // Case 1: text inserted before the annotation 347 | if (end <= anno_start) { 348 | return [anno_start + delta, anno_end + delta, type, anno_data]; 349 | } 350 | 351 | // Case 2: text inserted at the end or inside an annotation 352 | if (start >= anno_start && start <= anno_end) { 353 | console.log('Case 2: text inserted at the end or inside an annotation'); 354 | if (start === anno_end) { 355 | // Text inserted right after the annotation 356 | return [anno_start, anno_end, type, anno_data]; 357 | } else { 358 | // Text inserted inside the annotation 359 | return [anno_start, anno_end + delta, type, anno_data]; 360 | } 361 | } 362 | 363 | // Case 3: text inserted after the annotation 364 | if (start >= anno_end) { 365 | return annotation; 366 | } 367 | 368 | // Case 5: annotation is partly selected towards right 369 | if (start > anno_start && start < anno_end && end > anno_end) { 370 | return [anno_start, start, type, anno_data]; 371 | } 372 | 373 | // Case 6: annotation is partly selected towards left 374 | if (start < anno_start && end > anno_start && end < anno_end) { 375 | return [end + delta, anno_end + delta, type, anno_data]; 376 | } 377 | 378 | // Default case: shouldn't happen, but keep the annotation unchanged 379 | return annotation; 380 | }).filter(Boolean); 381 | 382 | this.set(this.selection.path, [annotated_text[0], new_annotations]); // this will update the current state and create a history entry 383 | 384 | // Setting the selection automatically triggers a re-render of the corresponding DOMSelection. 385 | const new_selection = { 386 | type: 'text', 387 | path: this.selection.path, 388 | anchor_offset: start + replaced_text.length, 389 | focus_offset: start + replaced_text.length, 390 | }; 391 | this.selection = new_selection; 392 | } 393 | 394 | undo() { 395 | if (this.history.length === 0) return; 396 | const previous = this.history[this.history.length - 1]; 397 | const current_copy = { 398 | entry: structuredClone($state.snapshot(this.entry)), 399 | selection: structuredClone($state.snapshot(this.selection)), 400 | }; 401 | 402 | // Directly update entry and selection with previous state 403 | this.entry = previous.entry; 404 | this.selection = previous.selection; 405 | this.history = this.history.slice(0, -1); 406 | this.future = [current_copy, ...this.future]; 407 | } 408 | 409 | redo() { 410 | if (this.future.length === 0) return; 411 | const [next, ...remaining_future] = this.future; 412 | const current_copy = { 413 | entry: structuredClone($state.snapshot(this.entry)), 414 | selection: structuredClone($state.snapshot(this.selection)), 415 | }; 416 | 417 | // Directly update entry and selection with next state 418 | this.entry = next.entry; 419 | this.selection = next.selection; 420 | 421 | this.history = [...this.history, current_copy]; 422 | this.future = remaining_future; 423 | } 424 | 425 | select_parent() { 426 | if (this.selection?.type === 'text') { 427 | if (this.selection.path.length > 2) { 428 | // For text selections, we need to go up two levels 429 | const parent_path = this.selection.path.slice(0, -2); 430 | const currentIndex = parseInt(this.selection.path[this.selection.path.length - 2]); 431 | this.selection = { 432 | type: 'container', 433 | path: parent_path, 434 | anchor_offset: currentIndex, 435 | focus_offset: currentIndex + 1 436 | }; 437 | } else { 438 | this.selection = undefined; 439 | } 440 | } else if (this.selection?.type === 'container') { 441 | // For container selections, we go up one level 442 | if (this.selection.path.length > 1) { 443 | const parent_path = this.selection.path.slice(0, -1); 444 | const currentIndex = parseInt(this.selection.path[this.selection.path.length - 1]); 445 | this.selection = { 446 | type: 'container', 447 | path: parent_path, 448 | anchor_offset: currentIndex, 449 | focus_offset: currentIndex + 1 450 | }; 451 | } else { 452 | this.selection = undefined; 453 | } 454 | } else { 455 | this.selection = undefined; 456 | } 457 | } 458 | 459 | move(direction) { 460 | if (this.selection?.type !== 'container') return; 461 | 462 | const path = this.selection.path; 463 | const container = [...this.get(path)]; 464 | const { start, end } = this.get_selection_range(); 465 | 466 | const is_moving_up = direction === 'up'; 467 | const offset = is_moving_up ? -1 : 1; 468 | 469 | if ((is_moving_up && start > 0) || (!is_moving_up && end < container.length)) { 470 | // Move the selected block(s) 471 | const moved_items = container.splice(start, end - start); 472 | container.splice(start + offset, 0, ...moved_items); 473 | 474 | // Update the container in the entry 475 | this.set(path, container); 476 | 477 | // Update the selection 478 | this.selection = { 479 | ...this.selection, 480 | anchor_offset: start + offset, 481 | focus_offset: end + offset 482 | }; 483 | } 484 | } 485 | 486 | move_up() { 487 | this.move('up'); 488 | } 489 | 490 | move_down() { 491 | this.move('down'); 492 | } 493 | 494 | get_selection_range() { 495 | if (!this.selection) return null; 496 | 497 | const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset); 498 | const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset); 499 | 500 | return { 501 | start, 502 | end, 503 | length: end - start 504 | }; 505 | } 506 | } 507 | -------------------------------------------------------------------------------- /src/lib/Icon.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/lib/ListBlock.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
16 | 17 | 18 | {#snippet block(block, path)} 19 | 20 | {/snippet} 21 | 22 |
23 | 24 | 30 | -------------------------------------------------------------------------------- /src/lib/ListItemBlock.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 |
21 | 22 | -------------------------------------------------------------------------------- /src/lib/StoryBlock.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
20 |
24 | 25 | {svedit.entry_session.get([...path, 26 |
27 |
28 | 29 | 30 | 31 |
32 |
33 | 34 | 97 | -------------------------------------------------------------------------------- /src/lib/Svedit.svelte: -------------------------------------------------------------------------------- 1 | 642 | 643 | 647 | 655 | 656 | 657 | 658 |
661 |
668 | {@render children()} 669 |
670 |
671 | 672 | 673 | {#if container_selection_paths} 674 | 675 | {#each container_selection_paths as path} 676 |
677 | {/each} 678 | {:else if container_cursor_info} 679 |
685 | {/if} 686 | 687 | {#if text_selection_info} 688 |
692 | 693 |
694 | {/if} 695 |
696 |
697 | 698 | 778 | -------------------------------------------------------------------------------- /src/lib/Text.svelte: -------------------------------------------------------------------------------- 1 | 53 | 54 | 55 | 56 |
{#if plain_text.length === 0}​{/if}{#each fragments as fragment, index}{#if typeof fragment === 'string'}{fragment}{:else if fragment.type === 'emphasis'}{fragment.content}{:else if fragment.type === 'strong'}{fragment.content}{:else if fragment.type === 'link'}{fragment.content}{:else}{fragment.content}{/if}{/each}
79 | 80 | 95 | -------------------------------------------------------------------------------- /src/lib/TextToolBar.svelte: -------------------------------------------------------------------------------- 1 | 58 | 59 | 60 |
61 | {#if entry_session.selection?.type === 'container'} 62 | 69 | 76 | {/if} 77 | {#if entry_session.selection?.type === 'text'} 78 | 87 | 96 | 104 | {/if} 105 | {#if entry_session.selection?.type === 'container' && entry_session.selected_block?.type === 'story'} 106 | 107 | {#each layout_options as option} 108 | 114 | {/each} 115 | {/if} 116 | {#if entry_session.selection?.type === 'container' && entry_session.selected_block?.type === 'list'} 117 |
118 | {#each list_style_options as option} 119 | 125 | {/each} 126 | {/if} 127 | 128 | {#if entry_session.selection?.type === 'text' 129 | || (entry_session.selection?.type === 'container' && entry_session.selected_block?.type === 'story') 130 | || (entry_session.selection?.type === 'container' && entry_session.selected_block?.type === 'list') 131 | } 132 |
133 | {/if} 134 | 141 | 148 |
149 | 150 | 201 | -------------------------------------------------------------------------------- /src/lib/UnknownBlock.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | Unknown block type {block.type} 10 |
11 | -------------------------------------------------------------------------------- /src/lib/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 | -------------------------------------------------------------------------------- /src/lib/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | Styles for browser consistency (partly opinionated) 3 | =================================================== 4 | Based on https://github.com/sindresorhus/modern-normalize 5 | */ 6 | 7 | 8 | /* 9 | 1. Use a more readable tab size (opinionated). 10 | ----------------------------------------- 11 | */ 12 | :root { 13 | -moz-tab-size: 4; 14 | tab-size: 4; 15 | 16 | /* variables */ 17 | --placeholder-color: #9ca3af; 18 | } 19 | 20 | 21 | /* 22 | Use a better box model (opinionated). 23 | ------------------------------------ 24 | 1. Prevent padding and border from affecting element width. 25 | https://github.com/mozdevs/cssremedy/issues/4 26 | */ 27 | 28 | *, 29 | *::before, 30 | *::after { 31 | box-sizing: border-box; 32 | } 33 | 34 | /* 35 | Inherit font-family and line-height from html element. 36 | ----------------------------------------------------- 37 | 1. Use the user's configured `sans` font-family as default. 38 | 2. Correct the line height in all browsers. 39 | 3. Prevent adjustments of font size after orientation changes in iOS. 40 | */ 41 | 42 | html { 43 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 44 | line-height: 1.5; 45 | -ms-text-size-adjust: 100%; 46 | -webkit-text-size-adjust: 100%; 47 | } 48 | 49 | /* 50 | Improve default font consistency. 51 | ------------------------------- 52 | 1. Remove margin in all browsers. 53 | 2. Inherit font-family and line-height from html so users can set them as a class directly on the html element. 54 | */ 55 | 56 | body { 57 | margin: 0; 58 | font-family: inherit; 59 | line-height: inherit; 60 | } 61 | 62 | 63 | /* 64 | Grouping content 65 | ================ 66 | */ 67 | 68 | /* 69 | Correct height and inheritance in hr element. 70 | ------------------------------------------- 71 | 1. Add the correct height in Firefox. 72 | 2. Correct the inheritance of border color in Firefox. 73 | https://bugzilla.mozilla.org/show_bug.cgi?id=190655 74 | 3. Ensure horizontal rules are visible by default. 75 | */ 76 | 77 | hr { 78 | height: 0; 79 | color: inherit; 80 | border-top: 1px solid var(--divider-color, var(--color-canvas-stroke)); 81 | } 82 | 83 | 84 | /* 85 | Text-level semantics 86 | ==================== 87 | */ 88 | 89 | /* 90 | Add correct text decoration in Chrome, Edge, and Safari. 91 | ------------------------------------------------------- 92 | */ 93 | 94 | abbr[title] { 95 | text-decoration: underline dotted; 96 | } 97 | 98 | /* 99 | Add correct font weight in Edge and Safari. 100 | ------------------------------------------- 101 | */ 102 | 103 | b, 104 | strong { 105 | font-weight: bolder; 106 | } 107 | 108 | /* 109 | Add correct font size in all browsers. 110 | ------------------------------------- 111 | */ 112 | 113 | small { 114 | font-size: 80%; 115 | } 116 | 117 | 118 | /* 119 | Prevent 'sub' and 'sup' elements from affecting the line height in all browsers. 120 | ------------------------------------------------------------------------------ 121 | */ 122 | 123 | sub, 124 | sup { 125 | font-size: 75%; 126 | line-height: 0; 127 | position: relative; 128 | vertical-align: baseline; 129 | } 130 | 131 | sub { 132 | bottom: -0.25em; 133 | } 134 | 135 | sup { 136 | top: -0.5em; 137 | } 138 | 139 | 140 | /* 141 | Tabular data 142 | ============ 143 | */ 144 | 145 | 146 | /* 147 | Remove text indentation and correct inheritance in table. 148 | -------------------------------------------------------- 149 | 1. Remove text indentation from table contents in Chrome and Safari. 150 | https://bugs.chromium.org/p/chromium/issues/detail?id=999088 151 | https://bugs.webkit.org/show_bug.cgi?id=201297 152 | 2. Correct table border color inheritance in Chrome and Safari. 153 | https://bugs.chromium.org/p/chromium/issues/detail?id=935729 154 | https://bugs.webkit.org/show_bug.cgi?id=195016 155 | */ 156 | 157 | table { 158 | text-indent: 0; 159 | border-color: inherit; 160 | } 161 | 162 | /* 163 | Forms 164 | ===== 165 | */ 166 | 167 | button, a { 168 | /* Fix iconsitency between button and anchor widths */ 169 | all: unset; 170 | cursor: pointer; 171 | } 172 | 173 | /* 174 | Change font styles and remove margin in Firefox and Safari. 175 | ---------------------------------------------------------- 176 | 1. Change the font styles in all browsers. 177 | 2. Remove the margin in Firefox and Safari. 178 | */ 179 | 180 | button, 181 | input, 182 | optgroup, 183 | select, 184 | textarea { 185 | font-family: inherit; 186 | font-size: 100%; 187 | line-height: 1.15; 188 | margin: 0; 189 | text-align: left; 190 | } 191 | 192 | /* 193 | Remove inheritance of text transform in Firefox. 194 | ----------------------------------------------- 195 | 1. Remove the inheritance of text transform in Firefox. 196 | 2. Remove the inheritance of text transform in Edge and Firefox. 197 | */ 198 | 199 | button, 200 | select { 201 | text-transform: none; 202 | } 203 | 204 | /* 205 | Correct inability to style clickable types in iOS and Safari. 206 | ------------------------------------------------------------ 207 | */ 208 | 209 | button, 210 | [type="button"], 211 | [type="reset"], 212 | [type="submit"] { 213 | -webkit-appearance: button; 214 | } 215 | 216 | /* 217 | Remove inner border and padding in Firefox. 218 | ------------------------------------------- 219 | */ 220 | 221 | ::-moz-focus-inner { 222 | border-style: none; 223 | padding: 0; 224 | } 225 | 226 | /* 227 | Restore focus styles unset by previous rule. 228 | -------------------------------------------- 229 | */ 230 | 231 | :-moz-focusring { 232 | outline: 1px dotted ButtonText; 233 | } 234 | 235 | /* 236 | Remove additional :invalid styles in Firefox. 237 | --------------------------------------------- 238 | See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737 239 | */ 240 | 241 | :-moz-ui-invalid { 242 | box-shadow: none; 243 | } 244 | 245 | 246 | /* 247 | Remove padding from 248 | ----------------------------- 249 | */ 250 | 251 | legend { 252 | padding: 0; 253 | } 254 | 255 | /* 256 | Add correct vertical alignment in Chrome and Firefox. 257 | ---------------------------------------------------- 258 | */ 259 | 260 | progress { 261 | vertical-align: baseline; 262 | } 263 | 264 | /* 265 | Correct cursor style of increment and decrement buttons in Safari. 266 | ----------------------------------------------------------------- 267 | */ 268 | 269 | ::-webkit-inner-spin-button, 270 | ::-webkit-outer-spin-button { 271 | height: auto; 272 | } 273 | 274 | /* 275 | Correct odd appearance in Chrome and Safari. 276 | ------------------------------------------- 277 | 1. Correct the odd appearance in Chrome and Safari. 278 | 2. Correct the outline style in Safari. 279 | */ 280 | 281 | [type="search"] { 282 | -webkit-appearance: textfield; 283 | outline-offset: -2px; 284 | } 285 | 286 | /* 287 | Remove inner padding and border in Chrome and Safari on macOS. 288 | ------------------------------------------------------------- 289 | */ 290 | 291 | ::-webkit-search-decoration { 292 | -webkit-appearance: none; 293 | } 294 | 295 | 296 | /* 297 | Correct inability to style clickable types in iOS and Safari. 298 | ------------------------------------------------------------ 299 | 1. Correct the inability to style clickable types in iOS and Safari. 300 | 2. Change font properties to inherit in Safari. 301 | */ 302 | 303 | ::-webkit-file-upload-button { 304 | -webkit-appearance: button; 305 | font: inherit; 306 | } 307 | 308 | /* 309 | Interactive 310 | =========== 311 | */ 312 | 313 | /* 314 | Add correct display in Chrome and Safari. 315 | ---------------------------------------- 316 | */ 317 | 318 | summary { 319 | display: list-item; 320 | } 321 | 322 | 323 | /* 324 | Manually forked from SUIT CSS Base: https://github.com/suitcss/base 325 | A thin layer on top of normalize.css that provides a starting point more 326 | suitable for web applications. 327 | */ 328 | 329 | /* 330 | Remove spacing and border for common elements. 331 | --------------------------------------------- 332 | */ 333 | 334 | blockquote, 335 | dl, 336 | dd, 337 | h1, 338 | h2, 339 | h3, 340 | h4, 341 | h5, 342 | h6, 343 | hr, 344 | figure, 345 | p, 346 | pre { 347 | margin: 0; 348 | } 349 | 350 | /* 351 | Remove background and border from button element. 352 | ------------------------------------------------- 353 | */ 354 | 355 | button { 356 | background-color: transparent; 357 | background-image: none; 358 | border: none; 359 | } 360 | 361 | /* 362 | Fix loss of default focus styles for button element in Firefox/IE. 363 | ----------------------------------------------------------------- 364 | */ 365 | 366 | button:focus { 367 | outline: 1px dotted; 368 | outline: 5px auto -webkit-focus-ring-color; 369 | } 370 | 371 | /* 372 | Remove margin and padding from fieldset element. 373 | ------------------------------------------------ 374 | */ 375 | 376 | fieldset { 377 | margin: 0; 378 | padding: 0; 379 | } 380 | 381 | /* 382 | Remove default list styles on ul, ol elements. 383 | --------------------------------------------- 384 | */ 385 | 386 | ol, 387 | ul { 388 | list-style: none; 389 | margin: 0; 390 | padding: 0; 391 | } 392 | 393 | /* 394 | Remove border from img element. 395 | ------------------------------------------ 396 | */ 397 | 398 | img { 399 | border: none; 400 | } 401 | 402 | /* 403 | Enable vertical resizing for textarea element. 404 | ---------------------------------------------- 405 | */ 406 | 407 | textarea { 408 | resize: vertical; 409 | } 410 | 411 | /* 412 | Set placeholder color 413 | ------------------------------- 414 | */ 415 | 416 | input::placeholder, 417 | textarea::placeholder { 418 | color: var(--placeholder-color); 419 | } 420 | 421 | /* 422 | Set cursor to pointer for buttons and button-like elements. 423 | ---------------------------------------------------------- 424 | */ 425 | 426 | button, 427 | [role="button"] { 428 | cursor: pointer; 429 | } 430 | 431 | /* 432 | Set border-collapse to collapse for table element. 433 | -------------------------------------------------- 434 | */ 435 | 436 | table { 437 | border-collapse: collapse; 438 | } 439 | 440 | /* 441 | Set font-size and font-weight to inherit for headings. 442 | ----------------------------------------------------- 443 | */ 444 | 445 | h1, 446 | h2, 447 | h3, 448 | h4, 449 | h5, 450 | h6 { 451 | font-size: inherit; 452 | font-weight: inherit; 453 | } 454 | 455 | /* 456 | Reset link styles. 457 | ------------------ 458 | */ 459 | 460 | a { 461 | color: inherit; 462 | text-decoration: inherit; 463 | } 464 | 465 | /* 466 | Reset form element properties. 467 | ------------------------------ 468 | */ 469 | 470 | button, 471 | input, 472 | optgroup, 473 | select, 474 | textarea { 475 | padding: 0; 476 | line-height: inherit; 477 | color: inherit; 478 | } 479 | 480 | 481 | /* 482 | Improve default font consistency. 483 | ------------------------------- 484 | 1. Use modern default monospace stack. 485 | 2. Correct the odd em font sizing in all browsers. 486 | */ 487 | 488 | code, 489 | kbd, 490 | samp, 491 | pre { 492 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 493 | font-size: 1em; 494 | } 495 | 496 | 497 | /* 498 | Make replaced elements display: block by default. 499 | Inspired by CSS Remedy. 500 | https://github.com/mozdevs/cssremedy/issues/14 501 | */ 502 | 503 | img, 504 | svg, 505 | video, 506 | canvas, 507 | audio, 508 | iframe, 509 | embed, 510 | object { 511 | display: block; 512 | vertical-align: middle; 513 | } 514 | 515 | /* 516 | Constrain images and videos to the parent width and preserve aspect ratio. 517 | https://github.com/mozdevs/cssremedy/issues/14 518 | */ 519 | 520 | img, 521 | video { 522 | max-width: 100%; 523 | height: auto; 524 | } 525 | 526 | 527 | /* 528 | Don't overflow pre 529 | */ 530 | pre { 531 | max-width: 100vw; 532 | } 533 | 534 | 535 | /* 536 | Hyphenate + prevent overflow text 537 | */ 538 | * { 539 | overflow-wrap: break-word; 540 | word-break: normal; 541 | } 542 | @supports (hyphenate-limit-chars: 10 4 4) { 543 | * { 544 | hyphens: auto; 545 | hyphenate-limit-chars: 10 4 4; 546 | hyphenate-limit-lines: 2; 547 | hyphenate-limit-last: always; 548 | hyphenate-limit-zone: 8%; 549 | } 550 | } 551 | 552 | /* 553 | Hanging Punctuation 554 | */ 555 | p, h1, h2, h3, h4, h5, h6, blockquote { 556 | hanging-punctuation: first; 557 | } 558 | 559 | /* Smooth Scrolling */ 560 | /* html { 561 | scroll-behavior: smooth; 562 | } */ 563 | 564 | 565 | /* Reset browesr defaults */ 566 | var, abbr, abbr[title] { 567 | font-style: normal; 568 | text-decoration: none; 569 | } -------------------------------------------------------------------------------- /src/lib/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 | } -------------------------------------------------------------------------------- /src/lib/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/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: 'JetBrains Mono', Verdana, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 23 | font-weight: 400; 24 | font-stretch: 100%; 25 | font-style: normal; 26 | font-variation-settings: 'wght' 400; 27 | font-size: var(--base-size); 28 | line-height: 1.5; 29 | } 30 | 31 | strong, b, .bold { 32 | font-weight: 700; 33 | font-variation-settings: 'wght' 700; 34 | } 35 | 36 | em, i, .italic { 37 | font-style: italic; 38 | font-variation-settings: 'wght' 400,; 39 | } 40 | 41 | .condensed { 42 | font-stretch: 100%; 43 | font-variation-settings: 'wght' 400; 44 | } 45 | 46 | .expanded { 47 | font-stretch: 125%; 48 | font-variation-settings: 'wght' 400; 49 | } 50 | 51 | /* Typography classes */ 52 | .heading1 { 53 | font-size: calc(var(--base-size) * var(--scale-ratio) * var(--scale-ratio) * var(--scale-ratio)); 54 | font-weight: 200; 55 | line-height: 1.2; 56 | margin-bottom: 0.5em; 57 | font-variation-settings: 'wght' 200; 58 | --text-wrap: balance; 59 | } 60 | 61 | .heading2 { 62 | font-size: calc(var(--base-size) * var(--scale-ratio) * var(--scale-ratio)); 63 | font-weight: 700; 64 | line-height: 1.3; 65 | margin-bottom: 0.5em; 66 | font-variation-settings: 'wght' 700; 67 | --text-wrap: balance; 68 | } 69 | 70 | .heading3 { 71 | font-size: calc(var(--base-size) * var(--scale-ratio)); 72 | font-weight: 700; 73 | line-height: 1.4; 74 | margin-bottom: 0.5em; 75 | --text-wrap: balance; 76 | } 77 | 78 | .body { 79 | font-size: var(--base-size); 80 | font-weight: 400; 81 | line-height: 1.5; 82 | margin-bottom: 1em; 83 | --text-wrap: pretty; 84 | } 85 | 86 | .caption { 87 | font-size: calc(var(--base-size) / var(--scale-ratio)); 88 | font-weight: 400; 89 | line-height: 1.4; 90 | opacity: 0.8; 91 | } 92 | 93 | .icon { 94 | width: 24px; 95 | height: 24px; 96 | } 97 | 98 | button { 99 | border-radius: 9999px; 100 | font-size: calc(var(--base-size) / var(--scale-ratio)); 101 | background-color: var(--canvas-fill-color); 102 | font-weight: 400; 103 | padding-inline: var(--s-3); 104 | min-height: 44px; 105 | display: flex; 106 | align-items: center; 107 | justify-content: center; 108 | transition: background-color 0.1s ease-in-out; 109 | &:focus, &:focus-visible { 110 | outline: none; 111 | } 112 | &:focus-visible, &:active { 113 | background-color: oklch(0 0 0 / 0.1); 114 | } 115 | &:disabled { 116 | opacity: 0.4; 117 | cursor: not-allowed; 118 | } 119 | &:not(:disabled):hover { 120 | background-color: oklch(from var(--canvas-fill-color) calc(l - 0.05) c h); 121 | } 122 | &.small { 123 | min-height: 36px; 124 | } 125 | } 126 | 127 | .flex-column, .flex-row { 128 | display: flex; 129 | } 130 | .flex-column { 131 | flex-direction: column; 132 | } 133 | .flex-row { 134 | flex-direction: row; 135 | } 136 | .items-center { 137 | align-items: center; 138 | } 139 | .flex-wrap { 140 | flex-wrap: wrap; 141 | } 142 | 143 | a { 144 | text-decoration: underline; 145 | color: var(--primary-text-color); 146 | text-underline-offset: 0.3em; 147 | } -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {@render children()} 13 |
-------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 50 | 51 | 52 | Svedit - A rich content editor for Svelte 5 53 | 54 | 55 | 56 |
57 | 58 | 59 | 60 |
61 |
62 | 63 | Star 64 |
65 | 66 |
67 | 68 |
69 |
In this example the title and subtitle above are editable, but this piece of content here is not. Below is a container of Story and List blocks:
70 |
71 | 72 | {#snippet block(block, path)} 73 | {#if block.type === 'story'} 74 | 75 | {:else if block.type === 'list'} 76 | 77 | {:else} 78 | 79 | {/if} 80 | {/snippet} 81 | 82 |
83 | 84 |
85 | 86 |
87 |

Selection:

88 |
{JSON.stringify(entry_session.selection || {}, null, '  ')}
89 |

Entry:

90 |
{JSON.stringify(entry_session.entry, null, '  ')}
91 |
92 |
93 | 94 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael/svedit/be94425f1933e6568d448d0bc6e401a20d2efb7c/static/favicon.png -------------------------------------------------------------------------------- /static/fonts/JetBrainsMono-Italic[wght].woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael/svedit/be94425f1933e6568d448d0bc6e401a20d2efb7c/static/fonts/JetBrainsMono-Italic[wght].woff2 -------------------------------------------------------------------------------- /static/fonts/JetBrainsMono[wght].woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael/svedit/be94425f1933e6568d448d0bc6e401a20d2efb7c/static/fonts/JetBrainsMono[wght].woff2 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /static/icons/arrow-down-tail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/icons/arrow-up-tail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/icons/bold.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/icons/disc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/icons/external-link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/icons/image-at-top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/icons/image-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/icons/image-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/icons/italic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/icons/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/icons/list-decimal-leading-zero.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/icons/list-decimal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/icons/list-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/icons/list-lower-latin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/icons/list-lower-roman.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/icons/list-task.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/icons/list-upper-latin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/icons/list-upper-roman.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/icons/rotate-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/icons/rotate-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/icons/square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/images/container-cursors.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /static/images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /static/images/lightweight.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael/svedit/be94425f1933e6568d448d0bc6e401a20d2efb7c/static/images/lightweight.jpg -------------------------------------------------------------------------------- /static/images/lightweight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /static/images/nested-blocks-illustration.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /static/images/svelte-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | 3 | /** @type {import('@sveltejs/kit').Config} */ 4 | const config = { 5 | kit: { 6 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 7 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 8 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 9 | adapter: adapter() 10 | } 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test('home page has expected h1', async ({ page }) => { 4 | await page.goto('/'); 5 | await expect(page.locator('h1')).toBeVisible(); 6 | }); 7 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | test: { 7 | include: ['src/**/*.{test,spec}.{js,ts}'] 8 | } 9 | }); 10 | --------------------------------------------------------------------------------