@firedrill-tools/notion 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +402 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounded.scenario.json +19 -0
  6. package/firedrill/conformance.suite.json +23 -0
  7. package/firedrill/notion-bounded.drill.json +318 -0
  8. package/firedrill/notion-byte-budget.drill.json +116 -0
  9. package/firedrill/notion-mcp-aliases.drill.json +150 -0
  10. package/firedrill/notion-page-authoring.drill.json +254 -0
  11. package/firedrill/notion-rate-limited.drill.json +118 -0
  12. package/firedrill/notion-schema-growth.drill.json +88 -0
  13. package/firedrill/notion-scope-agent-only.drill.json +131 -0
  14. package/firedrill/notion-scope-auditor.drill.json +86 -0
  15. package/firedrill/notion-scope-board-bot.drill.json +128 -0
  16. package/firedrill/notion-scope-notes-bot.drill.json +303 -0
  17. package/firedrill/notion-scope-stranger.drill.json +773 -0
  18. package/firedrill/notion-task-triage.drill.json +277 -0
  19. package/firedrill/notion-trash-and-restore.drill.json +186 -0
  20. package/firedrill/notion-update-lost.drill.json +88 -0
  21. package/firedrill/notion-workspace-read.drill.json +258 -0
  22. package/firedrill/notion-write-unavailable.drill.json +161 -0
  23. package/firedrill/rate-limited.scenario.json +11 -0
  24. package/firedrill/tools/notion/app/assets/ATTRIBUTION.md +35 -0
  25. package/firedrill/tools/notion/app/assets/fonts/OFL.txt +93 -0
  26. package/firedrill/tools/notion/app/assets/fonts/inter-latin.woff2 +0 -0
  27. package/firedrill/tools/notion/app/assets/notion-wordmark.svg +1 -0
  28. package/firedrill/tools/notion/app/assets/notion.svg +1 -0
  29. package/firedrill/tools/notion/app/site/app.js +797 -0
  30. package/firedrill/tools/notion/app/site/assets/fonts/inter-latin.woff2 +0 -0
  31. package/firedrill/tools/notion/app/site/assets/notion-wordmark.svg +1 -0
  32. package/firedrill/tools/notion/app/site/assets/notion.svg +1 -0
  33. package/firedrill/tools/notion/app/site/chrome.js +104 -0
  34. package/firedrill/tools/notion/app/site/cover-picker.js +83 -0
  35. package/firedrill/tools/notion/app/site/database.js +648 -0
  36. package/firedrill/tools/notion/app/site/editors.js +320 -0
  37. package/firedrill/tools/notion/app/site/format-bar.js +97 -0
  38. package/firedrill/tools/notion/app/site/icons.js +131 -0
  39. package/firedrill/tools/notion/app/site/index.html +125 -0
  40. package/firedrill/tools/notion/app/site/page.js +826 -0
  41. package/firedrill/tools/notion/app/site/rich.js +159 -0
  42. package/firedrill/tools/notion/app/site/state.js +170 -0
  43. package/firedrill/tools/notion/app/site/styles.css +826 -0
  44. package/firedrill/tools/notion/app/site/ui.js +418 -0
  45. package/firedrill/tools/notion/behavior.mjs +1123 -0
  46. package/firedrill/tools/notion/lib/blocks.mjs +371 -0
  47. package/firedrill/tools/notion/lib/identity.mjs +123 -0
  48. package/firedrill/tools/notion/lib/ids.mjs +63 -0
  49. package/firedrill/tools/notion/lib/json-depth.mjs +26 -0
  50. package/firedrill/tools/notion/lib/markdown.mjs +381 -0
  51. package/firedrill/tools/notion/lib/properties.mjs +513 -0
  52. package/firedrill/tools/notion/lib/query.mjs +272 -0
  53. package/firedrill/tools/notion/lib/render.mjs +137 -0
  54. package/firedrill/tools/notion/lib/rich-text.mjs +134 -0
  55. package/firedrill/tools/notion/lib/size.mjs +44 -0
  56. package/firedrill/tools/notion/lib/state.mjs +192 -0
  57. package/firedrill/tools/notion/lib/wire.mjs +89 -0
  58. package/firedrill/tools/notion/notion.tool.json +9837 -0
  59. package/firedrill/update-lost.scenario.json +11 -0
  60. package/firedrill/world.json +7039 -0
  61. package/firedrill/write-unavailable.scenario.json +11 -0
  62. package/firedrill.json +5 -0
  63. package/package.json +63 -0
  64. package/starter.json +6482 -0
  65. package/test/conformance.mjs +1186 -0
@@ -0,0 +1,826 @@
1
+ // Page view: cover/icon/title, the property panel of database rows, page comments and the block editor.
2
+ // Every edit is a Tool operation (pages.update, blocks.update, blocks.children.append, blocks.delete,
3
+ // comments.create, pages.update-markdown, pages.move); the page is re-read after each write.
4
+ import { emptyState, openSidePanel, skeleton } from "./chrome.js";
5
+ import { renderInlineDatabase, openSchemaEditor } from "./database.js";
6
+ import { newBlockInput, openBlockMenu, openEmojiPicker, openPropertyEditor } from "./editors.js";
7
+ import { icon, propertyIcon } from "./icons.js";
8
+ import { checkbox, emojiOf, objectIcon, person, plain, propertyValue, richText, textToRich, userName } from "./rich.js";
9
+ import { canComment, canWrite, loadIndex, lookup, navigate, pageTitle, state } from "./state.js";
10
+ import { $, action, avatar, call, closePopovers, confirmDialog, describe, el, iconButton, key, notSimulated, openPopover, popoverOpen, relativeTime, shortDate, snackbar, textButton, ToolError, unsimulatedButton } from "./ui.js";
11
+ import { coverSeed, openCoverPicker, workspaceCovers } from "./cover-picker.js";
12
+ import { registerFormatTarget } from "./format-bar.js";
13
+
14
+ const TEXT_TYPES = ["paragraph", "heading_1", "heading_2", "heading_3", "bulleted_list_item", "numbered_list_item", "to_do", "toggle", "quote", "callout", "code"];
15
+ const PLACEHOLDER = { paragraph: "Write something, or press '/' for commands…", heading_1: "Heading 1", heading_2: "Heading 2", heading_3: "Heading 3", bulleted_list_item: "List", numbered_list_item: "List", to_do: "To-do", toggle: "Toggle", quote: "Quote", callout: "Callout", code: "Code" };
16
+
17
+ /**
18
+ * Render a page into `host`. Returns a controller with `refresh()` and `page`.
19
+ * `options.peek`: rendered inside the side peek (no top-level actions), `options.onChanged`: called after writes.
20
+ */
21
+ export function renderPageView(host, pageId, options = {}) {
22
+ const view = {
23
+ id: pageId,
24
+ page: undefined,
25
+ schema: undefined, // data source properties for database rows
26
+ blocks: [],
27
+ blocksCursor: null,
28
+ blocksMore: false,
29
+ children: new Map(), // block id → { blocks, more, cursor }
30
+ expanded: new Set(),
31
+ comments: [],
32
+ commentsError: undefined,
33
+ focusAfter: undefined,
34
+ error: undefined,
35
+ loading: true,
36
+ };
37
+ host.replaceChildren(el("div", { class: "page-shell" }, skeleton(8)));
38
+ view.refresh = () => load(host, view, options);
39
+ void view.refresh();
40
+ return view;
41
+ }
42
+
43
+ async function load(host, view, options) {
44
+ try {
45
+ view.page = await call("pages.retrieve", { page_id: view.id });
46
+ view.error = undefined;
47
+ if (view.page.parent?.type === "data_source_id") view.schema = (await call("data-sources.retrieve", { data_source_id: view.page.parent.data_source_id })).properties;
48
+ else view.schema = undefined;
49
+ const children = await call("blocks.children.list", { block_id: view.id, page_size: 30 });
50
+ view.blocks = children.results;
51
+ view.blocksCursor = children.next_cursor;
52
+ view.blocksMore = children.has_more;
53
+ view.children = new Map();
54
+ view.comments = [];
55
+ view.commentsError = undefined;
56
+ if (canComment("read")) {
57
+ try {
58
+ view.comments = await allComments(view.id);
59
+ } catch (error) {
60
+ view.commentsError = describe(error);
61
+ }
62
+ }
63
+ } catch (error) {
64
+ view.error = error;
65
+ }
66
+ view.loading = false;
67
+ render(host, view, options);
68
+ options.onLoaded?.(view);
69
+ }
70
+
71
+ const COMMENT_REQUESTS = 20;
72
+
73
+ /** Every comment of a block, paged by cursor; `comments.truncated` is set when the request cap trips. */
74
+ async function allComments(blockId) {
75
+ const comments = [];
76
+ let cursor;
77
+ for (let request = 0; ; request += 1) {
78
+ if (request === COMMENT_REQUESTS) {
79
+ comments.truncated = true;
80
+ break;
81
+ }
82
+ const page = await call("comments.list", { block_id: blockId, page_size: 100, ...(cursor ? { start_cursor: cursor } : {}) });
83
+ comments.push(...page.results);
84
+ if (!page.has_more) break;
85
+ cursor = page.next_cursor;
86
+ }
87
+ return comments;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------------------------
91
+ // Rendering
92
+ // ---------------------------------------------------------------------------------------------
93
+
94
+ function render(host, view, options) {
95
+ host.replaceChildren();
96
+ if (view.error) {
97
+ host.append(errorState(view.error));
98
+ return;
99
+ }
100
+ const page = view.page;
101
+ const shell = el("div", { class: `page-shell ${page.cover ? "has-cover" : ""} ${page.icon ? "has-icon" : ""}`.trim() });
102
+ if (page.cover) shell.append(coverBanner(page, view, options));
103
+ const content = el("div", { class: "page-content" });
104
+ if (page.in_trash) content.append(trashBanner(view, host, options));
105
+ content.append(pageHeader(view, host, options));
106
+ if (view.schema) content.append(propertyPanel(view, host, options));
107
+ content.append(commentsStrip(view, host, options));
108
+ content.append(blockList(view, host, options));
109
+ shell.append(content);
110
+ host.append(shell);
111
+ if (view.focusAfter) {
112
+ const target = host.querySelector(`[data-block-id="${view.focusAfter}"] .block-text`);
113
+ view.focusAfter = undefined;
114
+ if (target) {
115
+ target.focus();
116
+ placeCaretAtEnd(target);
117
+ }
118
+ }
119
+ }
120
+
121
+ function errorState(error) {
122
+ if (error instanceof ToolError && (error.denied || error.is("OBJECT_NOT_FOUND") || error.is("RESTRICTED_RESOURCE"))) {
123
+ const denied = error.denied || error.is("RESTRICTED_RESOURCE");
124
+ return el("div", { class: "page-shell" }, [
125
+ el("div", { class: "page-content" }, emptyState(denied ? "You don't have access to this page" : "This content doesn't exist or you don't have access.", denied ? error.message : "Make sure the page is shared with the integration, or open another page from the sidebar.", [textButton("Go to Home", { onClick: () => navigate({ name: "home" }) })])),
126
+ ]);
127
+ }
128
+ if (error instanceof ToolError && error.is("RATE_LIMITED")) {
129
+ return el("div", { class: "page-shell" }, [el("div", { class: "page-content" }, emptyState("You're being rate limited", "Notion asked us to slow down (429). Try again in a few seconds.", [textButton("Retry", { class: "primary", onClick: () => window.dispatchEvent(new HashChangeEvent("hashchange")) })]))]);
130
+ }
131
+ return el("div", { class: "page-shell" }, [el("div", { class: "page-content" }, emptyState("Something went wrong", describe(error), [textButton("Retry", { class: "primary", onClick: () => window.dispatchEvent(new HashChangeEvent("hashchange")) })]))]);
132
+ }
133
+
134
+ function coverBanner(page, view, options) {
135
+ // External cover images cannot be fetched inside the test environment; the product's gradient covers are drawn instead.
136
+ const banner = el("div", { class: "page-cover", attrs: { role: "img", "aria-label": "Page cover" } });
137
+ banner.dataset.seed = String(coverSeed(page.cover?.external?.url ?? page.id));
138
+ banner.append(el("span", { class: "cover-note", text: page.cover?.external?.url ?? "" }));
139
+ if (view && canWrite() && !page.in_trash) {
140
+ const change = textButton("Change cover", {});
141
+ change.addEventListener("click", () => openCoverPicker(change, page.cover?.external?.url, (url) => writeCover(view, options, url)));
142
+ const reposition = textButton("Reposition", {});
143
+ reposition.addEventListener("click", () => notSimulated(reposition, "Reposition", "Cover position is an app-only setting that the public API does not expose.", { align: "end" }));
144
+ banner.append(el("div", { class: "page-cover-actions" }, [change, reposition]));
145
+ }
146
+ return banner;
147
+ }
148
+
149
+ function writeCover(view, options, url) {
150
+ return action(async () => {
151
+ await call("pages.update", { page_id: view.id, cover: url === null ? null : { type: "external", external: { url } } }, key());
152
+ await afterWrite(view, options);
153
+ });
154
+ }
155
+
156
+ function trashBanner(view, host, options) {
157
+ const restore = textButton("Restore page", { class: "on-red", onClick: () => action(async () => {
158
+ await call("pages.update", { page_id: view.id, in_trash: false }, key());
159
+ snackbar("Page restored");
160
+ await afterWrite(view, options);
161
+ }) });
162
+ return el("div", { class: "trash-banner" }, [el("span", { text: "This page is in Trash." }), restore, el("span", { class: "trash-note", text: "Permanent deletion is not part of the public API." })]);
163
+ }
164
+
165
+ async function afterWrite(view, options) {
166
+ await loadIndex();
167
+ await view.refresh();
168
+ options.onChanged?.(view);
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------------------------
172
+ // Header: icon, title, hover controls
173
+ // ---------------------------------------------------------------------------------------------
174
+
175
+ function pageHeader(view, host, options) {
176
+ const page = view.page;
177
+ const writable = canWrite() && !page.in_trash;
178
+ const header = el("div", { class: "page-header" });
179
+ const emoji = emojiOf(page.icon);
180
+ const iconButtonEl = el("button", { class: `page-icon ${emoji === undefined ? "empty" : ""}`.trim(), attrs: { type: "button", "aria-label": emoji === undefined ? "Add icon" : "Change icon", "aria-expanded": "false" } });
181
+ if (emoji !== undefined) iconButtonEl.append(el("span", { class: "page-emoji", text: emoji }));
182
+ else if (page.icon?.type === "external") iconButtonEl.append(icon("page", "page-icon-glyph"));
183
+ iconButtonEl.disabled = !writable;
184
+ iconButtonEl.addEventListener("click", () => openEmojiPicker(iconButtonEl, emoji, (picked) => action(async () => {
185
+ await call("pages.update", { page_id: view.id, icon: picked === null ? null : { type: "emoji", emoji: picked } }, key());
186
+ await afterWrite(view, options);
187
+ })));
188
+ if (emoji !== undefined || page.icon?.type === "external") header.append(iconButtonEl);
189
+
190
+ const controls = el("div", { class: "page-controls" });
191
+ if (writable && emoji === undefined) controls.append(textButton("Add icon", { class: "ghost", icon: "star", onClick: () => openEmojiPicker(controls.firstChild, undefined, (picked) => action(async () => {
192
+ if (picked === null) return;
193
+ await call("pages.update", { page_id: view.id, icon: { type: "emoji", emoji: picked } }, key());
194
+ await afterWrite(view, options);
195
+ })) }));
196
+ if (writable && !page.cover) controls.append(textButton("Add cover", { class: "ghost", icon: "image", onClick: (event) => {
197
+ const used = workspaceCovers();
198
+ if (used.length > 0) void writeCover(view, options, used[0].url);
199
+ else openCoverPicker(event.currentTarget, undefined, (url) => writeCover(view, options, url), { startWith: "link" });
200
+ } }));
201
+ if (canComment("read_insert") && !page.in_trash) controls.append(textButton("Add comment", { class: "ghost", icon: "comment", onClick: () => host.querySelector(".comment-composer input")?.focus() }));
202
+ header.append(controls);
203
+
204
+ const title = el("h1", { class: `page-title ${pageTitle(page) === "Untitled" && plain(titleProperty(page)?.title) === "" ? "empty" : ""}`.trim(), attrs: { "aria-label": "Page title", "data-placeholder": "Untitled" } });
205
+ const text = plain(titleProperty(page)?.title);
206
+ title.textContent = text;
207
+ if (writable) {
208
+ title.setAttribute("contenteditable", "plaintext-only");
209
+ title.setAttribute("spellcheck", "false");
210
+ const commit = () => {
211
+ const next = title.textContent.replace(/\n/g, " ").trim();
212
+ if (next === text) return;
213
+ const name = titlePropertyName(view);
214
+ void action(async () => {
215
+ await call("pages.update", { page_id: view.id, properties: { [name]: { title: textToRich(next) } } }, key());
216
+ await afterWrite(view, options);
217
+ });
218
+ };
219
+ title.addEventListener("blur", commit);
220
+ title.addEventListener("keydown", (event) => {
221
+ if (event.key === "Enter") {
222
+ event.preventDefault();
223
+ title.blur();
224
+ const first = host.querySelector(".block .block-text");
225
+ first?.focus();
226
+ }
227
+ if (event.key === "Escape") {
228
+ title.textContent = text;
229
+ title.blur();
230
+ }
231
+ });
232
+ title.addEventListener("input", () => title.classList.toggle("empty", title.textContent.length === 0));
233
+ }
234
+ header.append(title);
235
+ return header;
236
+ }
237
+
238
+ /** Schema order with the computed (created / last edited) properties last, as a workspace member would arrange them. */
239
+ export function orderedDefinitions(definitions) {
240
+ const computed = ["created_time", "created_by", "last_edited_time", "last_edited_by"];
241
+ return [...definitions.filter((definition) => !computed.includes(definition.type)), ...definitions.filter((definition) => computed.includes(definition.type))];
242
+ }
243
+
244
+ function titleProperty(page) {
245
+ return Object.values(page.properties ?? {}).find((entry) => entry.type === "title");
246
+ }
247
+ function titlePropertyName(view) {
248
+ const entry = Object.entries(view.page.properties ?? {}).find(([, value]) => value.type === "title");
249
+ return entry ? entry[0] : "title";
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------------------------
253
+ // Property panel (database rows)
254
+ // ---------------------------------------------------------------------------------------------
255
+
256
+ function propertyPanel(view, host, options) {
257
+ const page = view.page;
258
+ const panel = el("div", { class: "property-panel", attrs: { role: "table", "aria-label": "Properties" } });
259
+ const maps = lookup();
260
+ const writable = canWrite() && !page.in_trash;
261
+ const definitions = Object.values(view.schema);
262
+ const ordered = orderedDefinitions(definitions).filter((definition) => definition.type !== "title");
263
+ for (const definition of ordered) {
264
+ const property = page.properties[definition.name];
265
+ if (!property) continue;
266
+ const row = el("div", { class: "property-row", attrs: { role: "row" } });
267
+ const name = el("div", { class: "property-name", attrs: { role: "rowheader" } }, [icon(propertyIcon(definition.type), "property-icon"), el("span", { text: definition.name })]);
268
+ const computed = ["created_time", "created_by", "last_edited_time", "last_edited_by"].includes(definition.type);
269
+ const value = el("button", { class: `property-value ${property[definition.type] === null || (Array.isArray(property[definition.type]) && property[definition.type].length === 0) || property[definition.type] === "" ? "empty" : ""}`.trim(), attrs: { type: "button", "aria-label": `${definition.name} value`, "aria-expanded": "false", role: "cell" } });
270
+ value.append(propertyValue(property, maps));
271
+ value.dataset.placeholder = "Empty";
272
+ if (definition.type === "checkbox") {
273
+ value.replaceChildren(checkbox(property.checkbox === true, { label: definition.name, onToggle: writable ? (next) => writeProperty(view, options, definition.name, { checkbox: next }) : undefined }));
274
+ } else if (computed || !writable) {
275
+ value.disabled = true;
276
+ value.classList.add("readonly");
277
+ } else {
278
+ value.addEventListener("click", () => openPropertyEditor(value, definition, property, (next) => writeProperty(view, options, definition.name, next), { onNotice: (text) => snackbar(text) }));
279
+ }
280
+ row.append(name, value);
281
+ panel.append(row);
282
+ }
283
+ if (canWrite("read_update") && !page.in_trash) {
284
+ const add = textButton("Add a property", { class: "ghost add-property", icon: "plus", onClick: () => openSchemaEditor(add, page.parent.data_source_id, view.schema, () => afterWrite(view, options)) });
285
+ panel.append(add);
286
+ }
287
+ return panel;
288
+ }
289
+
290
+ function writeProperty(view, options, name, valueObject) {
291
+ return action(async () => {
292
+ await call("pages.update", { page_id: view.id, properties: { [name]: valueObject } }, key());
293
+ await afterWrite(view, options);
294
+ });
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------------------------
298
+ // Comments under the title
299
+ // ---------------------------------------------------------------------------------------------
300
+
301
+ function commentsStrip(view, host, options) {
302
+ const strip = el("div", { class: "comments-strip" });
303
+ if (!canComment("read")) return strip;
304
+ if (view.commentsError) {
305
+ strip.append(el("div", { class: "comments-error", text: view.commentsError }));
306
+ return strip;
307
+ }
308
+ const discussions = groupDiscussions(view.comments);
309
+ if (view.comments.truncated) strip.append(el("div", { class: "list-notice", text: `Showing the first ${view.comments.length} comments on this page.` }));
310
+ for (const discussion of discussions) strip.append(discussionBlock(discussion, view, options));
311
+ if (canComment("read_insert") && !view.page.in_trash) strip.append(commentComposer("Add a comment…", (text) => action(async () => {
312
+ await call("comments.create", { parent: { page_id: view.id }, rich_text: textToRich(text) }, key());
313
+ await view.refresh();
314
+ options.onChanged?.(view);
315
+ })));
316
+ return strip;
317
+ }
318
+
319
+ export function groupDiscussions(comments) {
320
+ const map = new Map();
321
+ for (const comment of comments) {
322
+ if (!map.has(comment.discussion_id)) map.set(comment.discussion_id, { id: comment.discussion_id, parent: comment.parent, comments: [] });
323
+ map.get(comment.discussion_id).comments.push(comment);
324
+ }
325
+ return [...map.values()];
326
+ }
327
+
328
+ export function discussionBlock(discussion, view, options, { showParent = false } = {}) {
329
+ const box = el("div", { class: "discussion" });
330
+ if (showParent && discussion.parent?.type === "block_id") box.append(el("div", { class: "discussion-context", text: "On a block of this page" }));
331
+ for (const comment of discussion.comments) {
332
+ const author = comment.display_name?.resolved_name ?? userName(comment.created_by, state.users);
333
+ const row = el("div", { class: "comment" }, [
334
+ avatar({ id: comment.created_by?.id, name: author }, "small"),
335
+ el("div", { class: "comment-body" }, [
336
+ el("div", { class: "comment-meta" }, [el("span", { class: "comment-author", text: author }), comment.display_name?.type === "integration" ? el("span", { class: "comment-bot", text: "Integration" }) : null, el("span", { class: "comment-time", text: relativeTime(comment.created_time, state.nowMs) })]),
337
+ el("div", { class: "comment-text" }, richText(comment.rich_text)),
338
+ ]),
339
+ ]);
340
+ box.append(row);
341
+ }
342
+ if (canComment("read_insert") && !view?.page?.in_trash) box.append(commentComposer("Reply…", (text) => action(async () => {
343
+ await call("comments.create", { discussion_id: discussion.id, rich_text: textToRich(text) }, key());
344
+ await view.refresh();
345
+ options?.onChanged?.(view);
346
+ }), { compact: true }));
347
+ return box;
348
+ }
349
+
350
+ export function commentComposer(placeholder, onSubmit, { compact = false } = {}) {
351
+ const form = el("form", { class: `comment-composer ${compact ? "compact" : ""}`.trim() });
352
+ const input = el("input", { attrs: { type: "text", placeholder, "aria-label": placeholder, autocomplete: "off" } });
353
+ const send = el("button", { class: "send-btn", attrs: { type: "submit", "aria-label": "Send comment" } }, icon("send"));
354
+ send.disabled = true;
355
+ input.addEventListener("input", () => { send.disabled = input.value.trim().length === 0; });
356
+ form.append(state.context ? avatar(state.context.user, "small") : el("span"), input, send);
357
+ form.addEventListener("submit", (event) => {
358
+ event.preventDefault();
359
+ const text = input.value.trim();
360
+ if (text.length === 0) return;
361
+ void Promise.resolve(onSubmit(text)).then(() => { if (form.isConnected) { input.value = ""; send.disabled = true; } });
362
+ });
363
+ return form;
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------------------------
367
+ // Blocks
368
+ // ---------------------------------------------------------------------------------------------
369
+
370
+ function blockList(view, host, options) {
371
+ const list = el("div", { class: "blocks", attrs: { role: "region", "aria-label": "Page content" } });
372
+ const writable = canWrite() && !view.page.in_trash;
373
+ if (view.blocks.length === 0) {
374
+ if (writable) {
375
+ const empty = el("div", { class: "blocks-empty" });
376
+ const start = el("button", { class: "blocks-empty-btn", attrs: { type: "button" } }, [el("span", { text: "Write something, or press " }), el("kbd", { text: "/" }), el("span", { text: " for commands…" })]);
377
+ start.addEventListener("click", () => appendBlock(view, options, view.id, newBlockInput("paragraph"), undefined));
378
+ empty.append(start);
379
+ list.append(empty);
380
+ } else list.append(el("div", { class: "blocks-empty", text: "This page is empty." }));
381
+ return list;
382
+ }
383
+ renderBlocks(list, view.blocks, { view, host, options, parentId: view.id, writable, depth: 0 });
384
+ if (view.blocksMore) {
385
+ const more = textButton("Load more blocks", { class: "load-more", onClick: () => action(async () => {
386
+ const next = await call("blocks.children.list", { block_id: view.id, page_size: 30, start_cursor: view.blocksCursor });
387
+ view.blocks.push(...next.results);
388
+ view.blocksCursor = next.next_cursor;
389
+ view.blocksMore = next.has_more;
390
+ render(host, view, options);
391
+ }, { exclusive: false }) });
392
+ list.append(more);
393
+ }
394
+ if (writable) {
395
+ const tail = el("div", { class: "blocks-tail", attrs: { role: "button", tabindex: "0", "aria-label": "Add a block at the end" } });
396
+ const add = () => appendBlock(view, options, view.id, newBlockInput("paragraph"), view.blocks[view.blocks.length - 1]?.id ? undefined : undefined);
397
+ tail.addEventListener("click", add);
398
+ tail.addEventListener("keydown", (event) => { if (event.key === "Enter") add(); });
399
+ list.append(tail);
400
+ }
401
+ return list;
402
+ }
403
+
404
+ function renderBlocks(list, blocks, context) {
405
+ let number = 0;
406
+ blocks.forEach((block, index) => {
407
+ if (block.type === "numbered_list_item") number = blocks[index - 1]?.type === "numbered_list_item" ? number + 1 : 1;
408
+ list.append(renderBlock(block, { ...context, number }));
409
+ });
410
+ }
411
+
412
+ function renderBlock(block, context) {
413
+ const { view, options, writable, depth } = context;
414
+ const type = block.type;
415
+ const wrapper = el("div", { class: `block block-${type} ${block.in_trash ? "trashed" : ""}`.trim(), attrs: { "data-block-id": block.id, "data-depth": String(depth) } });
416
+ if (writable && type !== "child_page" && type !== "child_database") wrapper.append(gutter(block, context));
417
+ const body = el("div", { class: "block-body" });
418
+ const content = block[type] ?? {};
419
+ switch (type) {
420
+ case "child_page": {
421
+ const node = state.pages.get(block.id);
422
+ const link = el("a", { class: "child-page", attrs: { href: `#/page/${block.id}` } }, [objectIcon(node?.iconObject, "page", "child-icon"), el("span", { class: "child-title", text: content.title || node?.title || "Untitled" })]);
423
+ body.append(link);
424
+ break;
425
+ }
426
+ case "child_database": {
427
+ const node = state.databases.get(block.id);
428
+ if (node?.isInline) {
429
+ body.append(el("div", { class: "inline-database" }, renderInlineDatabase(block.id, { onChanged: () => options.onChanged?.(view) })));
430
+ } else {
431
+ body.append(el("a", { class: "child-page child-database", attrs: { href: `#/database/${block.id}/table` } }, [objectIcon(node?.iconObject, "database", "child-icon"), el("span", { class: "child-title", text: content.title || node?.title || "Untitled" })]));
432
+ }
433
+ break;
434
+ }
435
+ case "divider":
436
+ body.append(el("hr", { class: "divider" }));
437
+ break;
438
+ case "bookmark": {
439
+ const card = el("a", { class: "bookmark", attrs: { href: content.url, target: "_blank", rel: "noopener noreferrer" } }, [
440
+ el("div", { class: "bookmark-main" }, [el("div", { class: "bookmark-title", text: plain(content.caption) || content.url }), el("div", { class: "bookmark-url", text: content.url })]),
441
+ el("div", { class: "bookmark-side" }, icon("link")),
442
+ ]);
443
+ body.append(card);
444
+ break;
445
+ }
446
+ case "image": {
447
+ // Remote images never load inside the test environment: the product's "image" frame with the URL as caption.
448
+ body.append(el("div", { class: "image-block" }, [el("div", { class: "image-frame" }, [icon("page", "image-glyph"), el("span", { text: "External image" })]), el("div", { class: "image-caption", text: plain(content.caption) || content.external?.url || "" })]));
449
+ break;
450
+ }
451
+ default:
452
+ body.append(textBlock(block, content, context));
453
+ }
454
+ wrapper.append(body);
455
+ // Nested children: toggles collapse, everything else shows its children inline.
456
+ if (block.has_children && type !== "child_page" && type !== "child_database") {
457
+ const expanded = type === "toggle" ? view.expanded.has(block.id) : true;
458
+ if (expanded) {
459
+ const childHost = el("div", { class: "block-children" });
460
+ const loaded = view.children.get(block.id);
461
+ if (loaded) {
462
+ renderBlocks(childHost, loaded.blocks, { ...context, parentId: block.id, depth: depth + 1 });
463
+ if (loaded.more) childHost.append(textButton("Load more", { class: "load-more", onClick: () => loadChildren(block.id, context, loaded.cursor) }));
464
+ } else {
465
+ childHost.append(el("div", { class: "block-loading", text: "Loading…" }));
466
+ void loadChildren(block.id, context);
467
+ }
468
+ wrapper.append(childHost);
469
+ }
470
+ }
471
+ return wrapper;
472
+ }
473
+
474
+ async function loadChildren(blockId, context, cursor) {
475
+ const { view, host, options } = context;
476
+ try {
477
+ const page = await call("blocks.children.list", { block_id: blockId, page_size: 50, ...(cursor ? { start_cursor: cursor } : {}) });
478
+ const existing = cursor ? view.children.get(blockId)?.blocks ?? [] : [];
479
+ view.children.set(blockId, { blocks: [...existing, ...page.results], more: page.has_more, cursor: page.next_cursor });
480
+ } catch (error) {
481
+ view.children.set(blockId, { blocks: [], more: false, cursor: null, error: describe(error) });
482
+ snackbar(describe(error), { error: true });
483
+ }
484
+ render(host, view, options);
485
+ }
486
+
487
+ function textBlock(block, content, context) {
488
+ const { view, options, writable, number } = context;
489
+ const type = block.type;
490
+ const row = el("div", { class: "block-row" });
491
+ if (type === "bulleted_list_item") row.append(el("span", { class: "marker bullet", text: "•", attrs: { "aria-hidden": "true" } }));
492
+ if (type === "numbered_list_item") row.append(el("span", { class: "marker number", text: `${number}.`, attrs: { "aria-hidden": "true" } }));
493
+ if (type === "to_do") row.append(checkbox(content.checked === true, { label: "To-do", onToggle: writable ? (next) => action(async () => {
494
+ await call("blocks.update", { block_id: block.id, to_do: { checked: next } }, key());
495
+ await refreshBlocks(view, context);
496
+ }) : undefined }));
497
+ if (type === "toggle") {
498
+ const open = view.expanded.has(block.id);
499
+ const chevron = el("button", { class: `toggle-chevron ${open ? "open" : ""}`.trim(), attrs: { type: "button", "aria-label": open ? "Collapse" : "Expand", "aria-expanded": String(open) } }, icon("chevron_right"));
500
+ chevron.addEventListener("click", () => {
501
+ if (open) view.expanded.delete(block.id);
502
+ else view.expanded.add(block.id);
503
+ render(context.host, view, options);
504
+ });
505
+ row.append(chevron);
506
+ }
507
+ if (type === "callout") row.append(el("span", { class: "callout-icon", text: emojiOf(content.icon) ?? "💡", attrs: { "aria-hidden": "true" } }));
508
+ if (type === "code") row.append(el("span", { class: "code-language", text: content.language ?? "plain text" }));
509
+ const text = el("div", { class: `block-text ${type === "to_do" && content.checked ? "checked" : ""}`.trim(), attrs: { "data-placeholder": PLACEHOLDER[type] ?? "" } });
510
+ if (content.color && content.color !== "default") text.dataset.color = content.color;
511
+ if (writable) editableText(text, block, content, context);
512
+ else text.append(richText(content.rich_text));
513
+ if (plain(content.rich_text).length === 0) text.classList.add("empty");
514
+ row.append(text);
515
+ return row;
516
+ }
517
+
518
+ /** Plain-text editing of a text block: edits replace the block's rich text with one plain run. */
519
+ function editableText(text, block, content, context) {
520
+ const { view, options } = context;
521
+ const original = plain(content.rich_text);
522
+ text.append(richText(content.rich_text));
523
+ text.setAttribute("contenteditable", "plaintext-only");
524
+ text.setAttribute("spellcheck", "false");
525
+ text.setAttribute("role", "textbox");
526
+ text.setAttribute("aria-label", `${block.type.replace(/_/g, " ")} block`);
527
+ registerFormatTarget(text, { openComments: () => openBlockComments(block, context) });
528
+ let menu;
529
+ const currentText = () => text.textContent.replace(/ /g, " ");
530
+ const commit = async () => {
531
+ const next = currentText().replace(/\n$/, "");
532
+ if (next === original) return false;
533
+ await call("blocks.update", { block_id: block.id, [block.type]: { rich_text: textToRich(next) } }, key());
534
+ return true;
535
+ };
536
+ text.addEventListener("focus", () => text.classList.remove("empty"));
537
+ text.addEventListener("input", () => {
538
+ const value = currentText();
539
+ if (value.startsWith("/")) {
540
+ const needle = value.slice(1);
541
+ if (!menu || !menu.isConnected) {
542
+ menu = openBlockMenu(text, (type) => insertFromSlash(block, type, context), { filter: needle });
543
+ text.focus();
544
+ } else menu.filter(needle);
545
+ } else if (menu?.isConnected) {
546
+ closePopovers();
547
+ menu = undefined;
548
+ }
549
+ });
550
+ text.addEventListener("blur", () => {
551
+ if (currentText().length === 0) text.classList.add("empty");
552
+ void action(async () => {
553
+ if (await commit()) await refreshBlocks(view, context);
554
+ }, { exclusive: false });
555
+ });
556
+ text.addEventListener("keydown", (event) => {
557
+ if (menu?.isConnected && popoverOpen()) {
558
+ if (event.key === "Enter") {
559
+ event.preventDefault();
560
+ menu.querySelector(".menu-item:not(:disabled)")?.click();
561
+ return;
562
+ }
563
+ if (event.key === "Escape") return; // the popover handler closes it
564
+ }
565
+ if (event.key === "Enter" && !event.shiftKey && block.type !== "code") {
566
+ event.preventDefault();
567
+ const nextType = ["bulleted_list_item", "numbered_list_item", "to_do"].includes(block.type) && currentText().length > 0 ? block.type : "paragraph";
568
+ void action(async () => {
569
+ await commit();
570
+ await appendBlockNow(view, options, context.parentId, newBlockInput(nextType), block.id, { silent: true });
571
+ });
572
+ } else if (event.key === "Backspace" && currentText().length === 0) {
573
+ event.preventDefault();
574
+ const previous = text.closest(".block")?.previousElementSibling;
575
+ const previousId = previous?.getAttribute("data-block-id");
576
+ void action(async () => {
577
+ await call("blocks.delete", { block_id: block.id }, key());
578
+ if (previousId) view.focusAfter = previousId;
579
+ await refreshBlocks(view, context);
580
+ });
581
+ } else if (event.key === "Escape") {
582
+ text.blur();
583
+ }
584
+ });
585
+ }
586
+
587
+ async function insertFromSlash(block, type, context) {
588
+ const { view, options } = context;
589
+ const host = context.host;
590
+ const textNode = host.querySelector(`[data-block-id="${block.id}"] .block-text`);
591
+ const typed = (textNode?.textContent ?? "").replace(/^\/\S*/, "").trim();
592
+ if (type === "page") {
593
+ await action(async () => {
594
+ const created = await call("pages.create", { parent: { page_id: view.id }, properties: { title: textToRich(typed || "Untitled") } }, key());
595
+ if (typed.length === 0 && textNode) await call("blocks.delete", { block_id: block.id }, key()).catch(() => undefined);
596
+ await loadIndex();
597
+ navigate({ name: "page", id: created.id });
598
+ });
599
+ return;
600
+ }
601
+ await action(async () => {
602
+ const appended = await call("blocks.children.append", { block_id: context.parentId, children: [newBlockInput(type, typed)], after: block.id }, key());
603
+ const emptyOriginal = (textNode?.textContent ?? "").replace(/^\/\S*/, "").trim().length === 0;
604
+ if (emptyOriginal) await call("blocks.delete", { block_id: block.id }, key());
605
+ view.focusAfter = appended.results[0]?.id;
606
+ await refreshBlocks(view, context);
607
+ });
608
+ }
609
+
610
+ /** Append one block (inside an already running action). */
611
+ async function appendBlockNow(view, options, parentId, input, afterId, { silent = false } = {}) {
612
+ const appended = await call("blocks.children.append", { block_id: parentId, children: [input], ...(afterId ? { after: afterId } : {}) }, key());
613
+ view.focusAfter = appended.results[0]?.id;
614
+ await view.refresh();
615
+ if (!silent) options.onChanged?.(view);
616
+ }
617
+
618
+ function appendBlock(view, options, parentId, input, afterId, flags) {
619
+ return action(() => appendBlockNow(view, options, parentId, input, afterId, flags));
620
+ }
621
+
622
+ async function refreshBlocks(view, context) {
623
+ await view.refresh();
624
+ context.options.onChanged?.(view);
625
+ }
626
+
627
+ function gutter(block, context) {
628
+ const { view, options } = context;
629
+ const box = el("div", { class: "gutter", attrs: { "aria-hidden": "false" } });
630
+ const add = iconButton("plus", "Add a block below", { class: "gutter-btn", tooltip: false });
631
+ add.addEventListener("click", () => openBlockMenu(add, (type) => {
632
+ if (type === "page") {
633
+ void action(async () => {
634
+ const created = await call("pages.create", { parent: { page_id: view.id }, properties: { title: textToRich("Untitled") } }, key());
635
+ await loadIndex();
636
+ navigate({ name: "page", id: created.id });
637
+ });
638
+ return;
639
+ }
640
+ void appendBlock(view, options, context.parentId, newBlockInput(type), block.id);
641
+ }));
642
+ const handle = iconButton("drag", "Block options", { class: "gutter-btn handle", tooltip: false });
643
+ handle.addEventListener("click", () => openPopover(handle, [
644
+ { header: block.type.replace(/_/g, " ") },
645
+ { label: "Comment", icon: "comment", onSelect: () => openBlockComments(block, context) },
646
+ { label: "Add block below", icon: "plus", onSelect: () => openBlockMenu(handle, (type) => appendBlock(view, options, context.parentId, newBlockInput(type), block.id)) },
647
+ "divider",
648
+ { label: "Delete", icon: "trash", danger: true, shortcut: "Del", onSelect: () => action(async () => {
649
+ if (!(await confirmDialog("Delete this block?", "The block and anything nested inside it move to the trash.", "Delete"))) return;
650
+ await call("blocks.delete", { block_id: block.id }, key());
651
+ await refreshBlocks(view, context);
652
+ }) },
653
+ ], { width: 240 }));
654
+ box.append(add, handle);
655
+ return box;
656
+ }
657
+
658
+ function openBlockComments(block, context) {
659
+ const { view, options } = context;
660
+ const body = el("div", { class: "panel-comments" }, el("div", { class: "panel-loading", text: "Loading comments…" }));
661
+ openSidePanel("Comments", body);
662
+ void (async () => {
663
+ try {
664
+ const comments = await allComments(block.id);
665
+ body.replaceChildren();
666
+ body.append(el("div", { class: "panel-context" }, [icon("block_text", "panel-context-icon"), el("span", { text: plain(block[block.type]?.rich_text) || block.type })]));
667
+ const discussions = groupDiscussions(comments);
668
+ if (discussions.length === 0) body.append(el("div", { class: "panel-empty", text: "No comments on this block yet." }));
669
+ for (const discussion of discussions) body.append(discussionBlock(discussion, view, options));
670
+ if (canComment("read_insert") && !view.page.in_trash) {
671
+ body.append(el("p", { class: "panel-note", text: "New block-level discussions cannot be started through the public API; reply in an existing one or comment on the page." }));
672
+ }
673
+ } catch (error) {
674
+ body.replaceChildren(el("div", { class: "panel-empty", text: describe(error) }));
675
+ }
676
+ })();
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------------------------
680
+ // Page-level actions (top bar): comments panel, more menu, move, markdown
681
+ // ---------------------------------------------------------------------------------------------
682
+
683
+ export function openCommentsPanel(view, options) {
684
+ const body = el("div", { class: "panel-comments" });
685
+ const fill = () => {
686
+ body.replaceChildren();
687
+ if (!canComment("read")) {
688
+ body.append(el("div", { class: "panel-empty", text: "This integration can't read comments." }));
689
+ return;
690
+ }
691
+ const discussions = groupDiscussions(view.comments);
692
+ body.append(el("div", { class: "panel-tabs" }, [el("button", { class: "panel-tab active", text: "Open", attrs: { type: "button" } }), el("button", { class: "panel-tab", text: "Resolved", attrs: { type: "button", disabled: "" } })]));
693
+ if (discussions.length === 0) body.append(el("div", { class: "panel-empty" }, [icon("comment", "panel-empty-icon"), el("p", { text: "No open comments yet" }), el("p", { class: "panel-note", text: "Comments on this page will appear here." })]));
694
+ for (const discussion of discussions) body.append(discussionBlock(discussion, view, { onChanged: () => { options?.onChanged?.(view); fill(); } }));
695
+ };
696
+ fill();
697
+ openSidePanel("Comments", body);
698
+ }
699
+
700
+ export function openMarkdownPanel(view, options) {
701
+ const body = el("div", { class: "panel-markdown" }, el("div", { class: "panel-loading", text: "Rendering markdown…" }));
702
+ openSidePanel("Markdown", body);
703
+ void action(async () => {
704
+ const rendered = await call("pages.retrieve-markdown", { page_id: view.id });
705
+ body.replaceChildren();
706
+ const area = el("textarea", { class: "markdown-area", attrs: { "aria-label": "Page markdown", spellcheck: "false", rows: "24" } });
707
+ area.value = rendered.markdown;
708
+ if (rendered.unknown_block_ids.length > 0) body.append(el("p", { class: "panel-note", text: `${rendered.unknown_block_ids.length} block(s) have no markdown form and are kept as <unknown/> tags.` }));
709
+ body.append(area);
710
+ if (canWrite() && !view.page.in_trash) {
711
+ const apply = textButton("Replace page content", { class: "primary", onClick: () => action(async () => {
712
+ if (!(await confirmDialog("Replace the page content?", "Blocks that are not in the markdown are moved to the trash; child pages and databases stay in place.", "Replace", { danger: false }))) return;
713
+ await call("pages.update-markdown", { page_id: view.id, type: "replace_content", content: area.value, allow_deleting_content: true }, key());
714
+ snackbar("Page content replaced from markdown");
715
+ await view.refresh();
716
+ options?.onChanged?.(view);
717
+ }) });
718
+ body.append(el("div", { class: "panel-actions" }, [apply]));
719
+ } else body.append(el("p", { class: "panel-note", text: "Read-only: this integration cannot update content." }));
720
+ }, { exclusive: false, onError: (error) => body.replaceChildren(el("div", { class: "panel-empty", text: describe(error) })) });
721
+ }
722
+
723
+ export function openMoveDialog(anchor, view, options) {
724
+ const box = el("div", { class: "editor-options" });
725
+ const search = el("input", { class: "editor-search", attrs: { type: "text", placeholder: "Move page to…", "aria-label": "Move page to" } });
726
+ const list = el("div", { class: "picker-list", attrs: { role: "listbox" } });
727
+ const excluded = new Set([view.id]);
728
+ const isDescendant = (node) => {
729
+ let current = node;
730
+ for (let depth = 0; depth < 32 && current; depth += 1) {
731
+ if (current.parent?.type === "page_id") {
732
+ if (current.parent.page_id === view.id) return true;
733
+ current = state.pages.get(current.parent.page_id);
734
+ } else return false;
735
+ }
736
+ return false;
737
+ };
738
+ const render = () => {
739
+ list.replaceChildren();
740
+ const needle = search.value.trim().toLowerCase();
741
+ const targets = [
742
+ ...[...state.pages.values()].filter((page) => page.parent?.type !== "data_source_id" && !excluded.has(page.id) && !isDescendant(page)).map((page) => ({ label: page.title, emoji: page.icon, kind: "page", id: page.id })),
743
+ ...[...state.databases.values()].map((database) => ({ label: database.title, emoji: database.icon, kind: "database", id: database.dataSourceId })),
744
+ ].filter((target) => target.label.toLowerCase().includes(needle));
745
+ if (targets.length === 0) list.append(el("div", { class: "picker-empty", text: "No pages found" }));
746
+ for (const target of targets.slice(0, 40)) {
747
+ const row = el("button", { class: "picker-item", attrs: { type: "button", role: "option" } }, [target.emoji ? el("span", { class: "picker-emoji", text: target.emoji }) : icon(target.kind === "database" ? "database" : "page", "picker-icon"), el("span", { class: "picker-label", text: target.label })]);
748
+ row.addEventListener("click", () => action(async () => {
749
+ closePopovers();
750
+ const parent = target.kind === "database" ? { type: "data_source_id", data_source_id: target.id } : { type: "page_id", page_id: target.id };
751
+ await call("pages.move", { page_id: view.id, parent }, key());
752
+ snackbar(`Moved to ${target.label}`);
753
+ await loadIndex();
754
+ await view.refresh();
755
+ options?.onChanged?.(view);
756
+ }));
757
+ list.append(row);
758
+ }
759
+ };
760
+ search.addEventListener("input", render);
761
+ box.append(search, list);
762
+ render();
763
+ return openPopover(anchor, box, { width: 320, align: "end" });
764
+ }
765
+
766
+ export function openPageMenu(anchor, view, options) {
767
+ const page = view.page;
768
+ const editor = state.users.get(page.last_edited_by?.id);
769
+ const items = [
770
+ { label: "Copy link", icon: "link", onSelect: () => navigator.clipboard?.writeText(page.url).then(() => snackbar("Link copied (synthetic notion.so URL)"), () => snackbar(page.url)) },
771
+ { label: "Markdown", icon: "markdown", description: "View or replace the content as markdown", onSelect: () => openMarkdownPanel(view, options) },
772
+ ];
773
+ if (canWrite() && !page.in_trash) {
774
+ items.push({ label: "Move to", icon: "move", shortcut: "⌘⇧P", onSelect: () => openMoveDialog(anchor, view, options) });
775
+ items.push("divider", { label: "Move to Trash", icon: "trash", danger: true, onSelect: () => action(async () => {
776
+ if (!(await confirmDialog("Move this page to Trash?", `"${pageTitle(page)}" and everything inside it move to the trash. You can restore it from Trash.`, "Move to Trash"))) return;
777
+ await call("pages.update", { page_id: view.id, in_trash: true }, key());
778
+ snackbar("Moved to Trash", { actionLabel: "Undo", onAction: async () => { await call("pages.update", { page_id: view.id, in_trash: false }, key()); await loadIndex(); await view.refresh(); options?.onChanged?.(view); } });
779
+ await loadIndex();
780
+ await view.refresh();
781
+ options?.onChanged?.(view);
782
+ }) });
783
+ }
784
+ const footer = el("div", { class: "menu-footer" }, [
785
+ el("div", { text: `Last edited by ${editor?.name ?? userName(page.last_edited_by, state.users)}` }),
786
+ el("div", { text: relativeTime(page.last_edited_time, state.nowMs) }),
787
+ ]);
788
+ const box = el("div");
789
+ box.append(...[el("div", { class: "menu-header", text: "Page" })]);
790
+ const menu = openPopover(anchor, items, { align: "end", width: 265 });
791
+ menu.append(footer);
792
+ return menu;
793
+ }
794
+
795
+ export function pageTopbarActions(view, options) {
796
+ const page = view.page;
797
+ const comments = iconButton("comment", "Comments", { onClick: () => openCommentsPanel(view, options) });
798
+ const more = iconButton("more", "More", { onClick: () => openPageMenu(more, view, options) });
799
+ const share = textButton("Share", { class: "share", onClick: () => openSharePopover(share, view) });
800
+ const edited = el("span", { class: "topbar-edited", text: page ? `Edited ${relativeTime(page.last_edited_time, state.nowMs)}` : "" });
801
+ const updates = unsimulatedButton("clock", "View all updates", "Page history and the updates feed are not part of the API subset this Tool serves. The page's last edit time is shown in the top bar.", { align: "end" });
802
+ const favorite = unsimulatedButton("star", "Add to Favorites", "Favorites are a per-user app preference that the API does not expose, so this Tool does not store them.", { align: "end" });
803
+ return [edited, share, comments, updates, favorite, more];
804
+ }
805
+
806
+ function openSharePopover(anchor, view) {
807
+ const box = el("div", { class: "share-popover" });
808
+ const integration = state.context?.integration;
809
+ box.append(el("div", { class: "share-row head" }, [el("span", { text: "Share" }), el("span", { class: "share-badge", text: "Synthetic workspace" })]));
810
+ box.append(el("input", { class: "editor-search", attrs: { type: "text", placeholder: "Sharing settings are not part of the public API", disabled: "" } }));
811
+ box.append(el("div", { class: "share-row" }, [person({ id: integration?.id, name: integration?.name ?? "Integration" }, state.users), el("span", { class: "share-role", text: integration?.access?.type === "workspace" ? "Full workspace access" : "Shared pages only" })]));
812
+ for (const user of [...state.users.values()].filter((user) => user.type === "person").slice(0, 6)) box.append(el("div", { class: "share-row" }, [person(user, state.users), el("span", { class: "share-role", text: user.is_guest ? "Guest" : "Can edit" })]));
813
+ box.append(el("p", { class: "panel-note", text: `Visible to this integration: ${integration?.access?.type === "workspace" ? "every page of the workspace" : "the shared roots and their descendants"}. Page permissions cannot be changed through the API subset.` }));
814
+ return openPopover(anchor, box, { width: 360, align: "end" });
815
+ }
816
+
817
+ export function placeCaretAtEnd(element) {
818
+ const range = document.createRange();
819
+ range.selectNodeContents(element);
820
+ range.collapse(false);
821
+ const selection = window.getSelection();
822
+ selection.removeAllRanges();
823
+ selection.addRange(range);
824
+ }
825
+
826
+ export { shortDate, $ };