diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5b29760b..8d796c82 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -531,7 +531,43 @@ The home page is excluded from this mapping: #### Custom slugs -Admins can change a page's Page URL from the page browser ellipsis menu. +Admins can change a page's Page URL from the page browser ellipsis menu, and from +the page-actions ellipsis menu in the browsing toolbar for the page they are on. + +Both entry points share one implementation: `PageUrlDialog.svelte` owns the dialog +and the `update_page_slug` call, and is rendered once by `Overlays.svelte`. Callers +open it through the `page_url_dialog` context with the target page's +`document_id` and current `page_href`, plus an optional `on_saved` hook for +caller-specific cleanup. The home page has no editable slug, so the entry is +disabled in both menus. + +Deleting a page follows the same shape: `PageDeleteDialog.svelte` owns the +confirmation and the `delete_page` call, opened through the `page_delete_dialog` +context. Callers pass `is_current_page`, since deleting the page you are looking +at has to navigate home afterwards rather than just refetching. The home page +cannot be deleted, so that entry is disabled in both menus too. + +#### Duplicating a page + +Duplicate page navigates to `/new?from=`. The `/new` load resolves that +slug to the page's own document — `get_page_document_for_duplicate`, admin-only, +deliberately without the shared nav and footer stitched in — and the client builds +the unsaved copy with `create_duplicate_doc`. + +Every node in the source page gets a fresh id via `clone_subtree_with_new_ids`, +which walks the same node/node_array/text references as the graph collector and +rewrites them to the copies, so a saved duplicate shares no node ids with its +original. Ids outside the copied set pass through untouched, which is what keeps +the shared `nav` and `footer` as references rather than copies; those roots are +excluded from the walk explicitly, since the stored page document does not +contain their nodes and they would otherwise be remapped to ids that do not +exist. The copy is then re-pointed at the current shared documents. + +The home page has no slug row by invariant, so it is addressed as `?from=/` — the +same way the page browser and `page_href` refer to it — and the query resolves +that to the configured home page id. Duplicating the home page produces an +ordinary new page; `home_page_id` is a site setting, so the copy does not become +a second home. When a user changes a page's slug: diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index dd2843b3..e39bf1ba 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -501,7 +501,8 @@ Required UI behavior: - link pickers must not expose drafts to unauthenticated users - toolbar actions that require admin auth must be hidden or disabled when unauthenticated - authenticated admins get an ellipsis page-actions menu in the browsing toolbar with Duplicate page, Edit URL, Delete page, and Logout entries -- keep the initial page-management entries presentational and retain the existing working Logout action; wire Duplicate page, Edit URL, and Delete page in a follow-up task +- Edit URL and Delete page open the same dialogs as the page browser ellipsis menu, and are disabled on the home page +- Duplicate page navigates to `/new?from=`, which starts the new page as a copy of that page; the home page has no slug and is addressed as `?from=/` ### Page browser behavior diff --git a/src/app.html b/src/app.html index 15fa6f2e..69f175d6 100644 --- a/src/app.html +++ b/src/app.html @@ -2,7 +2,17 @@ - + + %sveltekit.head% diff --git a/src/lib/api.remote.ts b/src/lib/api.remote.ts index 9b150883..c7595777 100644 --- a/src/lib/api.remote.ts +++ b/src/lib/api.remote.ts @@ -676,6 +676,28 @@ export const get_document = query(v.string(), async (slug) => { }; }); +/** + * Get a page's own document for duplication, without the shared nav and footer + * stitched in. The caller rebuilds those references against the current shared + * documents, so returning them here would only invite copying them by mistake. + */ +export const get_page_document_for_duplicate = query(v.string(), async (slug) => { + require_admin_session(getRequestEvent().locals); + + // The home page has no slug row by invariant, so it is addressed as `/` — + // the same way the page browser and `page_href` refer to it. + const document_id = + slug === '/' ? get_home_page_id_from_db() : (resolve_slug(slug)?.document_id ?? null); + + if (!document_id) { + error(404, `Page not found for slug: ${slug}`); + } + + return { + document: get_doc_from_db(document_id) + }; +}); + /** * Resolve the configured home page and return its stitched document. */ diff --git a/src/lib/document_graph.test.ts b/src/lib/document_graph.test.ts new file mode 100644 index 00000000..f4aa2aa3 --- /dev/null +++ b/src/lib/document_graph.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { clone_subtree_with_new_ids } from './document_graph.js'; + +function make_id_generator() { + let counter = 0; + return () => `new_${++counter}`; +} + +// A page with a body containing a prose block, a heading whose text carries a +// link mark pointing at a link node, and shared nav/footer references that live +// in other documents. +function make_page_nodes() { + return { + page_1: { + id: 'page_1', + type: 'page', + title: { content: 'Hello', marks: [], annotations: [] }, + description: { content: '', marks: [], annotations: [] }, + image: 'image_1', + nav: 'nav_doc', + footer: 'footer_doc', + body: { nodes: ['prose_1'], marks: [], annotations: [] } + }, + image_1: { id: 'image_1', type: 'image', src: '/a.png' }, + prose_1: { + id: 'prose_1', + type: 'prose', + layout: 'narrow-left', + body: { nodes: ['heading_1'], marks: [], annotations: [] } + }, + heading_1: { + id: 'heading_1', + type: 'heading_1', + content: { + content: 'Linked', + marks: [{ start_offset: 0, end_offset: 6, node_id: 'link_1' }], + annotations: [] + } + }, + link_1: { id: 'link_1', type: 'link', href: '/somewhere' } + } as any; +} + +const shared_roots = new Set(['nav_doc', 'footer_doc']); + +describe('clone_subtree_with_new_ids', () => { + it('gives every copied node a fresh id', () => { + const nodes = make_page_nodes(); + const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots); + + const source_ids = Object.keys(nodes); + const cloned_ids = Object.keys(result.nodes); + + expect(cloned_ids).toHaveLength(source_ids.length); + for (const id of cloned_ids) { + expect(source_ids).not.toContain(id); + } + // The stored key and the node's own id agree. + for (const [id, node] of Object.entries(result.nodes)) { + expect((node as any).id).toBe(id); + } + }); + + it('rewrites node and node_array references to the copies', () => { + const nodes = make_page_nodes(); + const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots); + + const page = result.nodes[result.root_id] as any; + const prose_id = page.body.nodes[0]; + const prose = result.nodes[prose_id] as any; + + expect(result.nodes[page.image]).toBeDefined(); + expect(prose).toBeDefined(); + expect(result.nodes[prose.body.nodes[0]]).toBeDefined(); + }); + + it('rewrites mark references inside annotated text', () => { + const nodes = make_page_nodes(); + const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots); + + const page = result.nodes[result.root_id] as any; + const prose = result.nodes[page.body.nodes[0]] as any; + const heading = result.nodes[prose.body.nodes[0]] as any; + const link_id = heading.content.marks[0].node_id; + + expect(link_id).not.toBe('link_1'); + expect(result.nodes[link_id]).toBeDefined(); + expect((result.nodes[link_id] as any).type).toBe('link'); + }); + + it('leaves excluded shared roots pointing at their own documents', () => { + const nodes = make_page_nodes(); + const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots); + + const page = result.nodes[result.root_id] as any; + expect(page.nav).toBe('nav_doc'); + expect(page.footer).toBe('footer_doc'); + expect(result.nodes.nav_doc).toBeUndefined(); + expect(result.nodes.footer_doc).toBeUndefined(); + }); + + it('leaves the source document untouched', () => { + const nodes = make_page_nodes(); + const before = structuredClone(nodes); + clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots); + expect(nodes).toEqual(before); + }); +}); diff --git a/src/lib/document_graph.ts b/src/lib/document_graph.ts index 704f09fa..7a7a1010 100644 --- a/src/lib/document_graph.ts +++ b/src/lib/document_graph.ts @@ -7,6 +7,70 @@ function get_attached_ranges( return [...(value?.marks ?? []), ...(value?.annotations ?? [])]; } +/** + * Deep-copy the subtree rooted at `root_id`, giving every copied node a fresh id + * and rewriting the references between them. + * + * Ids outside the copied set are left untouched, so references that point at + * other documents — a page's shared `nav` and `footer` — keep pointing there. + * Pass those roots in `exclude_roots` so they are not treated as part of the + * subtree; they live in their own documents and must not be duplicated. + */ +export function clone_subtree_with_new_ids( + root_id: string, + nodes: Record, + generate_id: () => string, + exclude_roots?: Set +): { root_id: string; nodes: Record } { + const source_ids = collect_node_ids_in_order(root_id, nodes, exclude_roots); + + const id_map = new Map(); + for (const id of source_ids) { + if (nodes[id]) id_map.set(id, generate_id()); + } + + const map_id = (id: string) => id_map.get(id) ?? id; + const cloned_nodes: Record = {}; + + for (const [source_id, new_id] of id_map) { + const node = structuredClone(nodes[source_id]); + node.id = new_id; + + const type_schema: NodeSchema | undefined = document_schema[node.type]; + + for (const [prop_name, prop_def] of Object.entries( + type_schema?.properties ?? {} + )) { + const value = node[prop_name]; + if (value == null) continue; + + if (prop_def.type === 'node' && typeof value === 'string') { + node[prop_name] = map_id(value); + } else if (prop_def.type === 'node_array') { + value.nodes = value.nodes.map(map_id); + remap_attached_ranges(value, map_id); + } else if (prop_def.type === 'text') { + remap_attached_ranges(value, map_id); + } + } + + cloned_nodes[new_id] = node; + } + + return { root_id: map_id(root_id), nodes: cloned_nodes }; +} + +function remap_attached_ranges( + value: { marks?: Attachment[]; annotations?: Attachment[] }, + map_id: (id: string) => string +) { + for (const range of get_attached_ranges(value)) { + if (range.node_id) { + range.node_id = map_id(range.node_id); + } + } +} + /** * Collect all node ids reachable from a root node by walking node/node_array * properties and mark/annotation references, preserving first-seen traversal order. diff --git a/src/lib/new_page.ts b/src/lib/new_page.ts index d96a9c44..6dfa7d88 100644 --- a/src/lib/new_page.ts +++ b/src/lib/new_page.ts @@ -1,7 +1,70 @@ import nanoid from '../routes/nanoid.js'; import { MEDIA_DEFAULTS } from '$lib/config.js'; +import { clone_subtree_with_new_ids } from '$lib/document_graph.js'; import type { Document } from 'svedit'; +function get_shared_roots(shared_documents: { nav_document: Document; footer_document: Document }) { + const nav_document = shared_documents?.nav_document; + const footer_document = shared_documents?.footer_document; + + if (!nav_document?.document_id || !nav_document?.nodes) { + throw new Error('Missing nav document for new page creation'); + } + + if (!footer_document?.document_id || !footer_document?.nodes) { + throw new Error('Missing footer document for new page creation'); + } + + return { nav_document, footer_document }; +} + +/** + * Create an unsaved copy of an existing page for `/new?from=`. + * + * Every node belonging to the source page gets a fresh id, so the copy shares no + * ids with the original once saved. The shared nav and footer are referenced, + * not copied — they belong to their own documents — and are re-pointed at the + * current shared documents rather than whatever the source happened to reference. + */ +export function create_duplicate_doc( + source_document: Document, + shared_documents: { nav_document: Document; footer_document: Document } +): Document { + const { nav_document, footer_document } = get_shared_roots(shared_documents); + + if (!source_document?.document_id || !source_document?.nodes) { + throw new Error('Missing source document for page duplication'); + } + + const source_root = source_document.nodes[source_document.document_id]; + + // The source page's own nodes are stored without the shared subtrees, so the + // nav/footer references would otherwise be collected and remapped to ids that + // do not exist. + const shared_roots = new Set(); + if (typeof source_root?.nav === 'string') shared_roots.add(source_root.nav); + if (typeof source_root?.footer === 'string') shared_roots.add(source_root.footer); + + const { root_id, nodes } = clone_subtree_with_new_ids( + source_document.document_id, + source_document.nodes, + nanoid, + shared_roots + ); + + nodes[root_id].nav = nav_document.document_id; + nodes[root_id].footer = footer_document.document_id; + + return { + document_id: root_id, + nodes: { + ...structuredClone(nav_document.nodes), + ...structuredClone(footer_document.nodes), + ...nodes + } + }; +} + /** * Create a new unsaved page document for the `/new` route. * @@ -23,16 +86,7 @@ export function create_empty_doc(shared_documents: { const heading_id = nanoid(); const paragraph_id = nanoid(); - const nav_document = shared_documents?.nav_document; - const footer_document = shared_documents?.footer_document; - - if (!nav_document?.document_id || !nav_document?.nodes) { - throw new Error('Missing nav document for new page creation'); - } - - if (!footer_document?.document_id || !footer_document?.nodes) { - throw new Error('Missing footer document for new page creation'); - } + const { nav_document, footer_document } = get_shared_roots(shared_documents); return { document_id: page_id, diff --git a/src/routes/commands.svelte.ts b/src/routes/commands.svelte.ts index 03514890..5f3943b9 100644 --- a/src/routes/commands.svelte.ts +++ b/src/routes/commands.svelte.ts @@ -344,3 +344,62 @@ export class EditLinkCommand extends Command { } } } + +/** + * Duplicate the selected nodes, following the same steps as copy → collapse the + * cursor at the end of the selection → paste, so the copies land directly after + * the originals. + * + * Text and property selections duplicate their closest parent node instead, so + * the command works from inside a node without selecting it first. + */ +export class DuplicateNodesCommand extends Command { + is_enabled() { + const selection = this.context.session.selection; + if (!selection) return false; + + // A collapsed node cursor sits between nodes — there is nothing to copy. + if (selection.type === 'node') return !is_selection_collapsed(selection); + + // Same condition select_parent() uses to decide whether a parent exists. + return selection.path.length > 3; + } + + execute() { + const { session } = this.context; + + // Anything that is not already a node selection duplicates its closest + // parent node, which select_parent() selects as a single-node range. + if (session.selection && session.selection.type !== 'node') { + session.select_parent(); + } + + const selection = session.selection; + if (selection?.type !== 'node' || is_selection_collapsed(selection)) return; + + // Read the payload before touching the selection — this is the same data + // the clipboard would carry for a node copy. + const payload = session.get_selected_annotated_nodes(); + if (!payload) return; + + const { nodes, main_nodes, marks, annotations } = payload; + const selection_end = Math.max(selection.anchor_offset, selection.focus_offset); + + const tr = session.tr; + // Collapsing is what makes this a duplicate rather than a replace: + // insert_nodes deletes a non-collapsed selection before inserting. + tr.set_selection({ + type: 'node', + path: selection.path, + anchor_offset: selection_end, + focus_offset: selection_end + }); + tr.insert_nodes( + main_nodes.map((node_id) => tr.build(node_id, nodes)), + marks, + annotations, + nodes + ); + session.apply(tr); + } +} diff --git a/src/routes/components/App.svelte b/src/routes/components/App.svelte index ae1a9f22..30dab509 100644 --- a/src/routes/components/App.svelte +++ b/src/routes/components/App.svelte @@ -10,6 +10,11 @@ import { create_session } from '../create_session.js'; import { create_page_browser, set_page_browser } from './page_browser_context.svelte.js'; import type { PageBrowser } from './page_browser_context.svelte.js'; + import { create_page_url_dialog, set_page_url_dialog } from './page_url_dialog_context.svelte.js'; + import { + create_page_delete_dialog, + set_page_delete_dialog + } from './page_delete_dialog_context.svelte.js'; import { demo_doc } from '$lib/demo_doc.js'; @@ -104,6 +109,10 @@ set_page_browser(page_browser); + set_page_url_dialog(create_page_url_dialog()); + + set_page_delete_dialog(create_page_delete_dialog()); + $effect(() => { document.documentElement.style.scrollBehavior = editable ? 'auto' : 'smooth'; }); diff --git a/src/routes/components/NodeNavigator.svelte b/src/routes/components/NodeNavigator.svelte index 21e2d98b..f4fb8777 100644 --- a/src/routes/components/NodeNavigator.svelte +++ b/src/routes/components/NodeNavigator.svelte @@ -135,12 +135,12 @@ {#if variant_item}
1 ? 'cursor-pointer hover:bg-(--muted)' : ''}" @@ -148,8 +148,8 @@ ? 'Choose variant · Type ⌃⇧↑/↓ · Layout ⌃⇧←/→' : undefined} > -
diff --git a/src/routes/components/PageDeleteDialog.svelte b/src/routes/components/PageDeleteDialog.svelte new file mode 100644 index 00000000..33c013e4 --- /dev/null +++ b/src/routes/components/PageDeleteDialog.svelte @@ -0,0 +1,213 @@ + + + + {#if target} +
{ + event.preventDefault(); + void confirm_delete(); + }} + > +

Delete page

+

{get_confirmation_message()}

+ {#if delete_error} + + {/if} +
+ + +
+
+ {/if} +
+ + diff --git a/src/routes/components/PageUrlDialog.svelte b/src/routes/components/PageUrlDialog.svelte new file mode 100644 index 00000000..a3714852 --- /dev/null +++ b/src/routes/components/PageUrlDialog.svelte @@ -0,0 +1,229 @@ + + + + {#if target} +
{ + event.preventDefault(); + void save(); + }} + > +

Edit URL

+
+ {app.origin || 'example.com'}/ + +
+ {#if page_url_error} + + {/if} +
+ + +
+
+ {/if} +
+ + diff --git a/src/routes/components/PagesDrawer.svelte b/src/routes/components/PagesDrawer.svelte index e7e817ad..8a22d83c 100644 --- a/src/routes/components/PagesDrawer.svelte +++ b/src/routes/components/PagesDrawer.svelte @@ -1,15 +1,16 @@ @@ -108,7 +238,8 @@ class="{TW_TOOLBAR_BTN} {session.commands.select_parent?.disabled ? TW_TOOLBAR_BTN_DISABLED : TW_TOOLBAR_BTN_HOVER}" - onmousedown={(e) => handle_btn_mousedown(e, session.commands.select_parent)} + onmousedown={handle_btn_mousedown} + onclick={(e) => handle_btn_click(e, session.commands.select_parent)} title="Select parent (Esc)" aria-label="Select parent" > @@ -149,11 +280,7 @@ > @@ -178,6 +305,7 @@ {#if editable || can_show_read_toolbar}
{#if editable && can_select_parent}
@@ -193,9 +321,10 @@ {@render selection_leading_contents()}
{/if} -
+ +
@@ -204,8 +333,11 @@
+
{#if !editable} @@ -307,6 +439,8 @@ class="page-actions-item" popovertarget="toolbar-page-actions-menu" popovertargetaction="hide" + onclick={duplicate_page} + disabled={!can_duplicate_page} role="menuitem">Duplicate page @@ -347,7 +485,8 @@ : TW_TOOLBAR_BTN_HOVER}" class:!text-(--svedit-editing-stroke)={session.commands.toggle_strong?.active} class:!bg-(--svedit-editing-fill)={session.commands.toggle_strong?.active} - onmousedown={(e) => handle_btn_mousedown(e, session.commands.toggle_strong)} + onmousedown={handle_btn_mousedown} + onclick={(e) => handle_btn_click(e, session.commands.toggle_strong)} title="Bold (⌘ B)" > handle_btn_mousedown(e, session.commands.toggle_emphasis)} + onmousedown={handle_btn_mousedown} + onclick={(e) => handle_btn_click(e, session.commands.toggle_emphasis)} title="Italic (⌘ I)" > handle_btn_mousedown(e, session.commands.toggle_code)} + onmousedown={handle_btn_mousedown} + onclick={(e) => handle_btn_click(e, session.commands.toggle_code)} title="Code (⌘ ⇧ C)" aria-label="Code" > @@ -430,7 +571,8 @@ class:!text-(--svedit-editing-stroke)={session.commands.toggle_highlight ?.active} class:!bg-(--svedit-editing-fill)={session.commands.toggle_highlight?.active} - onmousedown={(e) => handle_btn_mousedown(e, session.commands.toggle_highlight)} + onmousedown={handle_btn_mousedown} + onclick={(e) => handle_btn_click(e, session.commands.toggle_highlight)} title="Highlight (⌘ U)" > handle_btn_mousedown(e, session.commands.toggle_link)} + onmousedown={handle_btn_mousedown} + onclick={(e) => handle_btn_click(e, session.commands.toggle_link)} title="Link (⌘ K)" > @@ -502,7 +646,8 @@