diff --git a/README.md b/README.md index 67988fb9..cfb77132 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ npm install npm run dev ``` -Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/create_demo_session.js), add a [Toolbar](./src/routes/components/Toolbar.svelte), and render custom [Overlays](./src/routes/components/Overlays.svelte). For that just get inspired by the [Svedit demo code](./src/routes). +Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/create_demo_session.js), add a [Toolbar](./src/routes/Toolbar.svelte), and render custom [Overlays](./src/routes/Overlays.svelte). For that just get inspired by the [Svedit demo code](./src/routes). You can also install Svedit into an existing SvelteKit project with `npm install svedit`, but you'll need to set up the session, schema, config, and components yourself. See the [hello-svedit repo](https://github.com/michael/hello-svedit) or this repo's [`src/routes`](./src/routes) for reference. @@ -224,6 +224,67 @@ const document_schema = { Mark types are defined as nodes with `kind: 'mark'`, annotation types as nodes with `kind: 'annotation'`. Simple marks like `strong` and `emphasis` have no properties, while marks like `link` can carry data (e.g. `href`). Each `text` or `node_array` property specifies which types are allowed via `mark_types` and `annotation_types` — this lets you control formatting per property (e.g. allow bold and links in body text, but only emphasis in titles). `mark_types` may only reference `kind: 'mark'` node types and `annotation_types` only `kind: 'annotation'` node types. +## Adding a new node type + +Everything a node type needs can be grouped in one plain object — a package — and merged into your session setup with `compose`. Every package needs a unique `name`, used for diagnostics and duplicate-name checks. In the simplest case a new type is just a schema entry and a component: + +```js +// src/packages/quote/package.js +import Quote from './Quote.svelte'; + +export default { + name: 'quote', + schema: { + quote: { + kind: 'block', + properties: { + content: { type: 'text', allow_newlines: true }, + author: { type: 'string' } + } + } + }, + node_components: { quote: Quote }, + // Demo-app metadata; Svedit itself does not know about layouts. + node_layouts: { quote: 1 } +}; +``` + +Then compose your packages into the flat schema + config a `Session` expects, and allow the new type where it may appear (container wiring — `node_types`, `default_node_type`, `mark_types`, `annotation_types` — always stays with the property that owns the container, typically your document package): + +```js +import { compose, Session } from 'svedit'; +import page_pkg from '../packages/page/package.js'; +import quote_pkg from '../packages/quote/package.js'; + +const { schema, config } = compose([page_pkg, quote_pkg], { + generate_id: nanoid +}); + +const session = new Session(schema, doc, config); +``` + +That's usually all. Everything else is optional and only needed when the defaults don't fit: + +- **Inserter** — Svedit inserts new nodes generically from schema defaults: the node is created via `fill_node_defaults`, inserted, and for `kind: 'text'` nodes the caret is placed at the start of `content`. Add `inserters: { quote: (tr, content) => {...} }` to a package only when a new node needs more, e.g. seeded child nodes (see `list` and `story` in [`src/packages/`](src/packages/)). +- **HTML exporter** — `html_exporters: { quote: (node) => ... }` improves copy/paste to external apps; without one a generic exporter is used. +- **Commands and keymap** — a package can contribute commands (as a factory receiving the editor context) and keybindings that reference commands by name: +- **App registries** — custom object-valued keys like `node_layouts: { quote: 1 }` are merged into `config.node_layouts`. + +```js +commands: (context) => ({ toggle_strong: new ToggleMarkCommand('strong', context) }), +keymap: { 'meta+b,ctrl+b': ['toggle_strong'] } +``` + +`compose` merges everything into one flat result: schema entries and object-valued registry keys (`node_components`, `system_components`, `inserters`, `html_exporters`, plus custom app registries like `node_layouts`) merge key-by-key and **collisions throw**, keymap entries for the same key concatenate in package order, and command names are resolved across all packages. Unknown scalar/function package keys throw; app-level scalar config belongs in the second `compose(..., app_config)` argument. There is no plugin runtime or registry — a package is just an object, and after composition the result is indistinguishable from a hand-written flat config. The flat style (as used in [`src/test/create_test_session.js`](src/test/create_test_session.js)) remains fully supported. + +To catch omissions early, `Session` runs completeness checks in dev mode and logs `[svedit]` warnings with a fix hint for each finding: + +- a `document`/`block`/`text` type has no registered component (it would render as `UnknownNode`), +- a `default_node_type` can neither be auto-inserted from schema defaults nor has a custom inserter (Enter would fail), +- a `mark`/`annotation` type is not referenced by any `mark_types`/`annotation_types` (it could never be applied). + +The demo app is the reference for this setup style — each folder in [`src/packages/`](src/packages/) is one self-contained package, and [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js) composes them. + ## Document A document is a plain JavaScript object (POJO) with a `document_id` (the entry point) and a `nodes` object containing all content nodes. @@ -475,7 +536,7 @@ const session_config = { - **`generate_id`** - Function that generates unique IDs for new nodes - **`node_components`** - Maps each node type from your schema to a Svelte component - **`system_components`** - Optional overrides for internal editor components and a slot for your own overlays: - - `overlays` — A Svelte component rendered inside `` but outside the content canvas. Use it to add floating UI like link editors, image toolbars, or annotation popovers that appear near the current selection. See [`src/routes/components/Overlays.svelte`](src/routes/components/Overlays.svelte) for an example. + - `overlays` — A Svelte component rendered inside `` but outside the content canvas. Use it to add floating UI like link editors, image toolbars, or annotation popovers that appear near the current selection. See [`src/routes/Overlays.svelte`](src/routes/Overlays.svelte) for an example. - `node_gap`, `node_gap_markers`, `node_selection_markers` — Override the default system components if you need custom visuals for node gaps or selection indicators. - **`inserters`** - Functions that create blank nodes of each type and set up the selection - **`create_commands_and_keymap`** - Factory function that creates commands and keybindings for an editor instance diff --git a/src/lib/Session.svelte.js b/src/lib/Session.svelte.js index 55ad64f5..cafee207 100644 --- a/src/lib/Session.svelte.js +++ b/src/lib/Session.svelte.js @@ -10,6 +10,7 @@ import { validate_document_schema, validate_document, validate_config_components, + check_config_completeness, validate_node, is_id_valid, get_referencing_node_ids, @@ -94,6 +95,15 @@ export default class Session { validate_document(this.doc, this.schema); validate_config_components(this.schema, this.config); + // Soft completeness checks: point out likely setup omissions (missing + // components, non-insertable default node types, unused range types) + // during development without breaking the app. + if (import.meta.env?.DEV) { + for (const warning of check_config_completeness(this.schema, this.config)) { + console.warn(`[svedit] ${warning}`); + } + } + // Set selection after doc is initialized so validation can work properly this.selection = options.selection ?? null; } diff --git a/src/lib/compose.js b/src/lib/compose.js new file mode 100644 index 00000000..fb80baf6 --- /dev/null +++ b/src/lib/compose.js @@ -0,0 +1,179 @@ +/** + * Opt-in composition of packages into a flat schema + config. + * + * A package is a plain object that groups everything one concern needs + * (schema entries including sub-node types, components, inserters, + * exporters, commands, keymap contributions). `compose` merges packages + * into exactly the flat `{ schema, config }` shape a Session expects — after + * composition there is no runtime indirection, and the flat style remains + * fully supported. + * + * Rules: + * - Every package must have a unique `name`; names are used in diagnostics. + * - Packages define node types; only the app wires containers + * (`node_types`, `default_node_type`, `mark_types`, `annotation_types`). + * - Known Svedit registry keys and custom app registry keys merge + * key-by-key; duplicate keys across packages throw. + * - Unknown scalar/function package keys throw, because app-level scalar + * config belongs in the second `compose(..., app_config)` argument. + * - `commands` factories are combined into a single + * `create_commands_and_keymap`; `keymap` entries reference commands by + * name and concatenate when multiple packages bind the same key. + */ + +/** + * @import { Package } from './types' + */ + +import { define_keymap } from './KeyMapper.svelte.js'; + +/** Package keys that are not plain config registries */ +const SPECIAL_KEYS = ['name', 'schema', 'commands', 'keymap']; + +/** + * @param {Package} pkg + * @param {number} index + * @returns {string} Package name for error messages + */ +function package_name(pkg, index) { + if (typeof pkg.name !== 'string' || pkg.name.length === 0) { + throw new Error(`compose: package #${index + 1} must have a non-empty name.`); + } + return pkg.name; +} + +/** + * Merge `source` into `target` key-by-key, throwing on duplicate keys. + * + * @param {Record} target + * @param {Record} source + * @param {Record} owners - Maps merged keys to their package name + * @param {string} owner - Name of the package being merged + * @param {string} registry - Registry name for error messages (e.g. 'schema') + */ +function merge_registry(target, source, owners, owner, registry) { + for (const [key, value] of Object.entries(source)) { + if (key in target) { + throw new Error( + `compose: ${registry} key '${key}' from ${owner} conflicts with ${owners[key]}.` + ); + } + target[key] = value; + owners[key] = owner; + } +} + +function is_plain_object(value) { + return value && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Compose packages and app config into a flat schema and config. + * + * @param {Package[]} packages - Packages, merged in order + * @param {Record} [app_config] - App-level config (generate_id, + * system_components, handlers, ...). Object-valued keys participate in the + * collision-checked merge; other values are set directly. + * @returns {{ schema: Record, config: Record }} + */ +export function compose(packages, app_config = {}) { + /** @type {Record} */ + const schema = {}; + /** @type {Record} */ + const config = {}; + + // Track which package contributed each key, per registry + /** @type {Record>} */ + const owners = { schema: {} }; + + /** @type {Array<{ owner: string, factory: (context: any) => Record }>} */ + const command_factories = []; + /** @type {Record} */ + const keymap_names = {}; + const package_names = new Set(); + + for (const [index, pkg] of packages.entries()) { + const owner = package_name(pkg, index); + if (package_names.has(owner)) { + throw new Error(`compose: duplicate package name '${owner}'.`); + } + package_names.add(owner); + + for (const key of Object.keys(pkg)) { + if (SPECIAL_KEYS.includes(key)) continue; + const value = pkg[key]; + if (!is_plain_object(value)) { + throw new Error( + `compose: unknown package key '${key}' in ${owner}. Custom package keys must be plain object registries.` + ); + } + } + + if (pkg.schema) { + merge_registry(schema, pkg.schema, owners.schema, owner, 'schema'); + } + + if (pkg.commands) { + command_factories.push({ owner, factory: pkg.commands }); + } + + if (pkg.keymap) { + for (const [key_combo, command_names] of Object.entries(pkg.keymap)) { + // Same key from multiple packages concatenates in order, + // matching the existing fallback semantics of keymap arrays. + keymap_names[key_combo] = [...(keymap_names[key_combo] ?? []), ...command_names]; + } + } + + for (const [key, value] of Object.entries(pkg)) { + if (SPECIAL_KEYS.includes(key)) continue; + config[key] ??= {}; + owners[key] ??= {}; + merge_registry(config[key], value, owners[key], owner, `config.${key}`); + } + } + + for (const [key, value] of Object.entries(app_config)) { + if (is_plain_object(value)) { + config[key] ??= {}; + owners[key] ??= {}; + merge_registry(config[key], value, owners[key], 'app config', `config.${key}`); + } else { + config[key] = value; + } + } + + if (command_factories.length > 0 || Object.keys(keymap_names).length > 0) { + if (config.create_commands_and_keymap) { + throw new Error( + 'compose: pass commands/keymap through packages OR provide create_commands_and_keymap, not both.' + ); + } + config.create_commands_and_keymap = (context) => { + /** @type {Record} */ + const commands = {}; + /** @type {Record} */ + const command_owners = {}; + for (const { owner, factory } of command_factories) { + merge_registry(commands, factory(context), command_owners, owner, 'commands'); + } + + /** @type {Record} */ + const keymap = {}; + for (const [key_combo, command_names] of Object.entries(keymap_names)) { + keymap[key_combo] = command_names.map((command_name) => { + if (!commands[command_name]) { + throw new Error( + `compose: keymap '${key_combo}' references unknown command '${command_name}'.` + ); + } + return commands[command_name]; + }); + } + + return { commands, keymap: define_keymap(keymap) }; + }; + } + + return { schema, config }; +} diff --git a/src/lib/doc_utils.js b/src/lib/doc_utils.js index 0a987165..75f70f40 100644 --- a/src/lib/doc_utils.js +++ b/src/lib/doc_utils.js @@ -161,6 +161,15 @@ export function validate_document_schema(document_schema) { `Node type "${node_type}" property "${prop_name}" references unknown node types: ${missing_types.join(', ')}. Available node types: ${Object.keys(document_schema).join(', ')}` ); } + // node_types must reference content node kinds, not range kinds + const range_kind_types = prop_def.node_types.filter((ref_type) => + ['mark', 'annotation'].includes(document_schema[ref_type]?.kind) + ); + if (range_kind_types.length > 0) { + throw new Error( + `Node type "${node_type}" property "${prop_name}" node_types must not reference mark or annotation types, got: ${range_kind_types.join(', ')}. Use mark_types/annotation_types instead.` + ); + } } if (prop_def.type === 'text' || prop_def.type === 'node_array') { // mark_types must reference kind 'mark', annotation_types kind 'annotation' @@ -202,6 +211,73 @@ export function validate_config_components(schema, config) { } } +/** + * Check schema and config for common omissions when setting up node types. + * + * Unlike the validate_* functions these are soft checks: each finding is a + * human-readable warning describing what was likely forgotten and how to fix + * it. Session logs them in dev mode; nothing throws. + * + * @param {DocumentSchema} schema - The document schema + * @param {any} config - The Svedit config + * @returns {string[]} Warning messages, empty when the setup looks complete + */ +export function check_config_completeness(schema, config) { + const warnings = []; + + // Referenced mark/annotation types, to detect unused range types + const referenced_range_types = new Set(); + for (const node_schema of Object.values(schema)) { + for (const prop_def of Object.values(node_schema.properties)) { + for (const key of ['mark_types', 'annotation_types']) { + for (const type of prop_def[key] ?? []) { + referenced_range_types.add(type); + } + } + } + } + + for (const [node_type, node_schema] of Object.entries(schema)) { + if ( + ['document', 'block', 'text'].includes(node_schema.kind) && + !config?.node_components?.[node_type] + ) { + warnings.push( + `Node type '${node_type}' has no registered component and will render as UnknownNode. Add it to config.node_components.` + ); + } + + if ( + ['mark', 'annotation'].includes(node_schema.kind) && + !referenced_range_types.has(node_type) + ) { + warnings.push( + `Node type '${node_type}' (kind '${node_schema.kind}') is not referenced by any ${node_schema.kind}_types and can never be applied.` + ); + } + + for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) { + if (prop_def.type !== 'node_array') continue; + const default_type = get_default_node_type(prop_def); + if (!default_type || config?.inserters?.[default_type]) continue; + + // Dry-run the generic inserter: a default node type must be + // constructible from schema defaults alone. + try { + validate_node(fill_node_defaults({ id: '_check', type: default_type }, schema), schema, {}, { + require_references: false + }); + } catch (error) { + warnings.push( + `Node type '${default_type}' is the default node type of '${node_type}.${prop_name}' but cannot be auto-inserted (${/** @type {Error} */ (error).message}). Define config.inserters['${default_type}'].` + ); + } + } + } + + return warnings; +} + /** * Validate a primitive value against its schema type. * @param {PrimitiveType} type - The expected type diff --git a/src/lib/index.js b/src/lib/index.js index 639bcdc0..c871083c 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -24,9 +24,14 @@ export { validate_document_schema, validate_document, validate_node, + validate_config_components, + check_config_completeness, get_referencing_node_ids } from './doc_utils.js'; +// Config composition +export { compose } from './compose.js'; + // Command system export { default as Command } from './Command.svelte.js'; export * from './Command.svelte.js'; diff --git a/src/lib/transforms.svelte.js b/src/lib/transforms.svelte.js index 747b5afb..e53c3cbd 100644 --- a/src/lib/transforms.svelte.js +++ b/src/lib/transforms.svelte.js @@ -1,5 +1,5 @@ import { split_text, join_text, get_char_length } from './utils.js'; -import { get_default_node_type } from './doc_utils.js'; +import { get_default_node_type, fill_node_defaults } from './doc_utils.js'; /** * Set multiple properties on a node via a transaction. @@ -14,6 +14,51 @@ export function set_properties(tr, path, properties) { } } +/** + * Insert a new node of the given type at the current node selection. + * + * A custom inserter registered in `config.inserters[node_type]` always wins. + * Otherwise a generic schema-driven insertion is performed: the node is + * created from schema defaults, inserted, and — for `kind: 'text'` nodes — a + * collapsed text selection is placed at the start of its content. + * + * @param {object} tr - The transaction + * @param {string} node_type - The node type to insert + * @param {any} [content] - Optional text value assigned to `content` (kind 'text' only) + */ +export function insert_node_of_type(tr, node_type, content) { + const custom_inserter = tr.config?.inserters?.[node_type]; + if (custom_inserter) { + custom_inserter(tr, content); + return; + } + + const node = fill_node_defaults({ id: tr.generate_id(), type: node_type }, tr.schema); + const node_kind = tr.schema[node_type]?.kind; + if (content !== undefined && node_kind === 'text') { + node.content = content; + } + + try { + tr.create(node); + } catch (error) { + throw new Error( + `Cannot auto-insert node type '${node_type}' from schema defaults (${/** @type {Error} */ (error).message}). Define config.inserters['${node_type}'].`, + { cause: error } + ); + } + tr.insert_nodes([node.id]); + + if (node_kind === 'text') { + tr.set_selection({ + type: 'text', + path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], + anchor_offset: 0, + focus_offset: 0 + }); + } +} + export function break_text_node(tr) { // Keep a reference of the original selection (before any transforms are applied) const selection = tr.selection; @@ -64,7 +109,7 @@ export function break_text_node(tr) { tr.set_selection(node_insert_position); - tr.config.inserters[target_node_type](tr, right_text); + insert_node_of_type(tr, target_node_type, right_text); return true; } @@ -154,11 +199,11 @@ export function insert_default_node(tr) { const property_definition = tr.schema[node_array_node.type].properties[property_name]; const default_type = get_default_node_type(property_definition); - // Use the inserter function if available - if (tr.config?.inserters?.[default_type]) { - tr.config.inserters[default_type](tr); - return true; - } else { - throw new Error(`No inserter function available for default node type '${default_type}'`); + if (!default_type) { + console.warn('Cannot determine default node type for insert_default_node'); + return false; } + + insert_node_of_type(tr, default_type); + return true; } diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index 1f187722..0a88ed32 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -339,6 +339,35 @@ export type NodeSchema = TextNodeSchema | NonTextNodeSchema; */ export type DocumentSchema = Record; +/** + * A package groups everything one concern needs (schema entries including + * sub-node types, components, inserters, exporters, commands, keymap + * contributions). Packages are merged into a flat schema + config via + * `compose`. + */ +export type Package = { + /** Unique package name used in compose diagnostics */ + name: string; + /** Schema entries contributed by this package (loosely typed so plain + * object literals don't require const assertions; the merged result is + * validated at Session construction) */ + schema?: Record; + /** Components per node type, merged into config.node_components */ + node_components?: Record; + /** System components, merged into config.system_components */ + system_components?: Record; + /** Custom inserters per node type, merged into config.inserters */ + inserters?: Record void>; + /** HTML exporters per node type, merged into config.html_exporters */ + html_exporters?: Record string>; + /** Command factory; returned commands are merged by name */ + commands?: (context: any) => Record; + /** Key combos mapped to arrays of command names */ + keymap?: Record; + /** Custom app-owned registries, merged into config by key */ + [key: string]: any; +}; + /** * A node in the document. * Must have id and type properties, with other properties defined by the schema. diff --git a/src/packages/core/package.js b/src/packages/core/package.js new file mode 100644 index 00000000..e33cf90f --- /dev/null +++ b/src/packages/core/package.js @@ -0,0 +1,41 @@ +import { + SelectAllCommand, + InsertDefaultNodeCommand, + AddNewLineCommand, + BreakTextNodeCommand, + UndoCommand, + RedoCommand, + SelectParentCommand +} from 'svedit'; +import { CycleLayoutCommand, CycleNodeTypeCommand } from '../../routes/commands.svelte.js'; + +// App-level editing commands and their keybindings, independent of any +// specific node type. +export default { + name: 'core', + commands: (context) => ({ + select_all: new SelectAllCommand(context), + insert_default_node: new InsertDefaultNodeCommand(context), + add_new_line: new AddNewLineCommand(context), + break_text_node: new BreakTextNodeCommand(context), + undo: new UndoCommand(context), + redo: new RedoCommand(context), + select_parent: new SelectParentCommand(context), + next_layout: new CycleLayoutCommand('next', context), + previous_layout: new CycleLayoutCommand('previous', context), + next_type: new CycleNodeTypeCommand('next', context), + previous_type: new CycleNodeTypeCommand('previous', context) + }), + keymap: { + 'meta+a,ctrl+a': ['select_all'], + enter: ['break_text_node', 'add_new_line', 'insert_default_node'], + 'shift+enter': ['add_new_line'], + 'meta+z,ctrl+z': ['undo'], + 'meta+shift+z,ctrl+shift+z': ['redo'], + escape: ['select_parent'], + 'ctrl+shift+arrowright': ['next_layout'], + 'ctrl+shift+arrowleft': ['previous_layout'], + 'ctrl+shift+arrowdown': ['next_type'], + 'ctrl+shift+arrowup': ['previous_type'] + } +}; diff --git a/src/routes/components/Hero.svelte b/src/packages/hero/Hero.svelte similarity index 100% rename from src/routes/components/Hero.svelte rename to src/packages/hero/Hero.svelte diff --git a/src/packages/hero/package.js b/src/packages/hero/package.js new file mode 100644 index 00000000..8918e6d2 --- /dev/null +++ b/src/packages/hero/package.js @@ -0,0 +1,45 @@ +import Hero from './Hero.svelte'; +import { ALL_MARKS, TITLE_MARKS } from '../marks/package.js'; + +// The hero needs no inserter: the generic schema-driven inserter creates it +// from schema defaults. +export default { + name: 'hero', + schema: { + hero: { + kind: 'block', + properties: { + layout: { type: 'integer', default: 1 }, + title: { + type: 'text', + mark_types: TITLE_MARKS, + allow_newlines: false + }, + description: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + }, + image: { type: 'string' } // a dedicated type asset would be better + } + } + }, + node_components: { + hero: Hero + }, + node_layouts: { + hero: 1 + }, + html_exporters: { + hero: (node) => { + let html = ''; + if (node.title.content.trim()) { + html += `

${node.title.content}

\n`; + } + if (node.description.content.trim()) { + html += `

${node.description.content}

\n`; + } + return html; + } + } +}; diff --git a/src/routes/components/ImageGrid.svelte b/src/packages/image_grid/ImageGrid.svelte similarity index 100% rename from src/routes/components/ImageGrid.svelte rename to src/packages/image_grid/ImageGrid.svelte diff --git a/src/routes/components/ImageGridItem.svelte b/src/packages/image_grid/ImageGridItem.svelte similarity index 100% rename from src/routes/components/ImageGridItem.svelte rename to src/packages/image_grid/ImageGridItem.svelte diff --git a/src/packages/image_grid/package.js b/src/packages/image_grid/package.js new file mode 100644 index 00000000..f0caf88c --- /dev/null +++ b/src/packages/image_grid/package.js @@ -0,0 +1,95 @@ +import ImageGrid from './ImageGrid.svelte'; +import ImageGridItem from './ImageGridItem.svelte'; +import { ALL_MARKS, TITLE_MARKS } from '../marks/package.js'; + +export default { + name: 'image_grid', + schema: { + image_grid: { + kind: 'block', + properties: { + layout: { type: 'integer', default: 1 }, + image_grid_items: { + type: 'node_array', + node_types: ['image_grid_item'], + annotation_types: ['marker'] + } + } + }, + image_grid_item: { + kind: 'block', + properties: { + image: { type: 'string' }, // a dedicated type asset would be better + title: { + type: 'text', + mark_types: TITLE_MARKS, + allow_newlines: false + }, + description: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: false + } + } + } + }, + node_components: { + image_grid: ImageGrid, + image_grid_item: ImageGridItem + }, + node_layouts: { + image_grid: 1 + }, + inserters: { + // Custom: a new grid is seeded with six empty items. + image_grid: function (tr) { + const new_image_grid_items = []; + for (let i = 0; i < 6; i++) { + const image_grid_item = { + id: tr.generate_id(), + type: 'image_grid_item' + }; + tr.create(image_grid_item); + new_image_grid_items.push(image_grid_item.id); + } + const new_image_grid = { + id: tr.generate_id(), + type: 'image_grid', + image_grid_items: { nodes: new_image_grid_items, marks: [], annotations: [] } + }; + tr.create(new_image_grid); + tr.insert_nodes([new_image_grid.id]); + }, + // Custom: after inserting an item the caret collapses behind it + // instead of keeping the new node selected. + image_grid_item: function (tr) { + const new_image_grid_item = { + id: tr.generate_id(), + type: 'image_grid_item' + }; + tr.create(new_image_grid_item); + tr.insert_nodes([new_image_grid_item.id]); + tr.set_selection({ + type: 'node', + path: [...tr.selection.path], + anchor_offset: tr.selection.focus_offset, + focus_offset: tr.selection.focus_offset + }); + } + }, + html_exporters: { + image_grid_item: (node) => { + let html = '
\n'; + if (node.image) { + html += `${node.title.content}\n`; + } + if (node.title.content.trim()) { + html += `

${node.title.content}

\n`; + } + if (node.description.content.trim()) { + html += `

${node.description.content}

\n`; + } + return html + '
'; + } + } +}; diff --git a/src/routes/components/List.svelte b/src/packages/list/List.svelte similarity index 100% rename from src/routes/components/List.svelte rename to src/packages/list/List.svelte diff --git a/src/routes/components/ListItem.svelte b/src/packages/list/ListItem.svelte similarity index 100% rename from src/routes/components/ListItem.svelte rename to src/packages/list/ListItem.svelte diff --git a/src/packages/list/package.js b/src/packages/list/package.js new file mode 100644 index 00000000..0d939f63 --- /dev/null +++ b/src/packages/list/package.js @@ -0,0 +1,74 @@ +import List from './List.svelte'; +import ListItem from './ListItem.svelte'; +import { ALL_MARKS } from '../marks/package.js'; + +export default { + name: 'list', + schema: { + list: { + kind: 'block', + properties: { + list_items: { + type: 'node_array', + node_types: ['list_item'], + annotation_types: ['marker'] + }, + layout: { type: 'integer', default: 1 } + } + }, + list_item: { + kind: 'text', + properties: { + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: false + } + } + } + }, + node_components: { + list: List, + list_item: ListItem + }, + node_layouts: { + list: 5, + list_item: 1 + }, + inserters: { + // Custom: a new list is seeded with one empty item; list_item itself + // uses the generic schema-driven inserter. + list: function (tr) { + const new_list_item = { + id: tr.generate_id(), + type: 'list_item' + }; + tr.create(new_list_item); + const new_list = { + id: tr.generate_id(), + type: 'list', + list_items: { nodes: [new_list_item.id], marks: [], annotations: [] }, + layout: 3 + }; + tr.create(new_list); + tr.insert_nodes([new_list.id]); + } + }, + html_exporters: { + list: (node, doc, html_exporters) => { + const { list_item } = html_exporters; + let html = '
    \n'; + for (const list_item_id of node.list_items.nodes) { + html += list_item(doc.get(list_item_id), doc, html_exporters); + } + return html + '
'; + }, + list_item: (node) => { + const content = + typeof node.content === 'object' && node.content.content + ? node.content.content + : node.content || ''; + return `
  • ${content}
  • \n`; + } + } +}; diff --git a/src/packages/marker/package.js b/src/packages/marker/package.js new file mode 100644 index 00000000..ef949b18 --- /dev/null +++ b/src/packages/marker/package.js @@ -0,0 +1,22 @@ +import { ToggleAnnotationCommand } from 'svedit'; + +export default { + name: 'marker', + schema: { + // An annotation: data-only, no component allowed, so it may overlap marks + // and other annotations. Covered node wrappers get `anno-marker` classes + // automatically, styled in styles/annotations.css. + marker: { + kind: 'annotation', + properties: {} + } + }, + commands: (context) => ({ + // Annotations only compete with same-type annotations, so the + // marker toggle never conflicts with sections or other marks. + toggle_marker: new ToggleAnnotationCommand('marker', context) + }), + keymap: { + 'meta+shift+m,ctrl+shift+m': ['toggle_marker'] + } +}; diff --git a/src/routes/components/Emphasis.svelte b/src/packages/marks/Emphasis.svelte similarity index 100% rename from src/routes/components/Emphasis.svelte rename to src/packages/marks/Emphasis.svelte diff --git a/src/routes/components/Highlight.svelte b/src/packages/marks/Highlight.svelte similarity index 100% rename from src/routes/components/Highlight.svelte rename to src/packages/marks/Highlight.svelte diff --git a/src/routes/components/Link.svelte b/src/packages/marks/Link.svelte similarity index 100% rename from src/routes/components/Link.svelte rename to src/packages/marks/Link.svelte diff --git a/src/routes/components/Section.svelte b/src/packages/marks/Section.svelte similarity index 100% rename from src/routes/components/Section.svelte rename to src/packages/marks/Section.svelte diff --git a/src/routes/components/Strong.svelte b/src/packages/marks/Strong.svelte similarity index 100% rename from src/routes/components/Strong.svelte rename to src/packages/marks/Strong.svelte diff --git a/src/packages/marks/package.js b/src/packages/marks/package.js new file mode 100644 index 00000000..7b82fa8c --- /dev/null +++ b/src/packages/marks/package.js @@ -0,0 +1,61 @@ +import { ToggleMarkCommand } from 'svedit'; +import { ToggleLinkCommand } from '../../routes/commands.svelte.js'; + +import Strong from './Strong.svelte'; +import Emphasis from './Emphasis.svelte'; +import Highlight from './Highlight.svelte'; +import Link from './Link.svelte'; +import Section from './Section.svelte'; + +// Mark bundles other packages reference from their text properties +export const ALL_MARKS = ['strong', 'emphasis', 'highlight', 'link']; +export const TITLE_MARKS = ['emphasis', 'highlight']; + +export default { + name: 'marks', + schema: { + strong: { + kind: 'mark', + properties: {} + }, + emphasis: { + kind: 'mark', + properties: {} + }, + highlight: { + kind: 'mark', + properties: {} + }, + link: { + kind: 'mark', + properties: { + href: { type: 'string' } + } + }, + section: { + kind: 'mark', + properties: {} + } + }, + node_components: { + strong: Strong, + emphasis: Emphasis, + highlight: Highlight, + link: Link, + section: Section + }, + commands: (context) => ({ + toggle_strong: new ToggleMarkCommand('strong', context), + toggle_emphasis: new ToggleMarkCommand('emphasis', context), + toggle_highlight: new ToggleMarkCommand('highlight', context), + toggle_link: new ToggleLinkCommand(context), + toggle_section: new ToggleMarkCommand('section', context) + }), + keymap: { + 'meta+b,ctrl+b': ['toggle_strong'], + 'meta+i,ctrl+i': ['toggle_emphasis'], + 'meta+u,ctrl+u': ['toggle_highlight'], + 'meta+k,ctrl+k': ['toggle_link'], + 'meta+shift+s,ctrl+shift+s': ['toggle_section'] + } +}; diff --git a/src/routes/components/Page.svelte b/src/packages/page/Page.svelte similarity index 100% rename from src/routes/components/Page.svelte rename to src/packages/page/Page.svelte diff --git a/src/packages/page/package.js b/src/packages/page/package.js new file mode 100644 index 00000000..7bbe741f --- /dev/null +++ b/src/packages/page/package.js @@ -0,0 +1,43 @@ +import Page from './Page.svelte'; + +// The page package owns all container wiring: which node types are allowed in +// the body, which marks/annotations may wrap body ranges, and what gets +// inserted by default. Packages define node types; the app composes them. +export default { + name: 'page', + schema: { + page: { + kind: 'document', + properties: { + body: { + type: 'node_array', + node_types: [ + 'paragraph', + 'heading_1', + 'heading_2', + 'heading_3', + 'story', + 'list', + 'image_grid', + 'hero' + ], + mark_types: ['section'], + annotation_types: ['marker'], + default_node_type: 'paragraph' + }, + keywords: { + type: 'string_array' + }, + daily_visitors: { + type: 'integer_array' + }, + created_at: { + type: 'datetime' + } + } + } + }, + node_components: { + page: Page + } +}; diff --git a/src/routes/components/Button.svelte b/src/packages/story/Button.svelte similarity index 100% rename from src/routes/components/Button.svelte rename to src/packages/story/Button.svelte diff --git a/src/routes/components/Story.svelte b/src/packages/story/Story.svelte similarity index 100% rename from src/routes/components/Story.svelte rename to src/packages/story/Story.svelte diff --git a/src/packages/story/package.js b/src/packages/story/package.js new file mode 100644 index 00000000..73d9eb13 --- /dev/null +++ b/src/packages/story/package.js @@ -0,0 +1,106 @@ +import Story from './Story.svelte'; +import Button from './Button.svelte'; +import { ALL_MARKS, TITLE_MARKS } from '../marks/package.js'; + +export default { + name: 'story', + schema: { + story: { + kind: 'block', + properties: { + layout: { type: 'integer', default: 1 }, + title: { + type: 'text', + mark_types: TITLE_MARKS, + allow_newlines: false + }, + description: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + }, + buttons: { + type: 'node_array', + node_types: ['button'], + default_node_type: 'button' + }, + image: { type: 'string' } + } + }, + button: { + kind: 'text', + properties: { + content: { + type: 'text', + mark_types: [], + allow_newlines: false + }, + href: { type: 'string' } + } + } + }, + node_components: { + story: Story, + button: Button + }, + node_layouts: { + story: 3, + button: 1 + }, + inserters: { + // Custom: direct button insertion should place the caret inside + // the newly-created button label. + button: function (tr, content = { content: '', marks: [], annotations: [] }) { + const new_button = { + id: tr.generate_id(), + type: 'button', + content, + href: 'https://editable.website' + }; + tr.create(new_button); + tr.insert_nodes([new_button.id]); + tr.set_selection({ + type: 'text', + path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], + anchor_offset: 0, + focus_offset: 0 + }); + }, + // Custom: a new story is seeded with a nested button, which schema + // defaults alone can't express. + story: function (tr) { + const new_button = { + id: tr.generate_id(), + type: 'button' + }; + tr.create(new_button); + const new_story = { + id: tr.generate_id(), + type: 'story', + buttons: { nodes: [new_button.id], marks: [], annotations: [] } + }; + tr.create(new_story); + tr.insert_nodes([new_story.id]); + } + }, + html_exporters: { + story: (node, doc, html_exporters) => { + const { button } = html_exporters; + let html = ''; + if (node.image) { + html += `${node.title.content}\n`; + } + html += `

    ${node.title.content}

    \n`; + if (node.description) { + html += `

    ${node.description.content}

    \n`; + } + for (const button_id of node.buttons.nodes) { + html += button(doc.get(button_id), doc, html_exporters); + } + return html; + }, + button: (node) => { + return `${node.content.content}\n`; + } + } +}; diff --git a/src/routes/components/Heading1.svelte b/src/packages/text_blocks/Heading1.svelte similarity index 100% rename from src/routes/components/Heading1.svelte rename to src/packages/text_blocks/Heading1.svelte diff --git a/src/routes/components/Heading2.svelte b/src/packages/text_blocks/Heading2.svelte similarity index 100% rename from src/routes/components/Heading2.svelte rename to src/packages/text_blocks/Heading2.svelte diff --git a/src/routes/components/Heading3.svelte b/src/packages/text_blocks/Heading3.svelte similarity index 100% rename from src/routes/components/Heading3.svelte rename to src/packages/text_blocks/Heading3.svelte diff --git a/src/routes/components/Paragraph.svelte b/src/packages/text_blocks/Paragraph.svelte similarity index 100% rename from src/routes/components/Paragraph.svelte rename to src/packages/text_blocks/Paragraph.svelte diff --git a/src/packages/text_blocks/package.js b/src/packages/text_blocks/package.js new file mode 100644 index 00000000..3c86ebdd --- /dev/null +++ b/src/packages/text_blocks/package.js @@ -0,0 +1,83 @@ +import Paragraph from './Paragraph.svelte'; +import Heading1 from './Heading1.svelte'; +import Heading2 from './Heading2.svelte'; +import Heading3 from './Heading3.svelte'; +import { ALL_MARKS } from '../marks/package.js'; + +// Text blocks need no inserters: Svedit's generic schema-driven inserter +// creates them from schema defaults and places the caret in `content`. +export default { + name: 'text_blocks', + schema: { + paragraph: { + kind: 'text', + properties: { + layout: { type: 'integer', default: 1 }, + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + } + } + }, + heading_1: { + kind: 'text', + properties: { + layout: { type: 'integer', default: 1 }, + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + } + } + }, + heading_2: { + kind: 'text', + properties: { + layout: { type: 'integer', default: 1 }, + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + } + } + }, + heading_3: { + kind: 'text', + properties: { + layout: { type: 'integer', default: 1 }, + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + } + } + } + }, + node_components: { + paragraph: Paragraph, + heading_1: Heading1, + heading_2: Heading2, + heading_3: Heading3 + }, + node_layouts: { + paragraph: 1, + heading_1: 1, + heading_2: 1, + heading_3: 1 + }, + html_exporters: { + paragraph: (node) => { + return `

    ${node.content.content}

    \n`; + }, + heading_1: (node) => { + return `

    ${node.content.content}

    \n`; + }, + heading_2: (node) => { + return `

    ${node.content.content}

    \n`; + }, + heading_3: (node) => { + return `

    ${node.content.content}

    \n`; + } + } +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 90fbd5dc..582de986 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,5 @@ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a10f5580..049d7ec5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,7 @@ + +
    + {@render children()} +
    diff --git a/src/routes/components/LinkActionOverlay.svelte b/src/routes/LinkActionOverlay.svelte similarity index 95% rename from src/routes/components/LinkActionOverlay.svelte rename to src/routes/LinkActionOverlay.svelte index 6387883f..8ccb5a03 100644 --- a/src/routes/components/LinkActionOverlay.svelte +++ b/src/routes/LinkActionOverlay.svelte @@ -1,7 +1,7 @@ - -
    - {@render children()} -
    diff --git a/src/routes/create_demo_session.js b/src/routes/create_demo_session.js index 96fc0741..211bca34 100644 --- a/src/routes/create_demo_session.js +++ b/src/routes/create_demo_session.js @@ -1,1108 +1,92 @@ -import { - Session, - SelectAllCommand, - InsertDefaultNodeCommand, - AddNewLineCommand, - BreakTextNodeCommand, - ToggleMarkCommand, - ToggleAnnotationCommand, - UndoCommand, - RedoCommand, - SelectParentCommand, - define_document_schema, - fill_document_defaults, - define_keymap -} from 'svedit'; -import { CycleLayoutCommand, CycleNodeTypeCommand, ToggleLinkCommand } from './commands.svelte.js'; +import { Session, compose, fill_document_defaults } from 'svedit'; import nanoid from './nanoid.js'; +import doc from './demo_doc.js'; // System components -import Overlays from './components/Overlays.svelte'; +import Overlays from './Overlays.svelte'; -// Node components -import Page from './components/Page.svelte'; -import Story from './components/Story.svelte'; -import Button from './components/Button.svelte'; -import Paragraph from './components/Paragraph.svelte'; -import Heading1 from './components/Heading1.svelte'; -import Heading2 from './components/Heading2.svelte'; -import Heading3 from './components/Heading3.svelte'; -import List from './components/List.svelte'; -import ListItem from './components/ListItem.svelte'; -import ImageGrid from './components/ImageGrid.svelte'; -import ImageGridItem from './components/ImageGridItem.svelte'; -import Hero from './components/Hero.svelte'; -import Strong from './components/Strong.svelte'; -import Emphasis from './components/Emphasis.svelte'; -import Highlight from './components/Highlight.svelte'; -import Link from './components/Link.svelte'; -import Section from './components/Section.svelte'; +// Packages: each groups everything one concern needs (schema entries incl. +// sub-node types, components, inserters, exporters, commands, keymaps). +// `compose` merges them into the flat schema + config a Session expects. +// Container wiring (which node types the page body accepts) lives in the +// page package — packages define node types, the app composes them. +import page_pkg from '../packages/page/package.js'; +import text_blocks_pkg from '../packages/text_blocks/package.js'; +import marks_pkg from '../packages/marks/package.js'; +import marker_pkg from '../packages/marker/package.js'; +import story_pkg from '../packages/story/package.js'; +import list_pkg from '../packages/list/package.js'; +import image_grid_pkg from '../packages/image_grid/package.js'; +import hero_pkg from '../packages/hero/package.js'; +import core_pkg from '../packages/core/package.js'; -const ALL_MARKS = ['strong', 'emphasis', 'highlight', 'link']; -const TITLE_MARKS = ['emphasis', 'highlight']; +const { schema, config } = compose( + [ + page_pkg, + text_blocks_pkg, + marks_pkg, + marker_pkg, + story_pkg, + list_pkg, + image_grid_pkg, + hero_pkg, + core_pkg + ], + { + // Custom ID generator function + generate_id: nanoid, + // Provide overrides for system components (node_gap, node_gap_markers, + // node_selection_markers) or user-land overlays (link previews, etc.) + system_components: { + overlays: Overlays + }, + // Toggle view classes for the editor. On by default. + view_classes: true, + handle_property_deletion: (tr, path) => { + const property_definition = tr.inspect(path); + if (property_definition?.type !== 'string' || property_definition?.name !== 'image') return; + tr.set(path, ''); + }, + handle_media_paste: async (doc, pasted_media) => { + // ATTENTION: In a real-world-app, you may want to upload `pasted_media` here, + // before referencing them from the document. -export const document_schema = define_document_schema({ - page: { - kind: 'document', - properties: { - body: { - type: 'node_array', - node_types: [ - 'paragraph', - 'heading_1', - 'heading_2', - 'heading_3', - 'story', - 'list', - 'image_grid', - 'hero' - ], - mark_types: ['section'], - annotation_types: ['marker'], - default_node_type: 'paragraph' - }, - keywords: { - type: 'string_array' - }, - daily_visitors: { - type: 'integer_array' - }, - created_at: { - type: 'datetime' - } - } - }, - hero: { - kind: 'block', - properties: { - layout: { type: 'integer', default: 1 }, - title: { - type: 'text', - mark_types: TITLE_MARKS, - allow_newlines: false - }, - description: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - }, - image: { type: 'string' } // a dedicated type asset would be better - } - }, - paragraph: { - kind: 'text', - properties: { - layout: { type: 'integer', default: 1 }, - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - } - } - }, - heading_1: { - kind: 'text', - properties: { - layout: { type: 'integer', default: 1 }, - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - } - } - }, - heading_2: { - kind: 'text', - properties: { - layout: { type: 'integer', default: 1 }, - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - } - } - }, - heading_3: { - kind: 'text', - properties: { - layout: { type: 'integer', default: 1 }, - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - } - } - }, - button: { - kind: 'text', - properties: { - content: { - type: 'text', - mark_types: [], - allow_newlines: false - }, - href: { type: 'string' } - } - }, - story: { - kind: 'block', - properties: { - layout: { type: 'integer', default: 1 }, - title: { - type: 'text', - mark_types: TITLE_MARKS, - allow_newlines: false - }, - description: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - }, - buttons: { - type: 'node_array', - node_types: ['button'], - default_node_type: 'button' - }, - image: { type: 'string' } - } - }, - image_grid: { - kind: 'block', - properties: { - layout: { type: 'integer', default: 1 }, - image_grid_items: { - type: 'node_array', - node_types: ['image_grid_item'], - annotation_types: ['marker'] - } - } - }, - image_grid_item: { - kind: 'block', - properties: { - image: { type: 'string' }, // a dedicated type asset would be better - title: { - type: 'text', - mark_types: TITLE_MARKS, - allow_newlines: false - }, - description: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: false - } - } - }, - list_item: { - kind: 'text', - properties: { - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: false - } - } - }, - list: { - kind: 'block', - properties: { - list_items: { - type: 'node_array', - node_types: ['list_item'], - annotation_types: ['marker'], - }, - layout: { type: 'integer', default: 1 } - } - }, - link: { - kind: 'mark', - properties: { - href: { type: 'string' } - } - }, - strong: { - kind: 'mark', - properties: {} - }, - emphasis: { - kind: 'mark', - properties: {} - }, - highlight: { - kind: 'mark', - properties: {} - }, - section: { - kind: 'mark', - properties: {} - }, - // An annotation: data-only, no component allowed, so it may overlap marks - // and other annotations. Covered node wrappers get `anno-marker` classes - // automatically, styled in styles/annotations.css. - marker: { - kind: 'annotation', - properties: {} - } -}); - -const doc = { - document_id: 'page_1', - nodes: { - zXfRSCtGrGWaBCpaWdBvdZG: { - id: 'zXfRSCtGrGWaBCpaWdBvdZG', - type: 'link', - href: 'https://editable.website' - }, - hero_1: { - id: 'hero_1', - type: 'hero', - layout: 1, - title: { - content: 'Svedit', - marks: [], - annotations: [] - }, - description: { - content: 'A tiny library for building editable websites in Svelte.', - marks: [ - { - start_offset: 28, - end_offset: 36, - node_id: 'zXfRSCtGrGWaBCpaWdBvdZG' - } - ], - annotations: [] - }, - image: '' - }, - heading_1: { - id: 'heading_1', - type: 'heading_1', - layout: 1, - content: { - content: 'Text and structured content in symbiosis', - marks: [], - annotations: [] - } - }, - paragraph_1: { - id: 'paragraph_1', - type: 'paragraph', - layout: 1, - content: { - content: - "Unlike most rich text editors, Svedit isn't restricted to a linear character-based model for addressing content and caret positions. For that reason we can combine text-ish content like a paragraph or heading with structured, form-like content.", - marks: [], - annotations: [] - } - }, - JRZfuWeXnWKnWHaNtjupSsX: { - id: 'JRZfuWeXnWKnWHaNtjupSsX', - type: 'button', - content: { - content: 'Get started', - marks: [], - annotations: [] - }, - href: 'https://github.com/michael/svedit' - }, - story_1: { - id: 'story_1', - type: 'story', - layout: 1, - image: '/images/editable.svg', - title: { - content: 'Visual in‑place editing', - marks: [], - annotations: [] - }, - description: { - content: - 'Model your content in JSON, render it with Svelte components, and edit content directly in the layout. You only have to follow a couple of rules to make this work.', - marks: [], - annotations: [] - }, - buttons: { - nodes: ['JRZfuWeXnWKnWHaNtjupSsX'], - marks: [], - annotations: [] - } - }, - link_1: { - id: 'link_1', - type: 'link', - href: 'https://editable.website' - }, - story_2: { - id: 'story_2', - type: 'story', - layout: 2, - image: '/images/lightweight.svg', - title: { - content: 'Minimal viable editor', - marks: [], - annotations: [] - }, - description: { - content: - "The reference implementation uses only about 2000 lines of code. That means you'll be able to serve editable web pages, removing the need for a separate Content Management System.", - marks: [ - { - start_offset: 100, - end_offset: 118, - node_id: 'link_1' - } - ], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - image_grid_item_1: { - id: 'image_grid_item_1', - type: 'image_grid_item', - image: '/images/svelte-framework.svg', - title: { - content: 'Svelte-native editing', - marks: [], - annotations: [] - }, - description: { - content: "No mingling with 3rd-party rendering API's.", - marks: [], - annotations: [] - } - }, - image_grid_item_2: { - id: 'image_grid_item_2', - type: 'image_grid_item', - image: '/images/annotations.svg', - title: { - content: 'Marks and annotations', - marks: [], - annotations: [] - }, - description: { - content: - 'Wrap ranges in marks (e.g. links) and attach annotations (e.g. comments) at a separate layer.', - marks: [], - annotations: [] - } - }, - image_grid_item_3: { - id: 'image_grid_item_3', - type: 'image_grid_item', - image: '/images/graphmodel.svg', - title: { - content: 'Graph‑first content with nested nodes', - marks: [], - annotations: [] - }, - description: { - content: - 'From simple paragraphs to complex nodes with nested arrays and multiple properties.', - marks: [], - annotations: [] - } - }, - image_grid_item_4: { - id: 'image_grid_item_4', - type: 'image_grid_item', - image: '/images/dom-synced.svg', - title: { - content: 'DOM ↔ model selections match', - marks: [], - annotations: [] - }, - description: { - content: 'Avoids flaky mapping layers found in other editors.', - marks: [], - annotations: [] - } - }, - image_grid_item_5: { - id: 'image_grid_item_5', - type: 'image_grid_item', - image: '/images/cjk.svg', - title: { - content: 'Unicode‑safe, composition‑safe input', - marks: [], - annotations: [] - }, - description: { - content: 'Works correctly with emoji, diacritics, and CJK.', - marks: [], - annotations: [] - } - }, - image_grid_item_6: { - id: 'image_grid_item_6', - type: 'image_grid_item', - image: '/images/timetravel.svg', - title: { - content: 'Transactional editing with time travel', - marks: [], - annotations: [] - }, - description: { - content: 'Every change is safe and undoable.', - marks: [], - annotations: [] - } - }, - image_grid_1: { - id: 'image_grid_1', - type: 'image_grid', - layout: 1, - image_grid_items: { - nodes: [ - 'image_grid_item_1', - 'image_grid_item_2', - 'image_grid_item_3', - 'image_grid_item_4', - 'image_grid_item_5', - 'image_grid_item_6' - ], - marks: [], - annotations: [] - } - }, - story_3: { - id: 'story_3', - type: 'story', - layout: 1, - image: '/images/nested-blocks-illustration.svg', - title: { - content: 'Nested nodes', - marks: [], - annotations: [] - }, - description: { - content: - 'A node can embed a node_array of other nodes. For instance the list node at the bottom of the page has a node_array of list items.', - marks: [], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - story_4: { - id: 'story_4', - type: 'story', - layout: 2, - image: '/images/node-carets.svg', - title: { - content: 'Node carets', - marks: [], - annotations: [] - }, - description: { - content: - 'They work just like text carets, but instead of a character position in a string they address a node position in a node_array.\n\nTry it by selecting one of the gaps between the nodes. Then press ↵ to insert a new node or ⌫ to delete the node before the caret.', - marks: [], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - link_2: { - id: 'link_2', - type: 'link', - href: 'https://svelte.dev' - }, - emphasis_1: { - id: 'emphasis_1', - type: 'emphasis' - }, - story_5: { - id: 'story_5', - type: 'story', - layout: 1, - image: '/images/svelte-logo.svg', - title: { - content: 'Made for Svelte 5', - marks: [], - annotations: [] - }, - description: { - content: - 'Integrate with your Svelte application. Use it as a template and copy and paste Svedit.svelte to build your custom rich content editor.', - marks: [ - { - start_offset: 20, - end_offset: 26, - node_id: 'link_2' - }, - { - start_offset: 80, - end_offset: 93, - node_id: 'emphasis_1' - } - ], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - story_6: { - id: 'story_6', - type: 'story', - layout: 2, - image: '/images/extendable.svg', - title: { - content: 'Alpha version', - marks: [], - annotations: [] - }, - description: { - content: - "Expect bugs. Expect missing features. Expect the need for more work on your part to make this work for your use case.\n\nFind below a list of known issues we'll be working to get fixed next:", - marks: [], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - list_item_1: { - id: 'list_item_1', - type: 'list_item', - content: { - content: - "It's a bit hard to select whole lists or image grids with the mouse still. We're looking to improve this. However, by pressing the ESC key (or CMD+A) several times you can reach parent nodes easily.", - marks: [], - annotations: [] - } - }, - list_item_2: { - id: 'list_item_2', - type: 'list_item', - content: { - content: - 'Copy and pasting from and to external sources is working in principle, but is only capturing plain text so far.', - marks: [], - annotations: [] - } - }, - list_item_3: { - id: 'list_item_3', - type: 'list_item', - content: { - content: - 'Works best in Chrome, Safari 26+, and Firefox 157+, as Svedit uses CSS Anchor Positioning for overlays.', - marks: [], - annotations: [] - } - }, - list_item_4: { - id: 'list_item_4', - type: 'list_item', - content: { - content: - "Mobile support ist still experimental. As of 0.3.0 Svedit works on latest iOS and Android, but the UX isn't optimized yet.", - marks: [], - annotations: [] - } - }, - list_1: { - id: 'list_1', - type: 'list', - list_items: { - nodes: ['list_item_1', 'list_item_2', 'list_item_3', 'list_item_4'], - marks: [], - annotations: [] - }, - layout: 3 - }, - link_4: { - id: 'link_4', - type: 'link', - href: 'https://michaelaufreiter.com' - }, - link_5: { - id: 'link_5', - type: 'link', - href: 'https://mutter.co' - }, - VgWNyDmWcpgtkHvZXhjYTPS: { - id: 'VgWNyDmWcpgtkHvZXhjYTPS', - type: 'link', - href: 'https://github.com/michael/svedit/' - }, - story_7: { - id: 'story_7', - type: 'story', - layout: 1, - image: '/images/github.svg', - title: { - content: 'Find us on GitHub', - marks: [], - annotations: [] - }, - description: { - content: - 'Thank you for all the stars on GitHub. Svedit is made by Michael Aufreiter and Johannes Mutter and is licensed under the MIT License.', - marks: [ - { - start_offset: 57, - end_offset: 74, - node_id: 'link_4' - }, - { - start_offset: 79, - end_offset: 94, - node_id: 'link_5' - }, - { - start_offset: 31, - end_offset: 37, - node_id: 'VgWNyDmWcpgtkHvZXhjYTPS' - } - ], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - section_1: { - id: 'section_1', - type: 'section' - }, - page_1: { - id: 'page_1', - type: 'page', - body: { - nodes: [ - 'hero_1', - 'heading_1', - 'paragraph_1', - 'story_1', - 'story_2', - 'image_grid_1', - 'story_3', - 'story_4', - 'story_5', - 'story_6', - 'list_1', - 'story_7' - ], - marks: [ - { - start_offset: 0, - end_offset: 1, - node_id: 'section_1' - } - ], - annotations: [] - }, - keywords: ['svelte', 'editor', 'rich content'], - daily_visitors: [10, 20, 30, 100], - created_at: '2025-05-30T10:39:59.987Z' - } - } -}; - -// App-specific config object, always available via doc.config for introspection -export const session_config = { - // Custom ID generator function - generate_id: nanoid, - // Provide overrides for system components (node_gap, node_gap_markers, - // node_selection_markers) or user-land overlays (link previews, etc.) - system_components: { - overlays: Overlays - }, - // Registry of components for each node type - node_components: { - page: Page, - button: Button, - paragraph: Paragraph, - heading_1: Heading1, - heading_2: Heading2, - heading_3: Heading3, - story: Story, - list: List, - list_item: ListItem, - image_grid: ImageGrid, - image_grid_item: ImageGridItem, - hero: Hero, - strong: Strong, - emphasis: Emphasis, - highlight: Highlight, - link: Link, - section: Section - // NOTE: `marker` must not have a component: it is an annotation, so it - // is data-only and may overlap marks (e.g. a section) and other - // annotations. Covered node wrappers get `anno-marker` classes - // automatically — see styles/annotations.css for how it's styled with - // pure CSS. - }, - // Toggle view classes for the editor. On by default. - view_classes: true, - handle_property_deletion: (tr, path) => { - const property_definition = tr.inspect(path); - if (property_definition?.type !== 'string' || property_definition?.name !== 'image') return; - tr.set(path, ''); - }, - handle_media_paste: async (doc, pasted_media) => { - // ATTENTION: In a real-world-app, you may want to upload `pasted_media` here, - // before referencing them from the document. - - if (doc.selection.type === 'property') { - const property_definition = doc.inspect(doc.selection.path); - if (property_definition.name === 'image') { - const tr = doc.tr; - tr.set(doc.selection.path, pasted_media[0].data_url); - doc.apply(tr); - } - return null; - } else { - const pasted_json = { main_nodes: [], nodes: {} }; - // When caret inside an image grid we want to insert an image_grid_item - // otherwise we want to insert a story, as that is the only body node, - // that can carry an image. - let target_node_type; - if (doc.can_insert('image_grid_item')) { - target_node_type = 'image_grid_item'; + if (doc.selection.type === 'property') { + const property_definition = doc.inspect(doc.selection.path); + if (property_definition.name === 'image') { + const tr = doc.tr; + tr.set(doc.selection.path, pasted_media[0].data_url); + doc.apply(tr); + } + return null; } else { - target_node_type = 'story'; - } - for (let i = 0; i < pasted_media.length; i++) { - const pasted_image = pasted_media[i]; - pasted_json.nodes['node_' + i] = { - id: 'node_' + i, - type: target_node_type, - image: pasted_image.data_url - }; - pasted_json.main_nodes.push('node_' + i); + const pasted_json = { main_nodes: [], nodes: {} }; + // When caret inside an image grid we want to insert an image_grid_item + // otherwise we want to insert a story, as that is the only body node, + // that can carry an image. + let target_node_type; + if (doc.can_insert('image_grid_item')) { + target_node_type = 'image_grid_item'; + } else { + target_node_type = 'story'; + } + for (let i = 0; i < pasted_media.length; i++) { + const pasted_image = pasted_media[i]; + pasted_json.nodes['node_' + i] = { + id: 'node_' + i, + type: target_node_type, + image: pasted_image.data_url + }; + pasted_json.main_nodes.push('node_' + i); + } + return pasted_json; } - return pasted_json; } - }, - - // HTML exporters for different node types - html_exporters: { - hero: (node) => { - let html = ''; - if (node.title.content.trim()) { - html += `

    ${node.title.content}

    \n`; - } - if (node.description.content.trim()) { - html += `

    ${node.description.content}

    \n`; - } - return html; - }, - - list: (node, doc, html_exporters) => { - const { list_item } = html_exporters; - let html = '
      \n'; - for (const list_item_id of node.list_items.nodes) { - html += list_item(doc.get(list_item_id), doc, html_exporters); - } - return html + '
    '; - }, - story: (node, doc, html_exporters) => { - const { button } = html_exporters; - let html = ''; - if (node.image) { - html += `${node.title.content}\n`; - } - html += `

    ${node.title.content}

    \n`; - if (node.description) { - html += `

    ${node.description.content}

    \n`; - } - for (const button_id of node.buttons.nodes) { - html += button(doc.get(button_id), doc, html_exporters); - } - return html; - }, - paragraph: (node) => { - return `

    ${node.content.content}

    \n`; - }, - heading_1: (node) => { - return `

    ${node.content.content}

    \n`; - }, - heading_2: (node) => { - return `

    ${node.content.content}

    \n`; - }, - heading_3: (node) => { - return `

    ${node.content.content}

    \n`; - }, - button: (node) => { - return `${node.content.content}\n`; - }, - image_grid_item: (node) => { - let html = '
    \n'; - if (node.image) { - html += `${node.title.content}\n`; - } - if (node.title.content.trim()) { - html += `

    ${node.title.content}

    \n`; - } - if (node.description.content.trim()) { - html += `

    ${node.description.content}

    \n`; - } - return html + '
    '; - }, - list_item: (node) => { - const content = - typeof node.content === 'object' && node.content.content - ? node.content.content - : node.content || ''; - return `
  • ${content}
  • \n`; - } - }, - node_layouts: { - button: 1, - paragraph: 1, - heading_1: 1, - heading_2: 1, - heading_3: 1, - story: 3, - list: 5, - list_item: 1, - image_grid: 1, - hero: 1 - }, - // Custom functions to insert new "blank" nodes and setting the selection depening on the - // intended behavior. - inserters: { - paragraph: function (tr, content, layout) { - const new_paragraph = { - id: nanoid(), - type: 'paragraph' - }; - if (content !== undefined) new_paragraph.content = content; - if (layout !== undefined) new_paragraph.layout = layout; - tr.create(new_paragraph); - tr.insert_nodes([new_paragraph.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - heading_1: function (tr, content, layout) { - const new_heading_1 = { - id: nanoid(), - type: 'heading_1' - }; - if (content !== undefined) new_heading_1.content = content; - if (layout !== undefined) new_heading_1.layout = layout; - tr.create(new_heading_1); - tr.insert_nodes([new_heading_1.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - heading_2: function (tr, content, layout) { - const new_heading_2 = { - id: nanoid(), - type: 'heading_2' - }; - if (content !== undefined) new_heading_2.content = content; - if (layout !== undefined) new_heading_2.layout = layout; - tr.create(new_heading_2); - tr.insert_nodes([new_heading_2.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - heading_3: function (tr, content, layout) { - const new_heading_3 = { - id: nanoid(), - type: 'heading_3' - }; - if (content !== undefined) new_heading_3.content = content; - if (layout !== undefined) new_heading_3.layout = layout; - tr.create(new_heading_3); - tr.insert_nodes([new_heading_3.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - story: function (tr) { - const new_button = { - id: nanoid(), - type: 'button' - }; - tr.create(new_button); - const new_story = { - id: nanoid(), - type: 'story', - buttons: { nodes: [new_button.id], marks: [], annotations: [] } - }; - tr.create(new_story); - tr.insert_nodes([new_story.id]); - }, - list: function (tr) { - const new_list_item = { - id: nanoid(), - type: 'list_item' - }; - tr.create(new_list_item); - const new_list = { - id: nanoid(), - type: 'list', - list_items: { nodes: [new_list_item.id], marks: [], annotations: [] }, - layout: 3 - }; - tr.create(new_list); - tr.insert_nodes([new_list.id]); - }, - list_item: function (tr, content) { - const new_list_item = { - id: nanoid(), - type: 'list_item' - }; - if (content !== undefined) new_list_item.content = content; - tr.create(new_list_item); - tr.insert_nodes([new_list_item.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - - image_grid: function (tr) { - const new_image_grid_items = []; - for (let i = 0; i < 6; i++) { - const image_grid_item = { - id: nanoid(), - type: 'image_grid_item' - }; - tr.create(image_grid_item); - new_image_grid_items.push(image_grid_item.id); - } - const new_image_grid = { - id: nanoid(), - type: 'image_grid', - image_grid_items: { nodes: new_image_grid_items, marks: [], annotations: [] } - }; - tr.create(new_image_grid); - tr.insert_nodes([new_image_grid.id]); - }, - image_grid_item: function (tr) { - const new_image_grid_item = { - id: nanoid(), - type: 'image_grid_item' - }; - tr.create(new_image_grid_item); - tr.insert_nodes([new_image_grid_item.id]); - tr.set_selection({ - type: 'node', - path: [...tr.selection.path], - anchor_offset: tr.selection.focus_offset, - focus_offset: tr.selection.focus_offset - }); - }, - button: function (tr, content) { - const new_button = { - id: nanoid(), - type: 'button' - }; - if (content !== undefined) new_button.content = content; - tr.create(new_button); - tr.insert_nodes([new_button.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - hero: function (tr) { - const new_hero = { - id: nanoid(), - type: 'hero' - }; - tr.create(new_hero); - tr.insert_nodes([new_hero.id]); - } - }, - - /** - * Factory function to create Svedit commands and keymap. - * Called by Svedit component with the svedit context. - * - * @param {object} context - The svedit context with doc, editable, canvas. - * @returns {{ commands: object, keymap: object }} - */ - create_commands_and_keymap: (context) => { - // Create command instances with the provided context - const commands = { - select_all: new SelectAllCommand(context), - insert_default_node: new InsertDefaultNodeCommand(context), - add_new_line: new AddNewLineCommand(context), - break_text_node: new BreakTextNodeCommand(context), - toggle_strong: new ToggleMarkCommand('strong', context), - toggle_emphasis: new ToggleMarkCommand('emphasis', context), - toggle_highlight: new ToggleMarkCommand('highlight', context), - toggle_link: new ToggleLinkCommand(context), - toggle_section: new ToggleMarkCommand('section', context), - // Annotations only compete with same-type annotations, so the - // marker toggle never conflicts with sections or other marks. - toggle_marker: new ToggleAnnotationCommand('marker', context), - undo: new UndoCommand(context), - redo: new RedoCommand(context), - select_parent: new SelectParentCommand(context), - next_layout: new CycleLayoutCommand('next', context), - previous_layout: new CycleLayoutCommand('previous', context), - next_type: new CycleNodeTypeCommand('next', context), - previous_type: new CycleNodeTypeCommand('previous', context) - }; - - // Define keymap binding keys to commands - const keymap = define_keymap({ - 'meta+a,ctrl+a': [commands.select_all], - enter: [commands.break_text_node, commands.add_new_line, commands.insert_default_node], - 'shift+enter': [commands.add_new_line], - 'meta+b,ctrl+b': [commands.toggle_strong], - 'meta+i,ctrl+i': [commands.toggle_emphasis], - 'meta+u,ctrl+u': [commands.toggle_highlight], - 'meta+k,ctrl+k': [commands.toggle_link], - 'meta+shift+s,ctrl+shift+s': [commands.toggle_section], - 'meta+shift+m,ctrl+shift+m': [commands.toggle_marker], - 'meta+z,ctrl+z': [commands.undo], - 'meta+shift+z,ctrl+shift+z': [commands.redo], - escape: [commands.select_parent], - 'ctrl+shift+arrowright': [commands.next_layout], - 'ctrl+shift+arrowleft': [commands.previous_layout], - 'ctrl+shift+arrowdown': [commands.next_type], - 'ctrl+shift+arrowup': [commands.previous_type] - }); - - return { commands, keymap }; } -}; +); + +export const document_schema = schema; +export const session_config = config; export default function create_demo_session() { const demo_doc = fill_document_defaults(doc, document_schema); diff --git a/src/routes/demo_doc.js b/src/routes/demo_doc.js new file mode 100644 index 00000000..b0863fe6 --- /dev/null +++ b/src/routes/demo_doc.js @@ -0,0 +1,477 @@ +// The demo document: a plain JS object with a document_id and a flat map of +// nodes. See the "Document" section in the README for the format. +export default { + document_id: 'page_1', + nodes: { + zXfRSCtGrGWaBCpaWdBvdZG: { + id: 'zXfRSCtGrGWaBCpaWdBvdZG', + type: 'link', + href: 'https://editable.website' + }, + hero_1: { + id: 'hero_1', + type: 'hero', + layout: 1, + title: { + content: 'Svedit', + marks: [], + annotations: [] + }, + description: { + content: 'A tiny library for building editable websites in Svelte.', + marks: [ + { + start_offset: 28, + end_offset: 36, + node_id: 'zXfRSCtGrGWaBCpaWdBvdZG' + } + ], + annotations: [] + }, + image: '' + }, + heading_1: { + id: 'heading_1', + type: 'heading_1', + layout: 1, + content: { + content: 'Text and structured content in symbiosis', + marks: [], + annotations: [] + } + }, + paragraph_1: { + id: 'paragraph_1', + type: 'paragraph', + layout: 1, + content: { + content: + "Unlike most rich text editors, Svedit isn't restricted to a linear character-based model for addressing content and caret positions. For that reason we can combine text-ish content like a paragraph or heading with structured, form-like content.", + marks: [], + annotations: [] + } + }, + JRZfuWeXnWKnWHaNtjupSsX: { + id: 'JRZfuWeXnWKnWHaNtjupSsX', + type: 'button', + content: { + content: 'Get started', + marks: [], + annotations: [] + }, + href: 'https://github.com/michael/svedit' + }, + story_1: { + id: 'story_1', + type: 'story', + layout: 1, + image: '/images/editable.svg', + title: { + content: 'Visual in‑place editing', + marks: [], + annotations: [] + }, + description: { + content: + 'Model your content in JSON, render it with Svelte components, and edit content directly in the layout. You only have to follow a couple of rules to make this work.', + marks: [], + annotations: [] + }, + buttons: { + nodes: ['JRZfuWeXnWKnWHaNtjupSsX'], + marks: [], + annotations: [] + } + }, + link_1: { + id: 'link_1', + type: 'link', + href: 'https://editable.website' + }, + story_2: { + id: 'story_2', + type: 'story', + layout: 2, + image: '/images/lightweight.svg', + title: { + content: 'Minimal viable editor', + marks: [], + annotations: [] + }, + description: { + content: + "The reference implementation uses only about 2000 lines of code. That means you'll be able to serve editable web pages, removing the need for a separate Content Management System.", + marks: [ + { + start_offset: 100, + end_offset: 118, + node_id: 'link_1' + } + ], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + image_grid_item_1: { + id: 'image_grid_item_1', + type: 'image_grid_item', + image: '/images/svelte-framework.svg', + title: { + content: 'Svelte-native editing', + marks: [], + annotations: [] + }, + description: { + content: "No mingling with 3rd-party rendering API's.", + marks: [], + annotations: [] + } + }, + image_grid_item_2: { + id: 'image_grid_item_2', + type: 'image_grid_item', + image: '/images/annotations.svg', + title: { + content: 'Marks and annotations', + marks: [], + annotations: [] + }, + description: { + content: + 'Wrap ranges in marks (e.g. links) and attach annotations (e.g. comments) at a separate layer.', + marks: [], + annotations: [] + } + }, + image_grid_item_3: { + id: 'image_grid_item_3', + type: 'image_grid_item', + image: '/images/graphmodel.svg', + title: { + content: 'Graph‑first content with nested nodes', + marks: [], + annotations: [] + }, + description: { + content: + 'From simple paragraphs to complex nodes with nested arrays and multiple properties.', + marks: [], + annotations: [] + } + }, + image_grid_item_4: { + id: 'image_grid_item_4', + type: 'image_grid_item', + image: '/images/dom-synced.svg', + title: { + content: 'DOM ↔ model selections match', + marks: [], + annotations: [] + }, + description: { + content: 'Avoids flaky mapping layers found in other editors.', + marks: [], + annotations: [] + } + }, + image_grid_item_5: { + id: 'image_grid_item_5', + type: 'image_grid_item', + image: '/images/cjk.svg', + title: { + content: 'Unicode‑safe, composition‑safe input', + marks: [], + annotations: [] + }, + description: { + content: 'Works correctly with emoji, diacritics, and CJK.', + marks: [], + annotations: [] + } + }, + image_grid_item_6: { + id: 'image_grid_item_6', + type: 'image_grid_item', + image: '/images/timetravel.svg', + title: { + content: 'Transactional editing with time travel', + marks: [], + annotations: [] + }, + description: { + content: 'Every change is safe and undoable.', + marks: [], + annotations: [] + } + }, + image_grid_1: { + id: 'image_grid_1', + type: 'image_grid', + layout: 1, + image_grid_items: { + nodes: [ + 'image_grid_item_1', + 'image_grid_item_2', + 'image_grid_item_3', + 'image_grid_item_4', + 'image_grid_item_5', + 'image_grid_item_6' + ], + marks: [], + annotations: [] + } + }, + story_3: { + id: 'story_3', + type: 'story', + layout: 1, + image: '/images/nested-blocks-illustration.svg', + title: { + content: 'Nested nodes', + marks: [], + annotations: [] + }, + description: { + content: + 'A node can embed a node_array of other nodes. For instance the list node at the bottom of the page has a node_array of list items.', + marks: [], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + story_4: { + id: 'story_4', + type: 'story', + layout: 2, + image: '/images/node-carets.svg', + title: { + content: 'Node carets', + marks: [], + annotations: [] + }, + description: { + content: + 'They work just like text carets, but instead of a character position in a string they address a node position in a node_array.\n\nTry it by selecting one of the gaps between the nodes. Then press ↵ to insert a new node or ⌫ to delete the node before the caret.', + marks: [], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + link_2: { + id: 'link_2', + type: 'link', + href: 'https://svelte.dev' + }, + emphasis_1: { + id: 'emphasis_1', + type: 'emphasis' + }, + story_5: { + id: 'story_5', + type: 'story', + layout: 1, + image: '/images/svelte-logo.svg', + title: { + content: 'Made for Svelte 5', + marks: [], + annotations: [] + }, + description: { + content: + 'Integrate with your Svelte application. Use it as a template and copy and paste Svedit.svelte to build your custom rich content editor.', + marks: [ + { + start_offset: 20, + end_offset: 26, + node_id: 'link_2' + }, + { + start_offset: 80, + end_offset: 93, + node_id: 'emphasis_1' + } + ], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + story_6: { + id: 'story_6', + type: 'story', + layout: 2, + image: '/images/extendable.svg', + title: { + content: 'Alpha version', + marks: [], + annotations: [] + }, + description: { + content: + "Expect bugs. Expect missing features. Expect the need for more work on your part to make this work for your use case.\n\nFind below a list of known issues we'll be working to get fixed next:", + marks: [], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + list_item_1: { + id: 'list_item_1', + type: 'list_item', + content: { + content: + "It's a bit hard to select whole lists or image grids with the mouse still. We're looking to improve this. However, by pressing the ESC key (or CMD+A) several times you can reach parent nodes easily.", + marks: [], + annotations: [] + } + }, + list_item_2: { + id: 'list_item_2', + type: 'list_item', + content: { + content: + 'Copy and pasting from and to external sources is working in principle, but is only capturing plain text so far.', + marks: [], + annotations: [] + } + }, + list_item_3: { + id: 'list_item_3', + type: 'list_item', + content: { + content: + 'Works best in Chrome, Safari 26+, and Firefox 157+, as Svedit uses CSS Anchor Positioning for overlays.', + marks: [], + annotations: [] + } + }, + list_item_4: { + id: 'list_item_4', + type: 'list_item', + content: { + content: + "Mobile support ist still experimental. As of 0.3.0 Svedit works on latest iOS and Android, but the UX isn't optimized yet.", + marks: [], + annotations: [] + } + }, + list_1: { + id: 'list_1', + type: 'list', + list_items: { + nodes: ['list_item_1', 'list_item_2', 'list_item_3', 'list_item_4'], + marks: [], + annotations: [] + }, + layout: 3 + }, + link_4: { + id: 'link_4', + type: 'link', + href: 'https://michaelaufreiter.com' + }, + link_5: { + id: 'link_5', + type: 'link', + href: 'https://mutter.co' + }, + VgWNyDmWcpgtkHvZXhjYTPS: { + id: 'VgWNyDmWcpgtkHvZXhjYTPS', + type: 'link', + href: 'https://github.com/michael/svedit/' + }, + story_7: { + id: 'story_7', + type: 'story', + layout: 1, + image: '/images/github.svg', + title: { + content: 'Find us on GitHub', + marks: [], + annotations: [] + }, + description: { + content: + 'Thank you for all the stars on GitHub. Svedit is made by Michael Aufreiter and Johannes Mutter and is licensed under the MIT License.', + marks: [ + { + start_offset: 57, + end_offset: 74, + node_id: 'link_4' + }, + { + start_offset: 79, + end_offset: 94, + node_id: 'link_5' + }, + { + start_offset: 31, + end_offset: 37, + node_id: 'VgWNyDmWcpgtkHvZXhjYTPS' + } + ], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + section_1: { + id: 'section_1', + type: 'section' + }, + page_1: { + id: 'page_1', + type: 'page', + body: { + nodes: [ + 'hero_1', + 'heading_1', + 'paragraph_1', + 'story_1', + 'story_2', + 'image_grid_1', + 'story_3', + 'story_4', + 'story_5', + 'story_6', + 'list_1', + 'story_7' + ], + marks: [ + { + start_offset: 0, + end_offset: 1, + node_id: 'section_1' + } + ], + annotations: [] + }, + keywords: ['svelte', 'editor', 'rich content'], + daily_visitors: [10, 20, 30, 100], + created_at: '2025-05-30T10:39:59.987Z' + } + } +}; + +// App-specific config object, always available via doc.config for introspection diff --git a/src/test/compose.svelte.test.js b/src/test/compose.svelte.test.js new file mode 100644 index 00000000..1065db51 --- /dev/null +++ b/src/test/compose.svelte.test.js @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { compose } from '../lib/compose.js'; +import Session from '../lib/Session.svelte.js'; +import Command from '../lib/Command.svelte.js'; + +import Page from '../packages/page/Page.svelte'; +import Paragraph from '../packages/text_blocks/Paragraph.svelte'; + +class NoopCommand extends Command { + execute() {} +} + +const page_package = { + name: 'page', + schema: { + page: { + kind: 'document', + properties: { + body: { + type: 'node_array', + node_types: ['paragraph'], + default_node_type: 'paragraph' + } + } + } + }, + node_components: { page: Page } +}; + +const paragraph_package = { + name: 'paragraph', + schema: { + paragraph: { + kind: 'text', + properties: { + content: { type: 'text', allow_newlines: true } + } + } + }, + node_components: { paragraph: Paragraph }, + node_layouts: { paragraph: 1 } +}; + +describe('compose', () => { + it('merges schema and object-valued config registries', () => { + const { schema, config } = compose([page_package, paragraph_package], { + generate_id: () => 'id_1', + view_classes: false + }); + + expect(Object.keys(schema)).toEqual(['page', 'paragraph']); + expect(config.node_components.page).toBe(Page); + expect(config.node_components.paragraph).toBe(Paragraph); + // App-specific package registries merge like Svedit registries + expect(config.node_layouts).toEqual({ paragraph: 1 }); + // App-level scalars are set directly + expect(config.generate_id()).toBe('id_1'); + expect(config.view_classes).toBe(false); + }); + + it('merges custom package registries and app config registries', () => { + const { config } = compose( + [ + { name: 'a', node_layouts: { paragraph: 1 } }, + { name: 'b', node_layouts: { story: 3 } } + ], + { toolbar_groups: { text: ['paragraph'] } } + ); + + expect(config.node_layouts).toEqual({ paragraph: 1, story: 3 }); + expect(config.toolbar_groups).toEqual({ text: ['paragraph'] }); + }); + + it('throws on duplicate custom registry keys', () => { + expect(() => + compose([ + { name: 'a', node_layouts: { paragraph: 1 } }, + { name: 'b', node_layouts: { paragraph: 2 } } + ]) + ).toThrow("config.node_layouts key 'paragraph' from b conflicts with a"); + }); + + it('throws on duplicate schema keys across packages', () => { + expect(() => + compose([paragraph_package, { ...paragraph_package, name: 'other_paragraph' }]) + ).toThrow( + "schema key 'paragraph' from other_paragraph conflicts with paragraph" + ); + }); + + it('throws on duplicate registry keys and names the packages', () => { + const other = { + name: 'other', + node_components: { paragraph: Page } + }; + expect(() => compose([paragraph_package, other])).toThrow( + "config.node_components key 'paragraph' from other conflicts with paragraph" + ); + }); + + it('throws when a package has no name', () => { + expect(() => compose([/** @type {any} */ ({ schema: {} })])).toThrow( + 'package #1 must have a non-empty name' + ); + }); + + it('throws when two packages use the same name', () => { + expect(() => compose([{ name: 'a' }, { name: 'a' }])).toThrow( + "duplicate package name 'a'" + ); + }); + + it('throws on unknown package keys', () => { + expect(() => compose([/** @type {any} */ ({ name: 'a', view_classes: true })])).toThrow( + "unknown package key 'view_classes' in a" + ); + }); + + it('lets app config provide scalar keys', () => { + const { config } = compose([{ name: 'a' }], { view_classes: false }); + expect(config.view_classes).toBe(false); + }); + + it('builds create_commands_and_keymap from command factories and name-based keymaps', () => { + const package_a = { + name: 'a', + commands: (context) => ({ command_a: new NoopCommand(context) }), + keymap: { 'meta+j,ctrl+j': ['command_a'], enter: ['command_a'] } + }; + const package_b = { + name: 'b', + commands: (context) => ({ command_b: new NoopCommand(context) }), + // Same key from multiple packages concatenates in order + keymap: { enter: ['command_b'] } + }; + + const { config } = compose([package_a, package_b]); + const { commands, keymap } = config.create_commands_and_keymap({ session: null }); + + expect(Object.keys(commands)).toEqual(['command_a', 'command_b']); + expect(keymap['meta+j,ctrl+j']).toEqual([commands.command_a]); + expect(keymap['enter']).toEqual([commands.command_a, commands.command_b]); + }); + + it('throws on duplicate command names across packages', () => { + const a = { name: 'a', commands: (context) => ({ go: new NoopCommand(context) }) }; + const b = { name: 'b', commands: (context) => ({ go: new NoopCommand(context) }) }; + const { config } = compose([a, b]); + expect(() => config.create_commands_and_keymap({})).toThrow( + "commands key 'go' from b conflicts with a" + ); + }); + + it('throws when a keymap references an unknown command name', () => { + const a = { name: 'a', keymap: { enter: ['missing_command'] } }; + const { config } = compose([a]); + expect(() => config.create_commands_and_keymap({})).toThrow( + "keymap 'enter' references unknown command 'missing_command'" + ); + }); + + it('rejects mixing package commands with a custom create_commands_and_keymap', () => { + const a = { name: 'a', commands: (context) => ({ go: new NoopCommand(context) }) }; + expect(() => compose([a], { create_commands_and_keymap: () => ({}) })).toThrow( + 'not both' + ); + }); + + it('produces a config a Session can be constructed from', () => { + const { schema, config } = compose([page_package, paragraph_package], { + generate_id: () => `id_${Math.random().toString(36).slice(2)}` + }); + + const doc = { + document_id: 'page_1', + nodes: { + paragraph_1: { + id: 'paragraph_1', + type: 'paragraph', + content: { content: 'Hello', marks: [], annotations: [] } + }, + page_1: { + id: 'page_1', + type: 'page', + body: { nodes: ['paragraph_1'], marks: [], annotations: [] } + } + } + }; + + const session = new Session(schema, doc, config); + expect(session.get('paragraph_1').content.content).toBe('Hello'); + }); +}); diff --git a/src/test/create_test_session.js b/src/test/create_test_session.js index e91b35c8..54e0783b 100644 --- a/src/test/create_test_session.js +++ b/src/test/create_test_session.js @@ -3,15 +3,15 @@ import { define_document_schema } from '../lib/doc_utils.js'; import nanoid from '../routes/nanoid.js'; // Node components -import Page from '../routes/components/Page.svelte'; -import Story from '../routes/components/Story.svelte'; -import Button from '../routes/components/Button.svelte'; -import Paragraph from '../routes/components/Paragraph.svelte'; -import Heading1 from '../routes/components/Heading1.svelte'; -import Heading2 from '../routes/components/Heading2.svelte'; -import Heading3 from '../routes/components/Heading3.svelte'; -import List from '../routes/components/List.svelte'; -import ListItem from '../routes/components/ListItem.svelte'; +import Page from '../packages/page/Page.svelte'; +import Story from '../packages/story/Story.svelte'; +import Button from '../packages/story/Button.svelte'; +import Paragraph from '../packages/text_blocks/Paragraph.svelte'; +import Heading1 from '../packages/text_blocks/Heading1.svelte'; +import Heading2 from '../packages/text_blocks/Heading2.svelte'; +import Heading3 from '../packages/text_blocks/Heading3.svelte'; +import List from '../packages/list/List.svelte'; +import ListItem from '../packages/list/ListItem.svelte'; const document_schema = define_document_schema({ page: { @@ -306,7 +306,21 @@ const session_config = { } }; +function create_session_config() { + return { + ...session_config, + system_components: { ...session_config.system_components }, + node_components: { ...session_config.node_components }, + node_layouts: { ...session_config.node_layouts }, + inserters: { ...session_config.inserters } + }; +} + export default function create_test_session() { - const session = new Session(document_schema, doc, session_config); + const session = new Session( + structuredClone(document_schema), + structuredClone(doc), + create_session_config() + ); return session; } diff --git a/src/test/dev_ergonomics.svelte.test.js b/src/test/dev_ergonomics.svelte.test.js new file mode 100644 index 00000000..af655873 --- /dev/null +++ b/src/test/dev_ergonomics.svelte.test.js @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import create_test_session from './create_test_session.js'; +import { + check_config_completeness, + validate_document_schema +} from '../lib/doc_utils.js'; +import { insert_default_node, break_text_node } from '../lib/transforms.svelte.js'; + +// A fresh session whose shared module-level config is cloned, so per-test +// mutations (removing inserters/components) don't leak into other tests. +function create_isolated_session() { + const session = create_test_session(); + session.schema = structuredClone(session.schema); + session.config = { + ...session.config, + node_components: { ...session.config.node_components }, + inserters: { ...session.config.inserters } + }; + return session; +} + +describe('check_config_completeness', () => { + it('returns no warnings for a complete setup', () => { + const session = create_isolated_session(); + expect(check_config_completeness(session.schema, session.config)).toEqual([]); + }); + + it('warns about node types without a registered component', () => { + const session = create_isolated_session(); + delete session.config.node_components.story; + + const warnings = check_config_completeness(session.schema, session.config); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'story'"); + expect(warnings[0]).toContain('UnknownNode'); + }); + + it('does not require components for mark and annotation types', () => { + const session = create_isolated_session(); + session.schema.strong = { kind: 'mark', properties: {} }; + session.schema.comment = { kind: 'annotation', properties: {} }; + /** @type {any} */ (session.schema.story.properties.title).mark_types = ['strong']; + /** @type {any} */ (session.schema.story.properties.title).annotation_types = ['comment']; + + expect(check_config_completeness(session.schema, session.config)).toEqual([]); + }); + + it('warns about mark and annotation types no property allows', () => { + const session = create_isolated_session(); + session.schema.strong = { kind: 'mark', properties: {} }; + + const warnings = check_config_completeness(session.schema, session.config); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'strong'"); + expect(warnings[0]).toContain('never be applied'); + }); + + it('warns when a default node type cannot be auto-inserted and has no custom inserter', () => { + const session = create_isolated_session(); + // An asset reference without a default cannot be fabricated from schema + // defaults, so a paragraph without a custom inserter is not insertable. + session.schema.asset = { kind: 'block', properties: { url: { type: 'string' } } }; + session.config.node_components.asset = session.config.node_components.story; + session.schema.paragraph.properties.asset = { type: 'node', node_types: ['asset'] }; + delete session.config.inserters.paragraph; + + const warnings = check_config_completeness(session.schema, session.config); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'paragraph'"); + expect(warnings[0]).toContain("config.inserters['paragraph']"); + }); + + it('accepts a default node type covered by the generic inserter', () => { + const session = create_isolated_session(); + delete session.config.inserters.paragraph; + expect(check_config_completeness(session.schema, session.config)).toEqual([]); + }); +}); + +describe('schema validation of node_types kinds', () => { + it('rejects node_types referencing mark or annotation types', () => { + const session = create_isolated_session(); + const schema = structuredClone(session.schema); + schema.strong = { kind: 'mark', properties: {} }; + /** @type {any} */ (schema.page.properties.body).node_types = ['paragraph', 'strong']; + + expect(() => validate_document_schema(schema)).toThrow( + 'node_types must not reference mark or annotation types, got: strong' + ); + }); +}); + +describe('generic schema-driven inserter', () => { + it('insert_default_node inserts a schema-default node without a custom inserter', () => { + const session = create_isolated_session(); + delete session.config.inserters.paragraph; + + session.selection = { + type: 'node', + path: ['page_1', 'body'], + anchor_offset: 0, + focus_offset: 0 + }; + const tr = session.tr; + expect(insert_default_node(tr)).toBe(true); + session.apply(tr); + + const body = session.get(['page_1', 'body']); + expect(body.nodes).toHaveLength(4); + const new_node = session.get(body.nodes[0]); + expect(new_node.type).toBe('paragraph'); + expect(new_node.content).toEqual({ content: '', marks: [], annotations: [] }); + // The caret is placed at the start of the new node's content + expect(session.selection).toEqual({ + type: 'text', + path: ['page_1', 'body', 0, 'content'], + anchor_offset: 0, + focus_offset: 0 + }); + }); + + it('break_text_node splits without a custom inserter and carries the right part over', () => { + const session = create_isolated_session(); + delete session.config.inserters.list_item; + + // 'first list item' — split after 'first' + session.selection = { + type: 'text', + path: ['list_1', 'list_items', 0, 'content'], + anchor_offset: 5, + focus_offset: 5 + }; + const tr = session.tr; + expect(break_text_node(tr)).toBe(true); + session.apply(tr); + + const list_items = session.get(['list_1', 'list_items']); + expect(list_items.nodes).toHaveLength(3); + expect(session.get(list_items.nodes[0]).content.content).toBe('first'); + expect(session.get(list_items.nodes[1]).content.content).toBe(' list item'); + }); + + it('throws a hint naming config.inserters when defaults cannot construct the node', () => { + const session = create_isolated_session(); + session.schema.asset = { kind: 'block', properties: { url: { type: 'string' } } }; + session.schema.paragraph.properties.asset = { type: 'node', node_types: ['asset'] }; + delete session.config.inserters.paragraph; + + session.selection = { + type: 'node', + path: ['page_1', 'body'], + anchor_offset: 0, + focus_offset: 0 + }; + expect(() => insert_default_node(session.tr)).toThrow("config.inserters['paragraph']"); + }); +}); diff --git a/src/test/testing_components/SveditTest.svelte b/src/test/testing_components/SveditTest.svelte index 7a7a19be..a58d7f6c 100644 --- a/src/test/testing_components/SveditTest.svelte +++ b/src/test/testing_components/SveditTest.svelte @@ -1,6 +1,6 @@ diff --git a/src/test/testing_components/SveditTestWithKeymap.svelte b/src/test/testing_components/SveditTestWithKeymap.svelte index f04946cb..48de4bf9 100644 --- a/src/test/testing_components/SveditTestWithKeymap.svelte +++ b/src/test/testing_components/SveditTestWithKeymap.svelte @@ -2,7 +2,7 @@ import { setContext } from 'svelte'; import Svedit from '../../lib/Svedit.svelte'; import { KeyMapper } from '../../lib/index.js'; - import Layout from '../../routes/components/Layout.svelte'; + import Layout from '../../routes/Layout.svelte'; let { session } = $props();