Skip to content

Commit d6e9968

Browse files
authored
Page workflows (#153)
* Make PageUrlDialog available from page menu * Generalize page deletion flow * Duplicate page workflow * DuplicateNodesCommand * Focus rings fix * Fix focus ring for NodeNavigator * Properly handle toolbar clicks via keyboard and mouse * Make sure toolbar stays above the VirtualKeyboard * Hide variant selector on mobile, unless a single node is selected * Responsive variant selector * Ensure symmetric single-button toolbar
1 parent 9582000 commit d6e9968

21 files changed

Lines changed: 1196 additions & 337 deletions

ARCHITECTURE.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,43 @@ The home page is excluded from this mapping:
531531

532532
#### Custom slugs
533533

534-
Admins can change a page's Page URL from the page browser ellipsis menu.
534+
Admins can change a page's Page URL from the page browser ellipsis menu, and from
535+
the page-actions ellipsis menu in the browsing toolbar for the page they are on.
536+
537+
Both entry points share one implementation: `PageUrlDialog.svelte` owns the dialog
538+
and the `update_page_slug` call, and is rendered once by `Overlays.svelte`. Callers
539+
open it through the `page_url_dialog` context with the target page's
540+
`document_id` and current `page_href`, plus an optional `on_saved` hook for
541+
caller-specific cleanup. The home page has no editable slug, so the entry is
542+
disabled in both menus.
543+
544+
Deleting a page follows the same shape: `PageDeleteDialog.svelte` owns the
545+
confirmation and the `delete_page` call, opened through the `page_delete_dialog`
546+
context. Callers pass `is_current_page`, since deleting the page you are looking
547+
at has to navigate home afterwards rather than just refetching. The home page
548+
cannot be deleted, so that entry is disabled in both menus too.
549+
550+
#### Duplicating a page
551+
552+
Duplicate page navigates to `/new?from=<slug>`. The `/new` load resolves that
553+
slug to the page's own document — `get_page_document_for_duplicate`, admin-only,
554+
deliberately without the shared nav and footer stitched in — and the client builds
555+
the unsaved copy with `create_duplicate_doc`.
556+
557+
Every node in the source page gets a fresh id via `clone_subtree_with_new_ids`,
558+
which walks the same node/node_array/text references as the graph collector and
559+
rewrites them to the copies, so a saved duplicate shares no node ids with its
560+
original. Ids outside the copied set pass through untouched, which is what keeps
561+
the shared `nav` and `footer` as references rather than copies; those roots are
562+
excluded from the walk explicitly, since the stored page document does not
563+
contain their nodes and they would otherwise be remapped to ids that do not
564+
exist. The copy is then re-pointed at the current shared documents.
565+
566+
The home page has no slug row by invariant, so it is addressed as `?from=/` — the
567+
same way the page browser and `page_href` refer to it — and the query resolves
568+
that to the configured home page id. Duplicating the home page produces an
569+
ordinary new page; `home_page_id` is a site setting, so the copy does not become
570+
a second home.
535571

536572
When a user changes a page's slug:
537573

IMPLEMENTATION_PLAN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,8 @@ Required UI behavior:
501501
- link pickers must not expose drafts to unauthenticated users
502502
- toolbar actions that require admin auth must be hidden or disabled when unauthenticated
503503
- authenticated admins get an ellipsis page-actions menu in the browsing toolbar with Duplicate page, Edit URL, Delete page, and Logout entries
504-
- 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
504+
- Edit URL and Delete page open the same dialogs as the page browser ellipsis menu, and are disabled on the home page
505+
- Duplicate page navigates to `/new?from=<slug>`, which starts the new page as a copy of that page; the home page has no slug and is addressed as `?from=/`
505506

506507
### Page browser behavior
507508

src/app.html

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22
<html lang="en">
33
<head>
44
<meta charset="utf-8" />
5-
<meta name="viewport" content="width=device-width, initial-scale=1" />
5+
<!-- interactive-widget=resizes-content makes Chromium and Firefox shrink the
6+
layout viewport when the virtual keyboard opens, so a fixed bottom toolbar
7+
stays above it. iOS Safari ignores this and keeps the layout viewport at full
8+
height, which the visualViewport follower in Toolbar.svelte compensates for.
9+
maximum-scale=1 suppresses iOS input auto-zoom. It also disables pinch-zoom on
10+
browsers that honor it (iOS ignores it, Android Chrome does not), so drop it
11+
if that tradeoff is not wanted — it is unrelated to the keyboard handling. -->
12+
<meta
13+
name="viewport"
14+
content="width=device-width, initial-scale=1, maximum-scale=1, interactive-widget=resizes-content"
15+
/>
616

717
%sveltekit.head%
818
</head>

src/lib/api.remote.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,28 @@ export const get_document = query(v.string(), async (slug) => {
676676
};
677677
});
678678

679+
/**
680+
* Get a page's own document for duplication, without the shared nav and footer
681+
* stitched in. The caller rebuilds those references against the current shared
682+
* documents, so returning them here would only invite copying them by mistake.
683+
*/
684+
export const get_page_document_for_duplicate = query(v.string(), async (slug) => {
685+
require_admin_session(getRequestEvent().locals);
686+
687+
// The home page has no slug row by invariant, so it is addressed as `/` —
688+
// the same way the page browser and `page_href` refer to it.
689+
const document_id =
690+
slug === '/' ? get_home_page_id_from_db() : (resolve_slug(slug)?.document_id ?? null);
691+
692+
if (!document_id) {
693+
error(404, `Page not found for slug: ${slug}`);
694+
}
695+
696+
return {
697+
document: get_doc_from_db(document_id)
698+
};
699+
});
700+
679701
/**
680702
* Resolve the configured home page and return its stitched document.
681703
*/

src/lib/document_graph.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { clone_subtree_with_new_ids } from './document_graph.js';
3+
4+
function make_id_generator() {
5+
let counter = 0;
6+
return () => `new_${++counter}`;
7+
}
8+
9+
// A page with a body containing a prose block, a heading whose text carries a
10+
// link mark pointing at a link node, and shared nav/footer references that live
11+
// in other documents.
12+
function make_page_nodes() {
13+
return {
14+
page_1: {
15+
id: 'page_1',
16+
type: 'page',
17+
title: { content: 'Hello', marks: [], annotations: [] },
18+
description: { content: '', marks: [], annotations: [] },
19+
image: 'image_1',
20+
nav: 'nav_doc',
21+
footer: 'footer_doc',
22+
body: { nodes: ['prose_1'], marks: [], annotations: [] }
23+
},
24+
image_1: { id: 'image_1', type: 'image', src: '/a.png' },
25+
prose_1: {
26+
id: 'prose_1',
27+
type: 'prose',
28+
layout: 'narrow-left',
29+
body: { nodes: ['heading_1'], marks: [], annotations: [] }
30+
},
31+
heading_1: {
32+
id: 'heading_1',
33+
type: 'heading_1',
34+
content: {
35+
content: 'Linked',
36+
marks: [{ start_offset: 0, end_offset: 6, node_id: 'link_1' }],
37+
annotations: []
38+
}
39+
},
40+
link_1: { id: 'link_1', type: 'link', href: '/somewhere' }
41+
} as any;
42+
}
43+
44+
const shared_roots = new Set(['nav_doc', 'footer_doc']);
45+
46+
describe('clone_subtree_with_new_ids', () => {
47+
it('gives every copied node a fresh id', () => {
48+
const nodes = make_page_nodes();
49+
const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);
50+
51+
const source_ids = Object.keys(nodes);
52+
const cloned_ids = Object.keys(result.nodes);
53+
54+
expect(cloned_ids).toHaveLength(source_ids.length);
55+
for (const id of cloned_ids) {
56+
expect(source_ids).not.toContain(id);
57+
}
58+
// The stored key and the node's own id agree.
59+
for (const [id, node] of Object.entries(result.nodes)) {
60+
expect((node as any).id).toBe(id);
61+
}
62+
});
63+
64+
it('rewrites node and node_array references to the copies', () => {
65+
const nodes = make_page_nodes();
66+
const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);
67+
68+
const page = result.nodes[result.root_id] as any;
69+
const prose_id = page.body.nodes[0];
70+
const prose = result.nodes[prose_id] as any;
71+
72+
expect(result.nodes[page.image]).toBeDefined();
73+
expect(prose).toBeDefined();
74+
expect(result.nodes[prose.body.nodes[0]]).toBeDefined();
75+
});
76+
77+
it('rewrites mark references inside annotated text', () => {
78+
const nodes = make_page_nodes();
79+
const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);
80+
81+
const page = result.nodes[result.root_id] as any;
82+
const prose = result.nodes[page.body.nodes[0]] as any;
83+
const heading = result.nodes[prose.body.nodes[0]] as any;
84+
const link_id = heading.content.marks[0].node_id;
85+
86+
expect(link_id).not.toBe('link_1');
87+
expect(result.nodes[link_id]).toBeDefined();
88+
expect((result.nodes[link_id] as any).type).toBe('link');
89+
});
90+
91+
it('leaves excluded shared roots pointing at their own documents', () => {
92+
const nodes = make_page_nodes();
93+
const result = clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);
94+
95+
const page = result.nodes[result.root_id] as any;
96+
expect(page.nav).toBe('nav_doc');
97+
expect(page.footer).toBe('footer_doc');
98+
expect(result.nodes.nav_doc).toBeUndefined();
99+
expect(result.nodes.footer_doc).toBeUndefined();
100+
});
101+
102+
it('leaves the source document untouched', () => {
103+
const nodes = make_page_nodes();
104+
const before = structuredClone(nodes);
105+
clone_subtree_with_new_ids('page_1', nodes, make_id_generator(), shared_roots);
106+
expect(nodes).toEqual(before);
107+
});
108+
});

src/lib/document_graph.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,70 @@ function get_attached_ranges(
77
return [...(value?.marks ?? []), ...(value?.annotations ?? [])];
88
}
99

10+
/**
11+
* Deep-copy the subtree rooted at `root_id`, giving every copied node a fresh id
12+
* and rewriting the references between them.
13+
*
14+
* Ids outside the copied set are left untouched, so references that point at
15+
* other documents — a page's shared `nav` and `footer` — keep pointing there.
16+
* Pass those roots in `exclude_roots` so they are not treated as part of the
17+
* subtree; they live in their own documents and must not be duplicated.
18+
*/
19+
export function clone_subtree_with_new_ids(
20+
root_id: string,
21+
nodes: Record<string, DocumentNode>,
22+
generate_id: () => string,
23+
exclude_roots?: Set<string>
24+
): { root_id: string; nodes: Record<string, DocumentNode> } {
25+
const source_ids = collect_node_ids_in_order(root_id, nodes, exclude_roots);
26+
27+
const id_map = new Map<string, string>();
28+
for (const id of source_ids) {
29+
if (nodes[id]) id_map.set(id, generate_id());
30+
}
31+
32+
const map_id = (id: string) => id_map.get(id) ?? id;
33+
const cloned_nodes: Record<string, DocumentNode> = {};
34+
35+
for (const [source_id, new_id] of id_map) {
36+
const node = structuredClone(nodes[source_id]);
37+
node.id = new_id;
38+
39+
const type_schema: NodeSchema | undefined = document_schema[node.type];
40+
41+
for (const [prop_name, prop_def] of Object.entries<PropertyDefinition>(
42+
type_schema?.properties ?? {}
43+
)) {
44+
const value = node[prop_name];
45+
if (value == null) continue;
46+
47+
if (prop_def.type === 'node' && typeof value === 'string') {
48+
node[prop_name] = map_id(value);
49+
} else if (prop_def.type === 'node_array') {
50+
value.nodes = value.nodes.map(map_id);
51+
remap_attached_ranges(value, map_id);
52+
} else if (prop_def.type === 'text') {
53+
remap_attached_ranges(value, map_id);
54+
}
55+
}
56+
57+
cloned_nodes[new_id] = node;
58+
}
59+
60+
return { root_id: map_id(root_id), nodes: cloned_nodes };
61+
}
62+
63+
function remap_attached_ranges(
64+
value: { marks?: Attachment[]; annotations?: Attachment[] },
65+
map_id: (id: string) => string
66+
) {
67+
for (const range of get_attached_ranges(value)) {
68+
if (range.node_id) {
69+
range.node_id = map_id(range.node_id);
70+
}
71+
}
72+
}
73+
1074
/**
1175
* Collect all node ids reachable from a root node by walking node/node_array
1276
* properties and mark/annotation references, preserving first-seen traversal order.

src/lib/new_page.ts

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,70 @@
11
import nanoid from '../routes/nanoid.js';
22
import { MEDIA_DEFAULTS } from '$lib/config.js';
3+
import { clone_subtree_with_new_ids } from '$lib/document_graph.js';
34
import type { Document } from 'svedit';
45

6+
function get_shared_roots(shared_documents: { nav_document: Document; footer_document: Document }) {
7+
const nav_document = shared_documents?.nav_document;
8+
const footer_document = shared_documents?.footer_document;
9+
10+
if (!nav_document?.document_id || !nav_document?.nodes) {
11+
throw new Error('Missing nav document for new page creation');
12+
}
13+
14+
if (!footer_document?.document_id || !footer_document?.nodes) {
15+
throw new Error('Missing footer document for new page creation');
16+
}
17+
18+
return { nav_document, footer_document };
19+
}
20+
21+
/**
22+
* Create an unsaved copy of an existing page for `/new?from=<slug>`.
23+
*
24+
* Every node belonging to the source page gets a fresh id, so the copy shares no
25+
* ids with the original once saved. The shared nav and footer are referenced,
26+
* not copied — they belong to their own documents — and are re-pointed at the
27+
* current shared documents rather than whatever the source happened to reference.
28+
*/
29+
export function create_duplicate_doc(
30+
source_document: Document,
31+
shared_documents: { nav_document: Document; footer_document: Document }
32+
): Document {
33+
const { nav_document, footer_document } = get_shared_roots(shared_documents);
34+
35+
if (!source_document?.document_id || !source_document?.nodes) {
36+
throw new Error('Missing source document for page duplication');
37+
}
38+
39+
const source_root = source_document.nodes[source_document.document_id];
40+
41+
// The source page's own nodes are stored without the shared subtrees, so the
42+
// nav/footer references would otherwise be collected and remapped to ids that
43+
// do not exist.
44+
const shared_roots = new Set<string>();
45+
if (typeof source_root?.nav === 'string') shared_roots.add(source_root.nav);
46+
if (typeof source_root?.footer === 'string') shared_roots.add(source_root.footer);
47+
48+
const { root_id, nodes } = clone_subtree_with_new_ids(
49+
source_document.document_id,
50+
source_document.nodes,
51+
nanoid,
52+
shared_roots
53+
);
54+
55+
nodes[root_id].nav = nav_document.document_id;
56+
nodes[root_id].footer = footer_document.document_id;
57+
58+
return {
59+
document_id: root_id,
60+
nodes: {
61+
...structuredClone(nav_document.nodes),
62+
...structuredClone(footer_document.nodes),
63+
...nodes
64+
}
65+
};
66+
}
67+
568
/**
669
* Create a new unsaved page document for the `/new` route.
770
*
@@ -23,16 +86,7 @@ export function create_empty_doc(shared_documents: {
2386
const heading_id = nanoid();
2487
const paragraph_id = nanoid();
2588

26-
const nav_document = shared_documents?.nav_document;
27-
const footer_document = shared_documents?.footer_document;
28-
29-
if (!nav_document?.document_id || !nav_document?.nodes) {
30-
throw new Error('Missing nav document for new page creation');
31-
}
32-
33-
if (!footer_document?.document_id || !footer_document?.nodes) {
34-
throw new Error('Missing footer document for new page creation');
35-
}
89+
const { nav_document, footer_document } = get_shared_roots(shared_documents);
3690

3791
return {
3892
document_id: page_id,

0 commit comments

Comments
 (0)