@alfakit/core 0.1.9 → 0.1.10

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 (52) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +1 -0
  4. package/dist/actions.js.map +1 -1
  5. package/dist/calendar.d.ts.map +1 -1
  6. package/dist/calendar.js +3 -0
  7. package/dist/calendar.js.map +1 -1
  8. package/dist/form.d.ts +6 -0
  9. package/dist/form.d.ts.map +1 -1
  10. package/dist/form.js +27 -3
  11. package/dist/form.js.map +1 -1
  12. package/dist/index.d.ts +6 -6
  13. package/dist/index.js +7 -7
  14. package/dist/page-actions.d.ts.map +1 -1
  15. package/dist/page-actions.js +30 -1
  16. package/dist/page-actions.js.map +1 -1
  17. package/dist/prefetch.d.ts +3 -1
  18. package/dist/prefetch.d.ts.map +1 -1
  19. package/dist/prefetch.js +37 -3
  20. package/dist/prefetch.js.map +1 -1
  21. package/dist/pwa.d.ts +12 -0
  22. package/dist/pwa.d.ts.map +1 -1
  23. package/dist/pwa.js +28 -1
  24. package/dist/pwa.js.map +1 -1
  25. package/dist/rich-editor.d.ts +11 -0
  26. package/dist/rich-editor.d.ts.map +1 -1
  27. package/dist/rich-editor.js +29 -1
  28. package/dist/rich-editor.js.map +1 -1
  29. package/dist/schema/panel.d.ts +7 -0
  30. package/dist/schema/panel.d.ts.map +1 -1
  31. package/dist/schema/panel.js.map +1 -1
  32. package/dist/shortcuts.d.ts +27 -0
  33. package/dist/shortcuts.d.ts.map +1 -1
  34. package/dist/shortcuts.js +53 -2
  35. package/dist/shortcuts.js.map +1 -1
  36. package/dist/starter-kit.d.ts +27 -2
  37. package/dist/starter-kit.d.ts.map +1 -1
  38. package/dist/starter-kit.js +32 -2
  39. package/dist/starter-kit.js.map +1 -1
  40. package/dist/table-layout.d.ts +6 -2
  41. package/dist/table-layout.d.ts.map +1 -1
  42. package/dist/table-layout.js +5 -3
  43. package/dist/table-layout.js.map +1 -1
  44. package/dist/table.d.ts.map +1 -1
  45. package/dist/table.js +5 -2
  46. package/dist/table.js.map +1 -1
  47. package/dist/undo.d.ts +2 -0
  48. package/dist/undo.d.ts.map +1 -1
  49. package/dist/undo.js +21 -1
  50. package/dist/undo.js.map +1 -1
  51. package/package.json +1 -1
  52. package/themes.css +4 -0
@@ -1 +1 @@
1
- {"version":3,"file":"page-actions.js","names":[],"sources":["../src/page-actions.ts"],"sourcesContent":["import type { ActionContext } from './actions'\nimport type { AlfakitFlash } from './flash'\nimport type { FormStore } from './form'\nimport type { WizardProgress } from './modal-actions'\nimport type { ActionSchema } from './schema/actions'\nimport type { FormBlock } from './schema/form'\nimport type { DefaultActionSchema, PageSchema } from './schema/panel'\nimport { setPath } from './utils/path'\n\n/**\n * Page-level runtime shared by the renderers: the default action opened after a page\n * hydrated, page forms submitted from buttons elsewhere (`Action::formId()`), form data\n * refreshed by the server (`refreshFormData()`), and the filters of chart widgets.\n */\n\n// ---------------------------------------------------------------- Default actions\n\n/** Query-string keys that ask a page to open an action (`?tableAction=edit&tableActionRecord=1`). */\nexport const DEFAULT_ACTION_QUERY_KEYS = [\n 'defaultAction',\n 'defaultActionArguments',\n 'defaultActionContext',\n 'tableAction',\n 'tableActionRecord',\n 'tableActionArguments',\n // Filament's deep-link keys (`?action=sendEmail&actionArguments[source]=email`).\n 'action',\n 'actionArguments',\n 'actionComponent',\n] as const\n\n/** The action context the default action opens in. */\nexport function defaultActionContext(\n page: Pick<PageSchema, 'token'>,\n defaultAction: DefaultActionSchema,\n): ActionContext {\n return {\n token: page.token,\n component: defaultAction.component,\n ...(defaultAction.record !== undefined && defaultAction.record !== null\n ? { record: defaultAction.record }\n : {}),\n ...(defaultAction.arguments ? { arguments: defaultAction.arguments } : {}),\n }\n}\n\n/**\n * Identifies \"this default action on this page\". A page re-rendered after the action ran\n * (the server redirects back) still names it, so renderers remember the key they opened\n * and open it again only for another page or another action.\n */\nexport function defaultActionKey(\n page: Pick<PageSchema, 'class' | 'record' | 'defaultAction'>,\n pathname: string = typeof window !== 'undefined' ? window.location.pathname : '',\n): string | null {\n const action = page.defaultAction\n\n if (!action) return null\n\n return JSON.stringify([\n page.class,\n page.record ?? null,\n pathname,\n action.component,\n action.action.name,\n action.record ?? null,\n ])\n}\n\n/** The URL without the query-string keys that open an action (bracketed forms included). */\nexport function withoutDefaultActionQuery(href: string): string {\n const url = new URL(href, 'http://localhost')\n const keys = [...url.searchParams.keys()]\n\n for (const key of keys) {\n const base = key.replace(/\\[.*$/, '')\n\n if ((DEFAULT_ACTION_QUERY_KEYS as readonly string[]).includes(base)) {\n url.searchParams.delete(key)\n }\n }\n\n const relative = `${url.pathname}${url.search}${url.hash}`\n\n return /^https?:\\/\\//.test(href) ? url.toString() : relative\n}\n\nexport interface DefaultActionOpener {\n /** Key of the default action opened last (see `defaultActionKey()`). */\n opened: string | null\n}\n\n/**\n * Open the page's default action once per page: call after every page render. The query\n * string that asked for it is removed (without a visit), so later redirects back to the\n * page don't open it again. Returns whether it was opened now.\n */\nexport function openDefaultAction(\n page: PageSchema | undefined,\n opener: DefaultActionOpener,\n trigger: (action: ActionSchema, context: ActionContext) => void | Promise<void>,\n): boolean {\n if (!page?.defaultAction) {\n opener.opened = null\n\n return false\n }\n\n const key = defaultActionKey(page)\n\n if (key === null || key === opener.opened) return false\n\n opener.opened = key\n\n if (typeof window !== 'undefined') {\n const clean = withoutDefaultActionQuery(window.location.href)\n\n if (clean !== window.location.href) {\n window.history.replaceState(window.history.state, '', clean)\n }\n }\n\n void trigger(page.defaultAction.action, defaultActionContext(page, page.defaultAction))\n\n return true\n}\n\n// ---------------------------------------------------------------- Page form submitters\n\ntype FormSubmitter = (action: ActionSchema, context: ActionContext) => void | Promise<void>\n\nconst formSubmitters = new Map<string, FormSubmitter>()\n\n/**\n * A page form publishes how it submits itself (with its state, processing flag and\n * error handling), so buttons with `formId()` elsewhere on the page can use it.\n * `form` is the page form; named forms register as `form:{name}`.\n */\nexport function registerFormSubmitter(component: string, submit: FormSubmitter): () => void {\n formSubmitters.set(component, submit)\n\n return () => {\n if (formSubmitters.get(component) === submit) {\n formSubmitters.delete(component)\n }\n }\n}\n\n/**\n * Submit the form an action's `formId` names. Returns false when the action has no\n * `formId`, is a plain link, or no such form is on the page (the caller then runs the\n * action normally).\n */\nexport function submitFormById(action: ActionSchema, context: ActionContext): boolean {\n if (!action.formId || (action.url && !action.hasAction)) return false\n\n const submit =\n formSubmitters.get(action.formId) ??\n (action.formId.startsWith('form:') ? undefined : formSubmitters.get(`form:${action.formId}`))\n\n if (!submit) return false\n\n void submit(action, context)\n\n return true\n}\n\n// ---------------------------------------------------------------- Form refresh\n\n/** The paths `refreshFormData()` sent for this form, or null. */\nexport function formRefreshFor(\n flash: Pick<AlfakitFlash, 'alfakitFormRefresh'>,\n component: string,\n): Record<string, unknown> | null {\n const refresh = flash.alfakitFormRefresh\n\n return refresh && refresh.component === component && refresh.state ? refresh.state : null\n}\n\n/**\n * Copy refreshed values into a form store. The paths, and the fields below them, stop\n * counting as edited (and lose their errors); every other field keeps what the user typed.\n */\nexport function applyFormRefresh(store: FormStore, state: Record<string, unknown>): void {\n const paths = Object.keys(state)\n\n if (paths.length === 0) return\n\n store.store.set((current) => {\n let values = current.values\n const dirty = new Set(current.dirty)\n const errors = { ...current.errors }\n\n for (const path of paths) {\n values = setPath(values, path, state[path])\n\n // A refreshed path replaces everything below it (`content` with its blocks' fields).\n for (const key of [...dirty]) {\n if (key === path || key.startsWith(`${path}.`)) dirty.delete(key)\n }\n\n for (const key of Object.keys(errors)) {\n if (key === path || key.startsWith(`${path}.`)) delete errors[key]\n }\n }\n\n return { ...current, values, dirty, errors }\n })\n}\n\n/**\n * Whether a page form reloads from the server's new schema after a visit it did not submit\n * itself: another action (one embedded in a field, a table row in a relation manager)\n * succeeded while the form held no edits. Its components may show server state that the\n * action changed (a status, which actions are offered); with unsaved edits the form keeps\n * what the user typed.\n */\nexport function shouldResyncForm(\n flash: Pick<AlfakitFlash, 'alfakitAction'>,\n store: Pick<FormStore, 'isDirty'>,\n): boolean {\n return flash.alfakitAction?.status === 'success' && !store.isDirty\n}\n\n// ---------------------------------------------------------------- Wizard pages\n\n/** Whether a page form shows its buttons: always, except before a wizard form's last step. */\nexport function formActionsVisible(\n block: Pick<FormBlock, 'wizard'>,\n progress: WizardProgress | null | undefined,\n): boolean {\n if (!block.wizard || !progress || progress.count === 0) return true\n\n return progress.step >= progress.count - 1\n}\n\n// ---------------------------------------------------------------- Chart filters\n\n/**\n * The URL with a chart widget's filter values in the query string\n * (`chartFilters[{id}][field]=value`, lists as `[field][]`). The widget's previous values\n * are replaced; empty values are left out; other query values stay.\n */\nexport function chartFiltersUrl(\n href: string,\n queryKey: string,\n widgetId: string,\n state: Record<string, unknown> | null,\n): string {\n const url = new URL(href, 'http://localhost')\n const prefix = `${queryKey}[${widgetId}]`\n\n for (const key of [...url.searchParams.keys()]) {\n if (key.startsWith(prefix)) url.searchParams.delete(key)\n }\n\n for (const [field, value] of Object.entries(state ?? {})) {\n if (value === null || value === undefined || value === '') continue\n\n if (Array.isArray(value)) {\n for (const item of value) {\n if (item !== null && item !== undefined)\n url.searchParams.append(`${prefix}[${field}][]`, String(item))\n }\n\n continue\n }\n\n url.searchParams.set(\n `${prefix}[${field}]`,\n typeof value === 'boolean' ? (value ? '1' : '0') : String(value),\n )\n }\n\n const relative = `${url.pathname}${url.search}${url.hash}`\n\n return /^https?:\\/\\//.test(href) ? url.toString() : relative\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"page-actions.js","names":[],"sources":["../src/page-actions.ts"],"sourcesContent":["import type { ActionContext } from './actions'\nimport type { AlfakitFlash } from './flash'\nimport type { FormStore } from './form'\nimport { router } from './http'\nimport type { WizardProgress } from './modal-actions'\nimport type { ActionSchema } from './schema/actions'\nimport type { FormBlock } from './schema/form'\nimport type { DefaultActionSchema, PageSchema } from './schema/panel'\nimport { setPath } from './utils/path'\n\n/**\n * Page-level runtime shared by the renderers: the default action opened after a page\n * hydrated, page forms submitted from buttons elsewhere (`Action::formId()`), form data\n * refreshed by the server (`refreshFormData()`), and the filters of chart widgets.\n */\n\n// ---------------------------------------------------------------- Default actions\n\n/** Query-string keys that ask a page to open an action (`?tableAction=edit&tableActionRecord=1`). */\nexport const DEFAULT_ACTION_QUERY_KEYS = [\n 'defaultAction',\n 'defaultActionArguments',\n 'defaultActionContext',\n 'tableAction',\n 'tableActionRecord',\n 'tableActionArguments',\n // Filament's deep-link keys (`?action=sendEmail&actionArguments[source]=email`).\n 'action',\n 'actionArguments',\n 'actionComponent',\n] as const\n\n/** The action context the default action opens in. */\nexport function defaultActionContext(\n page: Pick<PageSchema, 'token'>,\n defaultAction: DefaultActionSchema,\n): ActionContext {\n return {\n token: page.token,\n component: defaultAction.component,\n ...(defaultAction.record !== undefined && defaultAction.record !== null\n ? { record: defaultAction.record }\n : {}),\n ...(defaultAction.arguments ? { arguments: defaultAction.arguments } : {}),\n }\n}\n\n/**\n * Identifies \"this default action on this page\". A page re-rendered after the action ran\n * (the server redirects back) still names it, so renderers remember the key they opened\n * and open it again only for another page or another action.\n */\nexport function defaultActionKey(\n page: Pick<PageSchema, 'class' | 'record' | 'defaultAction'>,\n pathname: string = typeof window !== 'undefined' ? window.location.pathname : '',\n): string | null {\n const action = page.defaultAction\n\n if (!action) return null\n\n return JSON.stringify([\n page.class,\n page.record ?? null,\n pathname,\n action.component,\n action.action.name,\n action.record ?? null,\n ])\n}\n\n/** The URL without the query-string keys that open an action (bracketed forms included). */\nexport function withoutDefaultActionQuery(href: string): string {\n const url = new URL(href, 'http://localhost')\n const keys = [...url.searchParams.keys()]\n\n for (const key of keys) {\n const base = key.replace(/\\[.*$/, '')\n\n if ((DEFAULT_ACTION_QUERY_KEYS as readonly string[]).includes(base)) {\n url.searchParams.delete(key)\n }\n }\n\n const relative = `${url.pathname}${url.search}${url.hash}`\n\n return /^https?:\\/\\//.test(href) ? url.toString() : relative\n}\n\n/**\n * Inertia keeps the page's URL in the history state (`state.page.url`) and writes it back\n * on its next history update (a partial reload, the modal's requests), which brought the\n * `tableAction` keys back into the address bar. The cleaned state carries the clean URL.\n */\nfunction replacePageUrl(href: string): void {\n const url = new URL(href, window.location.origin)\n const relative = `${url.pathname}${url.search}${url.hash}`\n\n // Inertia's own page (and so its later history writes) gets the clean URL too.\n try {\n router.replace({ url: relative, preserveState: true, preserveScroll: true })\n } catch {\n // No Inertia app on the page (tests, a host page before its app booted).\n }\n\n window.history.replaceState(withPageUrl(window.history.state, relative), '', href)\n}\n\nfunction withPageUrl(state: unknown, relative: string): unknown {\n const page = (state as { page?: { url?: unknown } } | null)?.page\n\n if (!page || typeof page.url !== 'string') return state\n\n return { ...(state as object), page: { ...page, url: relative } }\n}\n\nexport interface DefaultActionOpener {\n /** Key of the default action opened last (see `defaultActionKey()`). */\n opened: string | null\n}\n\n/**\n * Open the page's default action once per page: call after every page render. The query\n * string that asked for it is removed (without a visit), so later redirects back to the\n * page don't open it again. Returns whether it was opened now.\n */\nexport function openDefaultAction(\n page: PageSchema | undefined,\n opener: DefaultActionOpener,\n trigger: (action: ActionSchema, context: ActionContext) => void | Promise<void>,\n): boolean {\n if (!page?.defaultAction) {\n opener.opened = null\n\n return false\n }\n\n const key = defaultActionKey(page)\n\n if (key === null || key === opener.opened) return false\n\n opener.opened = key\n\n if (typeof window !== 'undefined') {\n const clean = withoutDefaultActionQuery(window.location.href)\n\n if (clean !== window.location.href) {\n replacePageUrl(clean)\n }\n }\n\n void trigger(page.defaultAction.action, defaultActionContext(page, page.defaultAction))\n\n return true\n}\n\n// ---------------------------------------------------------------- Page form submitters\n\ntype FormSubmitter = (action: ActionSchema, context: ActionContext) => void | Promise<void>\n\nconst formSubmitters = new Map<string, FormSubmitter>()\n\n/**\n * A page form publishes how it submits itself (with its state, processing flag and\n * error handling), so buttons with `formId()` elsewhere on the page can use it.\n * `form` is the page form; named forms register as `form:{name}`.\n */\nexport function registerFormSubmitter(component: string, submit: FormSubmitter): () => void {\n formSubmitters.set(component, submit)\n\n return () => {\n if (formSubmitters.get(component) === submit) {\n formSubmitters.delete(component)\n }\n }\n}\n\n/**\n * Submit the form an action's `formId` names. Returns false when the action has no\n * `formId`, is a plain link, or no such form is on the page (the caller then runs the\n * action normally).\n */\nexport function submitFormById(action: ActionSchema, context: ActionContext): boolean {\n if (!action.formId || (action.url && !action.hasAction)) return false\n\n const submit =\n formSubmitters.get(action.formId) ??\n (action.formId.startsWith('form:') ? undefined : formSubmitters.get(`form:${action.formId}`))\n\n if (!submit) return false\n\n void submit(action, context)\n\n return true\n}\n\n// ---------------------------------------------------------------- Form refresh\n\n/** The paths `refreshFormData()` sent for this form, or null. */\nexport function formRefreshFor(\n flash: Pick<AlfakitFlash, 'alfakitFormRefresh'>,\n component: string,\n): Record<string, unknown> | null {\n const refresh = flash.alfakitFormRefresh\n\n return refresh && refresh.component === component && refresh.state ? refresh.state : null\n}\n\n/**\n * Copy refreshed values into a form store. The paths, and the fields below them, stop\n * counting as edited (and lose their errors); every other field keeps what the user typed.\n */\nexport function applyFormRefresh(store: FormStore, state: Record<string, unknown>): void {\n const paths = Object.keys(state)\n\n if (paths.length === 0) return\n\n store.store.set((current) => {\n let values = current.values\n const dirty = new Set(current.dirty)\n const errors = { ...current.errors }\n\n for (const path of paths) {\n values = setPath(values, path, state[path])\n\n // A refreshed path replaces everything below it (`content` with its blocks' fields).\n for (const key of [...dirty]) {\n if (key === path || key.startsWith(`${path}.`)) dirty.delete(key)\n }\n\n for (const key of Object.keys(errors)) {\n if (key === path || key.startsWith(`${path}.`)) delete errors[key]\n }\n }\n\n return { ...current, values, dirty, errors }\n })\n}\n\n/**\n * Whether a page form reloads from the server's new schema after a visit it did not submit\n * itself: another action (one embedded in a field, a table row in a relation manager)\n * succeeded while the form held no edits. Its components may show server state that the\n * action changed (a status, which actions are offered); with unsaved edits the form keeps\n * what the user typed.\n */\nexport function shouldResyncForm(\n flash: Pick<AlfakitFlash, 'alfakitAction'>,\n store: Pick<FormStore, 'isDirty'>,\n): boolean {\n return flash.alfakitAction?.status === 'success' && !store.isDirty\n}\n\n// ---------------------------------------------------------------- Wizard pages\n\n/** Whether a page form shows its buttons: always, except before a wizard form's last step. */\nexport function formActionsVisible(\n block: Pick<FormBlock, 'wizard'>,\n progress: WizardProgress | null | undefined,\n): boolean {\n if (!block.wizard || !progress || progress.count === 0) return true\n\n return progress.step >= progress.count - 1\n}\n\n// ---------------------------------------------------------------- Chart filters\n\n/**\n * The URL with a chart widget's filter values in the query string\n * (`chartFilters[{id}][field]=value`, lists as `[field][]`). The widget's previous values\n * are replaced; empty values are left out; other query values stay.\n */\nexport function chartFiltersUrl(\n href: string,\n queryKey: string,\n widgetId: string,\n state: Record<string, unknown> | null,\n): string {\n const url = new URL(href, 'http://localhost')\n const prefix = `${queryKey}[${widgetId}]`\n\n for (const key of [...url.searchParams.keys()]) {\n if (key.startsWith(prefix)) url.searchParams.delete(key)\n }\n\n for (const [field, value] of Object.entries(state ?? {})) {\n if (value === null || value === undefined || value === '') continue\n\n if (Array.isArray(value)) {\n for (const item of value) {\n if (item !== null && item !== undefined)\n url.searchParams.append(`${prefix}[${field}][]`, String(item))\n }\n\n continue\n }\n\n url.searchParams.set(\n `${prefix}[${field}]`,\n typeof value === 'boolean' ? (value ? '1' : '0') : String(value),\n )\n }\n\n const relative = `${url.pathname}${url.search}${url.hash}`\n\n return /^https?:\\/\\//.test(href) ? url.toString() : relative\n}\n"],"mappings":"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"}
@@ -18,7 +18,9 @@ export declare function invalidatePrefetchedPages(): void;
18
18
  export declare function isMutatingRequest(url: string, method: string): boolean;
19
19
  /**
20
20
  * Invalidate prefetched pages and table totals after every Inertia visit that is not a
21
- * GET (actions, form saves). Returns a function that removes the listener.
21
+ * GET (actions, form saves), and reload a page the browser's Back or Forward button
22
+ * restores from history when records changed since it was shown: Inertia restores the
23
+ * saved props without asking the server. Returns a function that removes the listeners.
22
24
  */
23
25
  export declare function installPrefetchInvalidation(): () => void;
24
26
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"prefetch.d.ts","names":[],"sources":["../src/prefetch.ts"],"mappings":";;;;;;qBASa;;;;;wBAMG,kBACd,gCACA,SAAS,YACT;;;;wBAqBc;;wBAWA,kBAAkB,aAAa;;;;;wBAc/B"}
1
+ {"version":3,"file":"prefetch.d.ts","names":[],"sources":["../src/prefetch.ts"],"mappings":";;;;;;qBASa;;;;;wBAMG,kBACd,gCACA,SAAS,YACT;;;;wBA2Bc;;wBAYA,kBAAkB,aAAa;;;;;;;wBAkB/B"}
package/dist/prefetch.js CHANGED
@@ -22,11 +22,16 @@ function prefetchTablePage(identifier, changes, only = ["page"]) {
22
22
  ...resolved === void 0 ? {} : { only: resolved }
23
23
  }, { cacheFor: "10s" });
24
24
  }
25
+ /** Mutations so far; a page last shown before the latest one has stale props. */
26
+ let mutationCount = 0;
27
+ /** The mutation count when each URL was last shown. */
28
+ const shownAt = /* @__PURE__ */ new Map();
25
29
  /**
26
30
  * Drop prefetched pages and mark deferred table totals stale: records may have changed.
27
31
  */
28
32
  function invalidatePrefetchedPages() {
29
33
  if (typeof window === "undefined") return;
34
+ mutationCount++;
30
35
  router.flushAll();
31
36
  invalidateTableTotals();
32
37
  }
@@ -41,15 +46,44 @@ function isMutatingRequest(url, method) {
41
46
  return false;
42
47
  }
43
48
  }
49
+ let installed = false;
44
50
  /**
45
51
  * Invalidate prefetched pages and table totals after every Inertia visit that is not a
46
- * GET (actions, form saves). Returns a function that removes the listener.
52
+ * GET (actions, form saves), and reload a page the browser's Back or Forward button
53
+ * restores from history when records changed since it was shown: Inertia restores the
54
+ * saved props without asking the server. Returns a function that removes the listeners.
47
55
  */
48
56
  function installPrefetchInvalidation() {
49
- if (typeof window === "undefined") return () => {};
50
- return router.on("finish", (event) => {
57
+ if (typeof window === "undefined" || installed) return () => {};
58
+ installed = true;
59
+ let restoring = false;
60
+ const onPopstate = () => {
61
+ restoring = true;
62
+ };
63
+ window.addEventListener("popstate", onPopstate);
64
+ const removeStart = router.on("start", () => {
65
+ restoring = false;
66
+ });
67
+ const removeFinish = router.on("finish", (event) => {
51
68
  if (event.detail.visit.method !== "get") invalidatePrefetchedPages();
52
69
  });
70
+ const removeNavigate = router.on("navigate", (event) => {
71
+ const url = event.detail.page.url;
72
+ const restored = restoring;
73
+ restoring = false;
74
+ if (restored && (shownAt.get(url) ?? -1) < mutationCount) {
75
+ router.reload();
76
+ return;
77
+ }
78
+ shownAt.set(url, mutationCount);
79
+ });
80
+ return () => {
81
+ installed = false;
82
+ window.removeEventListener("popstate", onPopstate);
83
+ removeStart();
84
+ removeFinish();
85
+ removeNavigate();
86
+ };
53
87
  }
54
88
  //#endregion
55
89
  export { PREFETCH_CACHE_FOR, installPrefetchInvalidation, invalidatePrefetchedPages, isMutatingRequest, prefetchTablePage };
@@ -1 +1 @@
1
- {"version":3,"file":"prefetch.js","names":[],"sources":["../src/prefetch.ts"],"sourcesContent":["import { router } from '@inertiajs/core'\nimport { resolvePartialProps } from './partial-props'\nimport { buildTableUrl, type TableQuery } from './table'\nimport { invalidateTableTotals } from './table-totals'\n\n/**\n * How long a prefetched page is reused: navigation links (Inertia `<Link prefetch>`) and\n * a table's next page. Short, and dropped after every mutation.\n */\nexport const PREFETCH_CACHE_FOR = '10s'\n\n/**\n * Prefetch a table page (hovering or focusing \"next\") with the same visit `visitTable()`\n * makes, so the click is answered from the cache. Browser only.\n */\nexport function prefetchTablePage(\n identifier: string | undefined,\n changes: TableQuery,\n only: string[] = ['page'],\n): void {\n if (typeof window === 'undefined') return\n\n const resolved = resolvePartialProps(only)\n\n router.prefetch(\n buildTableUrl(window.location.href, identifier, changes),\n {\n preserveState: true,\n preserveScroll: true,\n replace: true,\n ...(resolved === undefined ? {} : { only: resolved }),\n },\n { cacheFor: PREFETCH_CACHE_FOR },\n )\n}\n\n/**\n * Drop prefetched pages and mark deferred table totals stale: records may have changed.\n */\nexport function invalidatePrefetchedPages(): void {\n if (typeof window === 'undefined') return\n\n router.flushAll()\n invalidateTableTotals()\n}\n\n/** Internal endpoints that change records (actions, inline edits, reordering, undo, page methods). */\nconst MUTATING_ENDPOINT = /\\/(?:actions(?:\\/undo)?|table\\/(?:cell|cells|reorder)|pages\\/call)\\/?$/\n\n/** Whether a successful request to `url` with `method` may have changed records. */\nexport function isMutatingRequest(url: string, method: string): boolean {\n if (method.toUpperCase() === 'GET') return false\n\n try {\n return MUTATING_ENDPOINT.test(new URL(url, 'http://localhost').pathname)\n } catch {\n return false\n }\n}\n\n/**\n * Invalidate prefetched pages and table totals after every Inertia visit that is not a\n * GET (actions, form saves). Returns a function that removes the listener.\n */\nexport function installPrefetchInvalidation(): () => void {\n if (typeof window === 'undefined') return () => {}\n\n return router.on('finish', (event) => {\n if (event.detail.visit.method !== 'get') invalidatePrefetchedPages()\n })\n}\n"],"mappings":";;;;;;;;;AASA,MAAa,qBAAqB;;;;;AAMlC,SAAgB,kBACd,YACA,SACA,OAAiB,CAAC,MAAM,GAClB;CACN,IAAI,OAAO,WAAW,aAAa;CAEnC,MAAM,WAAW,oBAAoB,IAAI;CAEzC,OAAO,SACL,cAAc,OAAO,SAAS,MAAM,YAAY,OAAO,GACvD;EACE,eAAe;EACf,gBAAgB;EAChB,SAAS;EACT,GAAI,aAAa,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM,SAAS;CACrD,GACA,EAAE,UAAA,MAA6B,CACjC;AACF;;;;AAKA,SAAgB,4BAAkC;CAChD,IAAI,OAAO,WAAW,aAAa;CAEnC,OAAO,SAAS;CAChB,sBAAsB;AACxB;;AAGA,MAAM,oBAAoB;;AAG1B,SAAgB,kBAAkB,KAAa,QAAyB;CACtE,IAAI,OAAO,YAAY,MAAM,OAAO,OAAO;CAE3C,IAAI;EACF,OAAO,kBAAkB,KAAK,IAAI,IAAI,KAAK,kBAAkB,CAAC,CAAC,QAAQ;CACzE,QAAQ;EACN,OAAO;CACT;AACF;;;;;AAMA,SAAgB,8BAA0C;CACxD,IAAI,OAAO,WAAW,aAAa,aAAa,CAAC;CAEjD,OAAO,OAAO,GAAG,WAAW,UAAU;EACpC,IAAI,MAAM,OAAO,MAAM,WAAW,OAAO,0BAA0B;CACrE,CAAC;AACH"}
1
+ {"version":3,"file":"prefetch.js","names":[],"sources":["../src/prefetch.ts"],"sourcesContent":["import { router } from '@inertiajs/core'\nimport { resolvePartialProps } from './partial-props'\nimport { buildTableUrl, type TableQuery } from './table'\nimport { invalidateTableTotals } from './table-totals'\n\n/**\n * How long a prefetched page is reused: navigation links (Inertia `<Link prefetch>`) and\n * a table's next page. Short, and dropped after every mutation.\n */\nexport const PREFETCH_CACHE_FOR = '10s'\n\n/**\n * Prefetch a table page (hovering or focusing \"next\") with the same visit `visitTable()`\n * makes, so the click is answered from the cache. Browser only.\n */\nexport function prefetchTablePage(\n identifier: string | undefined,\n changes: TableQuery,\n only: string[] = ['page'],\n): void {\n if (typeof window === 'undefined') return\n\n const resolved = resolvePartialProps(only)\n\n router.prefetch(\n buildTableUrl(window.location.href, identifier, changes),\n {\n preserveState: true,\n preserveScroll: true,\n replace: true,\n ...(resolved === undefined ? {} : { only: resolved }),\n },\n { cacheFor: PREFETCH_CACHE_FOR },\n )\n}\n\n/** Mutations so far; a page last shown before the latest one has stale props. */\nlet mutationCount = 0\n\n/** The mutation count when each URL was last shown. */\nconst shownAt = new Map<string, number>()\n\n/**\n * Drop prefetched pages and mark deferred table totals stale: records may have changed.\n */\nexport function invalidatePrefetchedPages(): void {\n if (typeof window === 'undefined') return\n\n mutationCount++\n router.flushAll()\n invalidateTableTotals()\n}\n\n/** Internal endpoints that change records (actions, inline edits, reordering, undo, page methods). */\nconst MUTATING_ENDPOINT = /\\/(?:actions(?:\\/undo)?|table\\/(?:cell|cells|reorder)|pages\\/call)\\/?$/\n\n/** Whether a successful request to `url` with `method` may have changed records. */\nexport function isMutatingRequest(url: string, method: string): boolean {\n if (method.toUpperCase() === 'GET') return false\n\n try {\n return MUTATING_ENDPOINT.test(new URL(url, 'http://localhost').pathname)\n } catch {\n return false\n }\n}\n\nlet installed = false\n\n/**\n * Invalidate prefetched pages and table totals after every Inertia visit that is not a\n * GET (actions, form saves), and reload a page the browser's Back or Forward button\n * restores from history when records changed since it was shown: Inertia restores the\n * saved props without asking the server. Returns a function that removes the listeners.\n */\nexport function installPrefetchInvalidation(): () => void {\n // Panels and standalone components on one page share the listeners.\n if (typeof window === 'undefined' || installed) return () => {}\n\n installed = true\n let restoring = false\n const onPopstate = (): void => {\n restoring = true\n }\n window.addEventListener('popstate', onPopstate)\n\n // A visit started after the popstate (a hash-only change fires no navigate) is not a restore.\n const removeStart = router.on('start', () => {\n restoring = false\n })\n const removeFinish = router.on('finish', (event) => {\n if (event.detail.visit.method !== 'get') invalidatePrefetchedPages()\n })\n const removeNavigate = router.on('navigate', (event) => {\n const url = event.detail.page.url\n const restored = restoring\n restoring = false\n\n // A URL never shown in this page load may come from before it: reload it too.\n if (restored && (shownAt.get(url) ?? -1) < mutationCount) {\n router.reload()\n return\n }\n\n shownAt.set(url, mutationCount)\n })\n\n return () => {\n installed = false\n window.removeEventListener('popstate', onPopstate)\n removeStart()\n removeFinish()\n removeNavigate()\n }\n}\n"],"mappings":";;;;;;;;;AASA,MAAa,qBAAqB;;;;;AAMlC,SAAgB,kBACd,YACA,SACA,OAAiB,CAAC,MAAM,GAClB;CACN,IAAI,OAAO,WAAW,aAAa;CAEnC,MAAM,WAAW,oBAAoB,IAAI;CAEzC,OAAO,SACL,cAAc,OAAO,SAAS,MAAM,YAAY,OAAO,GACvD;EACE,eAAe;EACf,gBAAgB;EAChB,SAAS;EACT,GAAI,aAAa,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM,SAAS;CACrD,GACA,EAAE,UAAA,MAA6B,CACjC;AACF;;AAGA,IAAI,gBAAgB;;AAGpB,MAAM,0BAAU,IAAI,IAAoB;;;;AAKxC,SAAgB,4BAAkC;CAChD,IAAI,OAAO,WAAW,aAAa;CAEnC;CACA,OAAO,SAAS;CAChB,sBAAsB;AACxB;;AAGA,MAAM,oBAAoB;;AAG1B,SAAgB,kBAAkB,KAAa,QAAyB;CACtE,IAAI,OAAO,YAAY,MAAM,OAAO,OAAO;CAE3C,IAAI;EACF,OAAO,kBAAkB,KAAK,IAAI,IAAI,KAAK,kBAAkB,CAAC,CAAC,QAAQ;CACzE,QAAQ;EACN,OAAO;CACT;AACF;AAEA,IAAI,YAAY;;;;;;;AAQhB,SAAgB,8BAA0C;CAExD,IAAI,OAAO,WAAW,eAAe,WAAW,aAAa,CAAC;CAE9D,YAAY;CACZ,IAAI,YAAY;CAChB,MAAM,mBAAyB;EAC7B,YAAY;CACd;CACA,OAAO,iBAAiB,YAAY,UAAU;CAG9C,MAAM,cAAc,OAAO,GAAG,eAAe;EAC3C,YAAY;CACd,CAAC;CACD,MAAM,eAAe,OAAO,GAAG,WAAW,UAAU;EAClD,IAAI,MAAM,OAAO,MAAM,WAAW,OAAO,0BAA0B;CACrE,CAAC;CACD,MAAM,iBAAiB,OAAO,GAAG,aAAa,UAAU;EACtD,MAAM,MAAM,MAAM,OAAO,KAAK;EAC9B,MAAM,WAAW;EACjB,YAAY;EAGZ,IAAI,aAAa,QAAQ,IAAI,GAAG,KAAK,MAAM,eAAe;GACxD,OAAO,OAAO;GACd;EACF;EAEA,QAAQ,IAAI,KAAK,aAAa;CAChC,CAAC;CAED,aAAa;EACX,YAAY;EACZ,OAAO,oBAAoB,YAAY,UAAU;EACjD,YAAY;EACZ,aAAa;EACb,eAAe;CACjB;AACF"}
package/dist/pwa.d.ts CHANGED
@@ -54,5 +54,17 @@ export declare function subscribeToPush(): Promise<PwaPushOutcome>;
54
54
  export declare function unsubscribeFromPush(): Promise<PwaPushOutcome>;
55
55
  /** Forget everything (tests). */
56
56
  export declare function resetPwa(): void;
57
+ /**
58
+ * Turn Web Push on or off for this browser, and the message to show for the outcome
59
+ * (a translation key, its English fallback, and whether it is an error).
60
+ */
61
+ export declare function togglePushNotifications(): Promise<{
62
+ outcome: PwaPushOutcome;
63
+ message: {
64
+ key: string;
65
+ fallback: string;
66
+ error: boolean;
67
+ };
68
+ }>;
57
69
  //#endregion
58
70
  //# sourceMappingURL=pwa.d.ts.map
package/dist/pwa.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"pwa.d.ts","names":["_$__store0.Store"],"sources":["../src/pwa.ts"],"mappings":";;;;;;;;;;YAYY;iBAEK;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;EACA,MAAM;;EAEN;;YAGU;YAEA;qBAYC,UAAQA,MAAA;;wBAgBL,YAAY,mBAAmB;;wBAU/B;;wBAYA,sBAAsB,gBAAgB,WAAW;;;;;;wBA+DjD,SAAS,KAAK;;;;;wBA8DR,oBAAoB,QAAQ;;wBAiBlC,cAAc,QAAO;;wBAKrB;;wBAcM,mBAAmB,QAAQ;;wBAiC3B,uBAAuB,QAAQ;;wBA2BrC"}
1
+ {"version":3,"file":"pwa.d.ts","names":["_$__store0.Store"],"sources":["../src/pwa.ts"],"mappings":";;;;;;;;;;YAYY;iBAEK;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;EACA,MAAM;;EAEN;;YAGU;YAEA;qBAYC,UAAQA,MAAA;;wBAgBL,YAAY,mBAAmB;;wBAU/B;;wBAYA,sBAAsB,gBAAgB,WAAW;;;;;;wBA+DjD,SAAS,KAAK;;;;;wBA8DR,oBAAoB,QAAQ;;wBAiBlC,cAAc,QAAO;;wBAKrB;;wBAcM,mBAAmB,QAAQ;;wBAiC3B,uBAAuB,QAAQ;;wBA2BrC;;;;;wBAiBM,2BAA2B;EAC/C,SAAS;EACT;IAAW;IAAa;IAAkB"}
package/dist/pwa.js CHANGED
@@ -197,7 +197,34 @@ function resetPwa() {
197
197
  updateTimer = null;
198
198
  pwaStore.set({ ...initialState });
199
199
  }
200
+ /**
201
+ * Turn Web Push on or off for this browser, and the message to show for the outcome
202
+ * (a translation key, its English fallback, and whether it is an error).
203
+ */
204
+ async function togglePushNotifications() {
205
+ const outcome = pwaStore.get().push === "subscribed" ? await unsubscribeFromPush() : await subscribeToPush();
206
+ return {
207
+ outcome,
208
+ message: outcome === "subscribed" ? {
209
+ key: "pwa.notifications.enabled",
210
+ fallback: "Notifications are on for this device",
211
+ error: false
212
+ } : outcome === "unsubscribed" ? {
213
+ key: "pwa.notifications.disabled",
214
+ fallback: "Notifications are off for this device",
215
+ error: false
216
+ } : outcome === "denied" ? {
217
+ key: "pwa.notifications.blocked",
218
+ fallback: "Notifications are blocked in your browser settings",
219
+ error: true
220
+ } : {
221
+ key: "pwa.notifications.failed",
222
+ fallback: "Couldn’t change notifications. Try again.",
223
+ error: true
224
+ }
225
+ };
226
+ }
200
227
  //#endregion
201
- export { applyPwaUpdate, canInstallPwa, isIosSafari, isStandalone, promptPwaInstall, pwaStore, resetPwa, startPwa, subscribeToPush, unsubscribeFromPush, urlBase64ToUint8Array };
228
+ export { applyPwaUpdate, canInstallPwa, isIosSafari, isStandalone, promptPwaInstall, pwaStore, resetPwa, startPwa, subscribeToPush, togglePushNotifications, unsubscribeFromPush, urlBase64ToUint8Array };
202
229
 
203
230
  //# sourceMappingURL=pwa.js.map
package/dist/pwa.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"pwa.js","names":[],"sources":["../src/pwa.ts"],"sourcesContent":["import { requestJson } from './http'\nimport type { PwaSchema } from './schema/pwa'\nimport { createStore } from './store'\n\n/**\n * `Panel::pwa()` in the browser: registers the panel's service worker, keeps the install\n * prompt for an \"Install app\" menu item, notices waiting updates (\"A new version is\n * available — Reload\") and manages this browser's Web Push subscription. Framework\n * agnostic; the renderers read `pwaStore` and call the functions below.\n */\n\n/** This browser's push status: `unsupported` without the Push API (or push off on the panel). */\nexport type PwaPushStatus = 'unsupported' | 'default' | 'subscribed' | 'denied'\n\nexport interface PwaState {\n /** The service worker registered. */\n registered: boolean\n /** The browser offered an install prompt (`beforeinstallprompt`). */\n installable: boolean\n /** Running as the installed app (`display-mode: standalone`). */\n installed: boolean\n /** iOS / iPadOS Safari, not installed: installing is \"Share › Add to Home Screen\". */\n iosHint: boolean\n /** A new service worker waits: offer \"Reload\". */\n updateAvailable: boolean\n push: PwaPushStatus\n /** A push (un)subscription is in flight. */\n pushBusy: boolean\n}\n\nexport type PwaInstallOutcome = 'accepted' | 'dismissed' | 'ios' | 'unavailable'\n\nexport type PwaPushOutcome = 'subscribed' | 'unsubscribed' | 'denied' | 'failed'\n\nconst initialState: PwaState = {\n registered: false,\n installable: false,\n installed: false,\n iosHint: false,\n updateAvailable: false,\n push: 'unsupported',\n pushBusy: false,\n}\n\nexport const pwaStore = createStore<PwaState>({ ...initialState })\n\n/** The `beforeinstallprompt` event, kept until \"Install app\" is chosen. */\ninterface InstallPromptEvent extends Event {\n prompt: () => Promise<void>\n userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>\n}\n\nlet deferredPrompt: InstallPromptEvent | null = null\nlet registration: ServiceWorkerRegistration | null = null\nlet started: string | null = null\nlet schema: PwaSchema | null = null\nlet reloading = false\nlet updateTimer: ReturnType<typeof setInterval> | null = null\n\n/** Whether the user agent is iOS / iPadOS Safari (where installing is manual). */\nexport function isIosSafari(userAgent: string, maxTouchPoints = 0): boolean {\n const ios =\n /iphone|ipad|ipod/i.test(userAgent) || (/macintosh/i.test(userAgent) && maxTouchPoints > 1)\n const safari =\n /safari/i.test(userAgent) && !/crios|fxios|edgios|opios|chrome|android/i.test(userAgent)\n\n return ios && safari\n}\n\n/** Whether the page runs as an installed app. */\nexport function isStandalone(): boolean {\n if (typeof window === 'undefined') return false\n\n const navigatorStandalone = (window.navigator as Navigator & { standalone?: boolean }).standalone\n\n return (\n window.matchMedia?.('(display-mode: standalone)').matches === true ||\n navigatorStandalone === true\n )\n}\n\n/** Base64url (VAPID public key) to the `applicationServerKey` bytes. */\nexport function urlBase64ToUint8Array(value: string): Uint8Array<ArrayBuffer> {\n const padded = (value + '='.repeat((4 - (value.length % 4)) % 4))\n .replace(/-/g, '+')\n .replace(/_/g, '/')\n const raw = atob(padded)\n const bytes = new Uint8Array(new ArrayBuffer(raw.length))\n\n for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)\n\n return bytes\n}\n\nfunction pushSupported(): boolean {\n return (\n typeof window !== 'undefined' &&\n 'serviceWorker' in navigator &&\n 'PushManager' in window &&\n 'Notification' in window\n )\n}\n\nfunction watchWaiting(reg: ServiceWorkerRegistration): void {\n const offer = () => {\n // Only an update: the first install has no controller yet and activates by itself.\n if (reg.waiting && navigator.serviceWorker.controller) {\n pwaStore.update({ updateAvailable: true })\n }\n }\n\n offer()\n\n reg.addEventListener('updatefound', () => {\n const worker = reg.installing\n\n worker?.addEventListener('statechange', () => {\n if (worker.state === 'installed') offer()\n })\n })\n}\n\nasync function refreshPushStatus(): Promise<void> {\n if (!schema?.push || !pushSupported() || !registration) {\n pwaStore.update({ push: 'unsupported' })\n\n return\n }\n\n if (Notification.permission === 'denied') {\n pwaStore.update({ push: 'denied' })\n\n return\n }\n\n const subscription = await registration.pushManager.getSubscription().catch(() => null)\n\n pwaStore.update({ push: subscription ? 'subscribed' : 'default' })\n}\n\n/**\n * Register the service worker and listen for the install prompt. Safe to call on every\n * page: it runs once per service worker URL, and does nothing during a server render or\n * without `pwa`.\n */\nexport function startPwa(pwa: PwaSchema | null | undefined): void {\n if (typeof window === 'undefined' || !pwa) return\n\n schema = pwa\n\n if (started === pwa.serviceWorker) {\n // The user may have signed in or out: push availability follows the panel schema.\n void refreshPushStatus()\n\n return\n }\n\n started = pwa.serviceWorker\n\n const installed = isStandalone()\n\n pwaStore.update({\n installed,\n iosHint: !installed && isIosSafari(navigator.userAgent, navigator.maxTouchPoints ?? 0),\n })\n\n window.addEventListener('beforeinstallprompt', (event) => {\n event.preventDefault()\n deferredPrompt = event as InstallPromptEvent\n pwaStore.update({ installable: true })\n })\n\n window.addEventListener('appinstalled', () => {\n deferredPrompt = null\n pwaStore.update({ installable: false, installed: true, iosHint: false })\n })\n\n if (!('serviceWorker' in navigator)) return\n\n navigator.serviceWorker.addEventListener('controllerchange', () => {\n if (!reloading) return\n\n window.location.reload()\n })\n\n navigator.serviceWorker\n .register(pwa.serviceWorker, { scope: pwa.scope === '/' ? '/' : pwa.scope })\n .then((reg) => {\n registration = reg\n pwaStore.update({ registered: true })\n watchWaiting(reg)\n void refreshPushStatus()\n\n // Long-lived tabs check for a new build every hour.\n if (updateTimer === null) {\n updateTimer = setInterval(() => void reg.update().catch(() => undefined), 60 * 60 * 1000)\n }\n })\n .catch(() => {\n pwaStore.update({ registered: false })\n })\n}\n\n/**\n * \"Install app\": shows the browser's prompt, or tells iOS Safari to explain \"Add to Home\n * Screen\" (the renderer shows `pwa.ios_hint`).\n */\nexport async function promptPwaInstall(): Promise<PwaInstallOutcome> {\n if (deferredPrompt) {\n const prompt = deferredPrompt\n\n deferredPrompt = null\n pwaStore.update({ installable: false })\n\n await prompt.prompt()\n const { outcome } = await prompt.userChoice\n\n return outcome\n }\n\n return pwaStore.get().iosHint ? 'ios' : 'unavailable'\n}\n\n/** Whether \"Install app\" makes sense (a prompt is ready, or the iOS hint applies). */\nexport function canInstallPwa(state: PwaState = pwaStore.get()): boolean {\n return !state.installed && (state.installable || state.iosHint)\n}\n\n/** \"Reload\": activate the waiting service worker, then reload once it took over. */\nexport function applyPwaUpdate(): void {\n const waiting = registration?.waiting\n\n if (!waiting) {\n window.location.reload()\n\n return\n }\n\n reloading = true\n waiting.postMessage({ type: 'SKIP_WAITING' })\n}\n\n/** Subscribe this browser to Web Push (asks for notification permission). */\nexport async function subscribeToPush(): Promise<PwaPushOutcome> {\n if (!schema?.push || !registration || !pushSupported()) return 'failed'\n\n pwaStore.update({ pushBusy: true })\n\n try {\n const permission = await Notification.requestPermission()\n\n if (permission !== 'granted') {\n pwaStore.update({ push: permission === 'denied' ? 'denied' : 'default' })\n\n return permission === 'denied' ? 'denied' : 'failed'\n }\n\n const subscription =\n (await registration.pushManager.getSubscription()) ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: urlBase64ToUint8Array(schema.push.publicKey),\n }))\n\n await requestJson(schema.push.endpoint, { method: 'POST', body: subscription.toJSON() })\n pwaStore.update({ push: 'subscribed' })\n\n return 'subscribed'\n } catch {\n return 'failed'\n } finally {\n pwaStore.update({ pushBusy: false })\n }\n}\n\n/** Unsubscribe this browser from Web Push. */\nexport async function unsubscribeFromPush(): Promise<PwaPushOutcome> {\n if (!schema?.push || !registration) return 'failed'\n\n pwaStore.update({ pushBusy: true })\n\n try {\n const subscription = await registration.pushManager.getSubscription()\n\n if (subscription) {\n await requestJson(schema.push.endpoint, {\n method: 'DELETE',\n body: { endpoint: subscription.endpoint },\n }).catch(() => undefined)\n await subscription.unsubscribe()\n }\n\n pwaStore.update({ push: 'default' })\n\n return 'unsubscribed'\n } catch {\n return 'failed'\n } finally {\n pwaStore.update({ pushBusy: false })\n }\n}\n\n/** Forget everything (tests). */\nexport function resetPwa(): void {\n deferredPrompt = null\n registration = null\n started = null\n schema = null\n reloading = false\n\n if (updateTimer !== null) clearInterval(updateTimer)\n\n updateTimer = null\n pwaStore.set({ ...initialState })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"pwa.js","names":[],"sources":["../src/pwa.ts"],"sourcesContent":["import { requestJson } from './http'\nimport type { PwaSchema } from './schema/pwa'\nimport { createStore } from './store'\n\n/**\n * `Panel::pwa()` in the browser: registers the panel's service worker, keeps the install\n * prompt for an \"Install app\" menu item, notices waiting updates (\"A new version is\n * available — Reload\") and manages this browser's Web Push subscription. Framework\n * agnostic; the renderers read `pwaStore` and call the functions below.\n */\n\n/** This browser's push status: `unsupported` without the Push API (or push off on the panel). */\nexport type PwaPushStatus = 'unsupported' | 'default' | 'subscribed' | 'denied'\n\nexport interface PwaState {\n /** The service worker registered. */\n registered: boolean\n /** The browser offered an install prompt (`beforeinstallprompt`). */\n installable: boolean\n /** Running as the installed app (`display-mode: standalone`). */\n installed: boolean\n /** iOS / iPadOS Safari, not installed: installing is \"Share › Add to Home Screen\". */\n iosHint: boolean\n /** A new service worker waits: offer \"Reload\". */\n updateAvailable: boolean\n push: PwaPushStatus\n /** A push (un)subscription is in flight. */\n pushBusy: boolean\n}\n\nexport type PwaInstallOutcome = 'accepted' | 'dismissed' | 'ios' | 'unavailable'\n\nexport type PwaPushOutcome = 'subscribed' | 'unsubscribed' | 'denied' | 'failed'\n\nconst initialState: PwaState = {\n registered: false,\n installable: false,\n installed: false,\n iosHint: false,\n updateAvailable: false,\n push: 'unsupported',\n pushBusy: false,\n}\n\nexport const pwaStore = createStore<PwaState>({ ...initialState })\n\n/** The `beforeinstallprompt` event, kept until \"Install app\" is chosen. */\ninterface InstallPromptEvent extends Event {\n prompt: () => Promise<void>\n userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>\n}\n\nlet deferredPrompt: InstallPromptEvent | null = null\nlet registration: ServiceWorkerRegistration | null = null\nlet started: string | null = null\nlet schema: PwaSchema | null = null\nlet reloading = false\nlet updateTimer: ReturnType<typeof setInterval> | null = null\n\n/** Whether the user agent is iOS / iPadOS Safari (where installing is manual). */\nexport function isIosSafari(userAgent: string, maxTouchPoints = 0): boolean {\n const ios =\n /iphone|ipad|ipod/i.test(userAgent) || (/macintosh/i.test(userAgent) && maxTouchPoints > 1)\n const safari =\n /safari/i.test(userAgent) && !/crios|fxios|edgios|opios|chrome|android/i.test(userAgent)\n\n return ios && safari\n}\n\n/** Whether the page runs as an installed app. */\nexport function isStandalone(): boolean {\n if (typeof window === 'undefined') return false\n\n const navigatorStandalone = (window.navigator as Navigator & { standalone?: boolean }).standalone\n\n return (\n window.matchMedia?.('(display-mode: standalone)').matches === true ||\n navigatorStandalone === true\n )\n}\n\n/** Base64url (VAPID public key) to the `applicationServerKey` bytes. */\nexport function urlBase64ToUint8Array(value: string): Uint8Array<ArrayBuffer> {\n const padded = (value + '='.repeat((4 - (value.length % 4)) % 4))\n .replace(/-/g, '+')\n .replace(/_/g, '/')\n const raw = atob(padded)\n const bytes = new Uint8Array(new ArrayBuffer(raw.length))\n\n for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)\n\n return bytes\n}\n\nfunction pushSupported(): boolean {\n return (\n typeof window !== 'undefined' &&\n 'serviceWorker' in navigator &&\n 'PushManager' in window &&\n 'Notification' in window\n )\n}\n\nfunction watchWaiting(reg: ServiceWorkerRegistration): void {\n const offer = () => {\n // Only an update: the first install has no controller yet and activates by itself.\n if (reg.waiting && navigator.serviceWorker.controller) {\n pwaStore.update({ updateAvailable: true })\n }\n }\n\n offer()\n\n reg.addEventListener('updatefound', () => {\n const worker = reg.installing\n\n worker?.addEventListener('statechange', () => {\n if (worker.state === 'installed') offer()\n })\n })\n}\n\nasync function refreshPushStatus(): Promise<void> {\n if (!schema?.push || !pushSupported() || !registration) {\n pwaStore.update({ push: 'unsupported' })\n\n return\n }\n\n if (Notification.permission === 'denied') {\n pwaStore.update({ push: 'denied' })\n\n return\n }\n\n const subscription = await registration.pushManager.getSubscription().catch(() => null)\n\n pwaStore.update({ push: subscription ? 'subscribed' : 'default' })\n}\n\n/**\n * Register the service worker and listen for the install prompt. Safe to call on every\n * page: it runs once per service worker URL, and does nothing during a server render or\n * without `pwa`.\n */\nexport function startPwa(pwa: PwaSchema | null | undefined): void {\n if (typeof window === 'undefined' || !pwa) return\n\n schema = pwa\n\n if (started === pwa.serviceWorker) {\n // The user may have signed in or out: push availability follows the panel schema.\n void refreshPushStatus()\n\n return\n }\n\n started = pwa.serviceWorker\n\n const installed = isStandalone()\n\n pwaStore.update({\n installed,\n iosHint: !installed && isIosSafari(navigator.userAgent, navigator.maxTouchPoints ?? 0),\n })\n\n window.addEventListener('beforeinstallprompt', (event) => {\n event.preventDefault()\n deferredPrompt = event as InstallPromptEvent\n pwaStore.update({ installable: true })\n })\n\n window.addEventListener('appinstalled', () => {\n deferredPrompt = null\n pwaStore.update({ installable: false, installed: true, iosHint: false })\n })\n\n if (!('serviceWorker' in navigator)) return\n\n navigator.serviceWorker.addEventListener('controllerchange', () => {\n if (!reloading) return\n\n window.location.reload()\n })\n\n navigator.serviceWorker\n .register(pwa.serviceWorker, { scope: pwa.scope === '/' ? '/' : pwa.scope })\n .then((reg) => {\n registration = reg\n pwaStore.update({ registered: true })\n watchWaiting(reg)\n void refreshPushStatus()\n\n // Long-lived tabs check for a new build every hour.\n if (updateTimer === null) {\n updateTimer = setInterval(() => void reg.update().catch(() => undefined), 60 * 60 * 1000)\n }\n })\n .catch(() => {\n pwaStore.update({ registered: false })\n })\n}\n\n/**\n * \"Install app\": shows the browser's prompt, or tells iOS Safari to explain \"Add to Home\n * Screen\" (the renderer shows `pwa.ios_hint`).\n */\nexport async function promptPwaInstall(): Promise<PwaInstallOutcome> {\n if (deferredPrompt) {\n const prompt = deferredPrompt\n\n deferredPrompt = null\n pwaStore.update({ installable: false })\n\n await prompt.prompt()\n const { outcome } = await prompt.userChoice\n\n return outcome\n }\n\n return pwaStore.get().iosHint ? 'ios' : 'unavailable'\n}\n\n/** Whether \"Install app\" makes sense (a prompt is ready, or the iOS hint applies). */\nexport function canInstallPwa(state: PwaState = pwaStore.get()): boolean {\n return !state.installed && (state.installable || state.iosHint)\n}\n\n/** \"Reload\": activate the waiting service worker, then reload once it took over. */\nexport function applyPwaUpdate(): void {\n const waiting = registration?.waiting\n\n if (!waiting) {\n window.location.reload()\n\n return\n }\n\n reloading = true\n waiting.postMessage({ type: 'SKIP_WAITING' })\n}\n\n/** Subscribe this browser to Web Push (asks for notification permission). */\nexport async function subscribeToPush(): Promise<PwaPushOutcome> {\n if (!schema?.push || !registration || !pushSupported()) return 'failed'\n\n pwaStore.update({ pushBusy: true })\n\n try {\n const permission = await Notification.requestPermission()\n\n if (permission !== 'granted') {\n pwaStore.update({ push: permission === 'denied' ? 'denied' : 'default' })\n\n return permission === 'denied' ? 'denied' : 'failed'\n }\n\n const subscription =\n (await registration.pushManager.getSubscription()) ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: urlBase64ToUint8Array(schema.push.publicKey),\n }))\n\n await requestJson(schema.push.endpoint, { method: 'POST', body: subscription.toJSON() })\n pwaStore.update({ push: 'subscribed' })\n\n return 'subscribed'\n } catch {\n return 'failed'\n } finally {\n pwaStore.update({ pushBusy: false })\n }\n}\n\n/** Unsubscribe this browser from Web Push. */\nexport async function unsubscribeFromPush(): Promise<PwaPushOutcome> {\n if (!schema?.push || !registration) return 'failed'\n\n pwaStore.update({ pushBusy: true })\n\n try {\n const subscription = await registration.pushManager.getSubscription()\n\n if (subscription) {\n await requestJson(schema.push.endpoint, {\n method: 'DELETE',\n body: { endpoint: subscription.endpoint },\n }).catch(() => undefined)\n await subscription.unsubscribe()\n }\n\n pwaStore.update({ push: 'default' })\n\n return 'unsubscribed'\n } catch {\n return 'failed'\n } finally {\n pwaStore.update({ pushBusy: false })\n }\n}\n\n/** Forget everything (tests). */\nexport function resetPwa(): void {\n deferredPrompt = null\n registration = null\n started = null\n schema = null\n reloading = false\n\n if (updateTimer !== null) clearInterval(updateTimer)\n\n updateTimer = null\n pwaStore.set({ ...initialState })\n}\n\n/**\n * Turn Web Push on or off for this browser, and the message to show for the outcome\n * (a translation key, its English fallback, and whether it is an error).\n */\nexport async function togglePushNotifications(): Promise<{\n outcome: PwaPushOutcome\n message: { key: string; fallback: string; error: boolean }\n}> {\n const outcome =\n pwaStore.get().push === 'subscribed' ? await unsubscribeFromPush() : await subscribeToPush()\n\n const message =\n outcome === 'subscribed'\n ? {\n key: 'pwa.notifications.enabled',\n fallback: 'Notifications are on for this device',\n error: false,\n }\n : outcome === 'unsubscribed'\n ? {\n key: 'pwa.notifications.disabled',\n fallback: 'Notifications are off for this device',\n error: false,\n }\n : outcome === 'denied'\n ? {\n key: 'pwa.notifications.blocked',\n fallback: 'Notifications are blocked in your browser settings',\n error: true,\n }\n : {\n key: 'pwa.notifications.failed',\n fallback: 'Couldn’t change notifications. Try again.',\n error: true,\n }\n\n return { outcome, message }\n}\n"],"mappings":"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"}
@@ -241,6 +241,17 @@ export interface SuggestionEditor {
241
241
  * The mention and merge tag popup: call `update(editor)` after every editor transaction
242
242
  * and `handleKeyDown()` from the editor's key handler; render `store` as a listbox.
243
243
  */
244
+ /**
245
+ * Where a `position: fixed` popup placed at viewport coordinates next to `element` must be
246
+ * moved by: a transformed ancestor (a modal's centring `translate`, its zoom animation)
247
+ * becomes the containing block of fixed descendants, so viewport coordinates would be
248
+ * offset by that ancestor's position (the mention list of an editor in a modal showed
249
+ * outside it). Zero when no ancestor does that, and during a server render.
250
+ */
251
+ export declare function fixedContainingBlockOffset(element: Element | null | undefined): {
252
+ x: number;
253
+ y: number;
254
+ };
244
255
  export declare class EditorSuggestions {
245
256
  private readonly options;
246
257
  readonly store: Store<SuggestionState>;
@@ -1 +1 @@
1
- {"version":3,"file":"rich-editor.d.ts","names":[],"sources":["../src/rich-editor.ts"],"mappings":";;;;;;;;;;;;;;;qBAqBa;UAIH;EACR,OAAO;EACP;EACA;;KAGG;EACH;EACA,YAAY,SAAS;EACrB,aAAa,YAAY,4BAA4B;;;wBAevC,YAAY,OAAO;wBAInB,aAAa,OAAO;;wBAKpB;;;;;;;;;;;;;;EAoBT,aAAA,MAAA;IACD,MAAM;IACN,gBAAgB;eACL;EAOQ,aAAA;IAAE,MAAM;;;;wBAOjB;;;;;;;;;;;;EAgBT,aAAA,MAAA;IACD,MAAM;IACN,gBAAgB;eACL;EAOQ,aAAA;IAAE,MAAM;;;iBAMhB;EACf,QAAQ;;;;;EAKR,cAAc,YAAY,QAAQ,4BAA4B,iBAAiB;;EAE/E,SAAS;IAAS;IAAY,QAAQ;IAAyB;;EAC/D;IAAU;IAAc;;;;;;EAKxB;;UAOQ;EACR;EACA;IAAQ,WAAW;;EACnB;IAAS;MAAM,SAAS,cAAc;;;;;;;;;wBA4BxB,sBAAsB,SAAS;;;;;;;;;;MAYhB,YAAA,SAAA,gBAAW;MACP,aAAA,YAAA;;;;;;;;EAW5B,aAAA;IACD,gBAAgB;eACL;EAIH,kBAAA,MAAA,QAAA;IAKN,MAAM;IACN;IACA,QAAQ;;;IAiHI,OAAA,SAAA;;;;;;iBAgBH;EACf;EACA;;;;;;qBAOW;;qBAGA;mBAIQ;mBACA;mBAJF;EAGE,YAAA,MAAM,kBACN;EAGb,eAAe,iBAAiB,iBAAiB,QAAQ;;EAc/D,QAAQ,YAAY,QAAQ,0BAA0B;;EAKtD,YAAY,YAAY,QAAQ,0BAA0B,QAAQ;;EAyBlE,aAAa,YAAY,QAAQ,yBAAyB,cAAc;;;iBAMzD;EACf;EACA,QAAQ;EACR;;EAEA;;wBAGc,oBAAoB,iBAAiB,SAAS;iBAW7C;EACf;EACA;;iBAGe;EACf;EACA;;;;;;;wBAQc,oBACd,oBACA,qBACC;wBAwBa,sBACd,OAAO,kBACP,eACA,iBACC;iBAac;EACf;EACA;EACA;EACA;EACA,OAAO;EACP;EACA;;EAEA;IAAU;IAAc;IAAa;;;UAG7B;EACR,SAAS;EACT,gBAAgB;IAAS;IAAc;KAAc,mBAAmB;EACxE;;;iBAIe;EACf;IACE;MACE;MACA;MACA;QACE;QACA;UACE,YAAY,cAAc,YAAY,yBAAyB;;;;;EAKvE;IAAQ,YAAY;MAAgB;MAAc;MAAa;;;EAC/D,SAAS;;;;;;qBA2BE;mBAUQ;WATH,OAAO,MAAM;UAErB;UAEA;UAEA;EAGW,YAAA;IACf,UAAU;IACV,WAAW;IACX,SAAS,iBAAiB,kBAAkB,QAAQ;;MAIpD,SAAS;cAID;EAOZ,OAAO,QAAQ;;EAqGf,cAAc,OAAO,KAAK,uBAAuB,QAAQ;EA6BzD,UAAU;EAIV,OAAO,eAAe,QAAQ;EAkB9B;EAUA;;;wBAQc,iBAAiB,iBAAiB"}
1
+ {"version":3,"file":"rich-editor.d.ts","names":[],"sources":["../src/rich-editor.ts"],"mappings":";;;;;;;;;;;;;;;qBAqBa;UAIH;EACR,OAAO;EACP;EACA;;KAGG;EACH;EACA,YAAY,SAAS;EACrB,aAAa,YAAY,4BAA4B;;;wBAevC,YAAY,OAAO;wBAInB,aAAa,OAAO;;wBAKpB;;;;;;;;;;;;;;EAoBT,aAAA,MAAA;IACD,MAAM;IACN,gBAAgB;eACL;EAOQ,aAAA;IAAE,MAAM;;;;wBAOjB;;;;;;;;;;;;EAgBT,aAAA,MAAA;IACD,MAAM;IACN,gBAAgB;eACL;EAOQ,aAAA;IAAE,MAAM;;;iBAMhB;EACf,QAAQ;;;;;EAKR,cAAc,YAAY,QAAQ,4BAA4B,iBAAiB;;EAE/E,SAAS;IAAS;IAAY,QAAQ;IAAyB;;EAC/D;IAAU;IAAc;;;;;;EAKxB;;UAOQ;EACR;EACA;IAAQ,WAAW;;EACnB;IAAS;MAAM,SAAS,cAAc;;;;;;;;;wBA4BxB,sBAAsB,SAAS;;;;;;;;;;MAYhB,YAAA,SAAA,gBAAW;MACP,aAAA,YAAA;;;;;;;;EAW5B,aAAA;IACD,gBAAgB;eACL;EAIH,kBAAA,MAAA,QAAA;IAKN,MAAM;IACN;IACA,QAAQ;;;IAiHI,OAAA,SAAA;;;;;;iBAgBH;EACf;EACA;;;;;;qBAOW;;qBAGA;mBAIQ;mBACA;mBAJF;EAGE,YAAA,MAAM,kBACN;EAGb,eAAe,iBAAiB,iBAAiB,QAAQ;;EAc/D,QAAQ,YAAY,QAAQ,0BAA0B;;EAKtD,YAAY,YAAY,QAAQ,0BAA0B,QAAQ;;EAyBlE,aAAa,YAAY,QAAQ,yBAAyB,cAAc;;;iBAMzD;EACf;EACA,QAAQ;EACR;;EAEA;;wBAGc,oBAAoB,iBAAiB,SAAS;iBAW7C;EACf;EACA;;iBAGe;EACf;EACA;;;;;;;wBAQc,oBACd,oBACA,qBACC;wBAwBa,sBACd,OAAO,kBACP,eACA,iBACC;iBAac;EACf;EACA;EACA;EACA;EACA,OAAO;EACP;EACA;;EAEA;IAAU;IAAc;IAAa;;;UAG7B;EACR,SAAS;EACT,gBAAgB;IAAS;IAAc;KAAc,mBAAmB;EACxE;;;iBAIe;EACf;IACE;MACE;MACA;MACA;QACE;QACA;UACE,YAAY,cAAc,YAAY,yBAAyB;;;;;EAKvE;IAAQ,YAAY;MAAgB;MAAc;MAAa;;;EAC/D,SAAS;;;;;;;;;;;;;wBAkCK,2BAA2B,SAAS;EAClD;EACA;;qBAyBW;mBAUQ;WATH,OAAO,MAAM;UAErB;UAEA;UAEA;EAGW,YAAA;IACf,UAAU;IACV,WAAW;IACX,SAAS,iBAAiB,kBAAkB,QAAQ;;MAIpD,SAAS;cAID;EAOZ,OAAO,QAAQ;;EAqGf,cAAc,OAAO,KAAK,uBAAuB,QAAQ;EA6BzD,UAAU;EAIV,OAAO,eAAe,QAAQ;EAkB9B;EAUA;;;wBAQc,iBAAiB,iBAAiB"}
@@ -337,6 +337,34 @@ const CLOSED = {
337
337
  * The mention and merge tag popup: call `update(editor)` after every editor transaction
338
338
  * and `handleKeyDown()` from the editor's key handler; render `store` as a listbox.
339
339
  */
340
+ /**
341
+ * Where a `position: fixed` popup placed at viewport coordinates next to `element` must be
342
+ * moved by: a transformed ancestor (a modal's centring `translate`, its zoom animation)
343
+ * becomes the containing block of fixed descendants, so viewport coordinates would be
344
+ * offset by that ancestor's position (the mention list of an editor in a modal showed
345
+ * outside it). Zero when no ancestor does that, and during a server render.
346
+ */
347
+ function fixedContainingBlockOffset(element) {
348
+ if (!element || typeof window === "undefined") return {
349
+ x: 0,
350
+ y: 0
351
+ };
352
+ for (let node = element.parentElement; node; node = node.parentElement) {
353
+ const style = window.getComputedStyle(node);
354
+ const translate = style.getPropertyValue("translate");
355
+ if (style.transform && style.transform !== "none" || translate !== "" && translate !== "none" || style.filter && style.filter !== "none" || style.perspective && style.perspective !== "none" || /\b(paint|layout|strict|content)\b/.test(style.contain ?? "") || (style.willChange ?? "").includes("transform")) {
356
+ const rect = node.getBoundingClientRect();
357
+ return {
358
+ x: rect.left + node.clientLeft,
359
+ y: rect.top + node.clientTop
360
+ };
361
+ }
362
+ }
363
+ return {
364
+ x: 0,
365
+ y: 0
366
+ };
367
+ }
340
368
  var EditorSuggestions = class {
341
369
  options;
342
370
  store = createStore({ ...CLOSED });
@@ -501,6 +529,6 @@ function toEditorDocument(value) {
501
529
  return null;
502
530
  }
503
531
  //#endregion
504
- export { EditorSuggestions, MERGE_TAG_TRIGGER, NOT_PROSE_CLASS, RichEditorRemote, customBlockNodeConfig, filterSuggestionItems, findSuggestionMatch, isCustomBlockResult, mentionNodeConfig, mentionText, mergeTagNodeConfig, mergeTagText, toEditorDocument };
532
+ export { EditorSuggestions, MERGE_TAG_TRIGGER, NOT_PROSE_CLASS, RichEditorRemote, customBlockNodeConfig, filterSuggestionItems, findSuggestionMatch, fixedContainingBlockOffset, isCustomBlockResult, mentionNodeConfig, mentionText, mergeTagNodeConfig, mergeTagText, toEditorDocument };
505
533
 
506
534
  //# sourceMappingURL=rich-editor.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"rich-editor.js","names":[],"sources":["../src/rich-editor.ts"],"sourcesContent":["import type { FormStore } from './form'\nimport { requestJson } from './http'\nimport type {\n RichEditorCustomBlockSchema,\n RichEditorMentionSchema,\n RichEditorMergeTagSchema,\n} from './schema/components'\nimport { createStore, type Store } from './store'\n\n/**\n * Framework-agnostic parts of the RichEditor extensions: Tiptap node configurations for\n * mentions, merge tags and custom blocks (renderers pass them to `Node.create()`), the\n * suggestion popup state for mentions and merge tags, and the server lookups (mention\n * search and custom block previews through the options endpoint).\n *\n * Stored formats (HTML; JSON documents use the same node names and attributes):\n * - mention: `<span data-type=\"mention\" data-id=\"5\" data-label=\"Jane\" data-char=\"@\">@Jane</span>`\n * - merge tag: `<span data-type=\"mergeTag\" data-id=\"name\">{{ name }}</span>`\n * - custom block: `<div data-type=\"customBlock\" data-id=\"hero\" data-config=\"{…}\"></div>`\n */\n\nexport const MERGE_TAG_TRIGGER = '{{'\n\n// ---------------------------------------------------------------- Node configurations\n\ninterface NodeLike {\n attrs: Record<string, unknown>\n nodeSize: number\n type: unknown\n}\n\ntype AttributeSpec = {\n default: unknown\n parseHTML: (element: HTMLElement) => unknown\n renderHTML: (attributes: Record<string, unknown>) => Record<string, string>\n}\n\nfunction dataAttribute(key: string, name: string, fallback: unknown = null): AttributeSpec {\n return {\n default: fallback,\n parseHTML: (element) => element.getAttribute(name) ?? fallback,\n renderHTML: (attributes) =>\n attributes[key] === null || attributes[key] === undefined\n ? {}\n : { [name]: String(attributes[key]) },\n }\n}\n\n/** The text a mention shows: its trigger character and label. */\nexport function mentionText(attrs: Record<string, unknown>): string {\n return `${String(attrs.char ?? '@')}${String(attrs.label ?? attrs.id ?? '')}`\n}\n\nexport function mergeTagText(attrs: Record<string, unknown>): string {\n return `{{ ${String(attrs.id ?? '')} }}`\n}\n\n/** `Node.create(mentionNodeConfig())`: an inline, atomic mention. */\nexport function mentionNodeConfig() {\n return {\n name: 'mention',\n group: 'inline',\n inline: true,\n atom: true,\n selectable: false,\n addAttributes() {\n return {\n id: dataAttribute('id', 'data-id'),\n label: dataAttribute('label', 'data-label'),\n char: dataAttribute('char', 'data-char', '@'),\n }\n },\n parseHTML() {\n return [{ tag: 'span[data-type=\"mention\"]' }]\n },\n renderHTML({\n node,\n HTMLAttributes,\n }: {\n node: NodeLike\n HTMLAttributes: Record<string, unknown>\n }): [string, Record<string, unknown>, string] {\n return [\n 'span',\n { ...HTMLAttributes, 'data-type': 'mention', class: 'alfakit-mention' },\n mentionText(node.attrs),\n ]\n },\n renderText({ node }: { node: NodeLike }) {\n return mentionText(node.attrs)\n },\n }\n}\n\n/** `Node.create(mergeTagNodeConfig())`: an inline, atomic `{{ tag }}`. */\nexport function mergeTagNodeConfig() {\n return {\n name: 'mergeTag',\n group: 'inline',\n inline: true,\n atom: true,\n selectable: false,\n addAttributes() {\n return { id: dataAttribute('id', 'data-id') }\n },\n parseHTML() {\n return [{ tag: 'span[data-type=\"mergeTag\"]' }]\n },\n renderHTML({\n node,\n HTMLAttributes,\n }: {\n node: NodeLike\n HTMLAttributes: Record<string, unknown>\n }): [string, Record<string, unknown>, string] {\n return [\n 'span',\n { ...HTMLAttributes, 'data-type': 'mergeTag', class: 'alfakit-merge-tag' },\n mergeTagText(node.attrs),\n ]\n },\n renderText({ node }: { node: NodeLike }) {\n return mergeTagText(node.attrs)\n },\n }\n}\n\nexport interface CustomBlockNodeOptions {\n blocks: RichEditorCustomBlockSchema[]\n /**\n * Server-rendered preview HTML of a block configuration, and whether it gets the editor's\n * prose styles (`RichContentCustomBlock::shouldApplyProseStylingToPreview()`).\n */\n loadPreview: (id: string, config: Record<string, unknown>) => Promise<string | CustomBlockPreview>\n /** Open the block's modal to edit the configuration of the block at `pos`. */\n onEdit: (block: { id: string; config: Record<string, unknown>; pos: number }) => void\n labels: { edit: string; remove: string }\n /**\n * `minimalCustomBlockControls()`: compact edit and remove buttons over the preview; the\n * block's label stays for screen readers only.\n */\n minimal?: boolean\n}\n\n/** Inline styles of minimal block controls (the node view is plain DOM, outside Tailwind). */\nconst VISUALLY_HIDDEN =\n 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0'\n\ninterface NodeViewEditor {\n isEditable: boolean\n view: { dispatch: (tr: never) => void }\n state: { tr: { delete: (from: number, to: number) => unknown } }\n}\n\nfunction parseConfig(value: unknown): Record<string, unknown> {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value as Record<string, unknown>\n }\n\n if (typeof value === 'string' && value !== '') {\n try {\n const parsed: unknown = JSON.parse(value)\n\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {}\n } catch {\n return {}\n }\n }\n\n return {}\n}\n\n/**\n * `Node.create(customBlockNodeConfig(options))`: an atomic block that shows the server's\n * preview, with edit and remove buttons. The node view is plain DOM, so every renderer\n * shares it.\n */\nexport function customBlockNodeConfig(options: CustomBlockNodeOptions) {\n return {\n name: 'customBlock',\n group: 'block',\n atom: true,\n draggable: true,\n selectable: true,\n addAttributes() {\n return {\n id: dataAttribute('id', 'data-id'),\n config: {\n default: {},\n parseHTML: (element: HTMLElement) => parseConfig(element.getAttribute('data-config')),\n renderHTML: (attributes: Record<string, unknown>) => ({\n 'data-config': JSON.stringify(parseConfig(attributes.config)),\n }),\n },\n }\n },\n parseHTML() {\n return [{ tag: 'div[data-type=\"customBlock\"]' }]\n },\n renderHTML({\n HTMLAttributes,\n }: {\n HTMLAttributes: Record<string, unknown>\n }): [string, Record<string, unknown>] {\n return ['div', { ...HTMLAttributes, 'data-type': 'customBlock' }]\n },\n addNodeView() {\n return ({\n node,\n getPos,\n editor,\n }: {\n node: NodeLike\n getPos: () => number | undefined\n editor: NodeViewEditor\n }) => createCustomBlockView(node, getPos, editor, options)\n },\n }\n}\n\nfunction createCustomBlockView(\n initial: NodeLike,\n getPos: () => number | undefined,\n editor: NodeViewEditor,\n options: CustomBlockNodeOptions,\n) {\n let node = initial\n const dom = document.createElement('div')\n dom.className = 'alfakit-custom-block'\n dom.setAttribute('data-slot', 'rich-editor-custom-block')\n dom.contentEditable = 'false'\n\n const header = document.createElement('div')\n header.className = 'alfakit-custom-block-header'\n const title = document.createElement('span')\n const controls = document.createElement('span')\n controls.className = 'alfakit-custom-block-controls'\n\n const button = (label: string, onClick: () => void) => {\n const element = document.createElement('button')\n element.type = 'button'\n element.textContent = label\n element.addEventListener('mousedown', (event) => event.preventDefault())\n element.addEventListener('click', (event) => {\n event.preventDefault()\n onClick()\n })\n\n return element\n }\n\n const edit = button(options.labels.edit, () => {\n const pos = getPos()\n const block = options.blocks.find((candidate) => candidate.id === node.attrs.id)\n\n if (pos !== undefined && block?.action.hasModal) {\n options.onEdit({ id: block.id, config: parseConfig(node.attrs.config), pos })\n }\n })\n const remove = button(options.labels.remove, () => {\n const pos = getPos()\n\n if (pos !== undefined) {\n editor.view.dispatch(editor.state.tr.delete(pos, pos + node.nodeSize) as never)\n }\n })\n\n controls.append(edit, remove)\n header.append(title, controls)\n\n const preview = document.createElement('div')\n preview.className = `alfakit-custom-block-preview ${NOT_PROSE_CLASS}`\n\n dom.append(header, preview)\n\n if (options.minimal) {\n dom.setAttribute('data-minimal', '')\n dom.style.position = 'relative'\n // Logical properties: the controls sit at the end side in both directions.\n header.style.cssText =\n 'position:absolute;top:0.25rem;inset-inline-end:0.25rem;z-index:1;padding:0;border:0;background:transparent'\n title.style.cssText = VISUALLY_HIDDEN\n controls.style.cssText =\n 'background:var(--background);border:1px solid var(--border);border-radius:calc(var(--radius) - 2px);padding:0.125rem'\n }\n\n let rendered = ''\n\n const render = () => {\n const id = String(node.attrs.id ?? '')\n const block = options.blocks.find((candidate) => candidate.id === id)\n const config = parseConfig(node.attrs.config)\n const key = `${id}:${JSON.stringify(config)}`\n\n title.textContent = block?.label ?? id\n edit.hidden = !editor.isEditable || !block?.action.hasModal\n remove.hidden = !editor.isEditable\n\n if (key === rendered) return\n\n rendered = key\n preview.setAttribute('aria-busy', 'true')\n\n void options\n .loadPreview(id, config)\n .then((loaded) => {\n // The block may have changed while the preview loaded.\n if (rendered === key) {\n const { html, prose } =\n typeof loaded === 'string' ? { html: loaded, prose: false } : loaded\n\n // Without prose styling the block's own markup is shown as is (Filament's default).\n preview.classList.toggle(NOT_PROSE_CLASS, !prose)\n // Trusted: rendered by the block class on the server.\n preview.innerHTML = html\n }\n })\n .catch(() => {\n if (rendered === key) preview.textContent = block?.label ?? id\n })\n .finally(() => preview.removeAttribute('aria-busy'))\n }\n\n render()\n\n return {\n dom,\n update(updated: NodeLike) {\n if (updated.type !== node.type) return false\n\n node = updated\n render()\n\n return true\n },\n stopEvent: () => false,\n ignoreMutation: () => true,\n }\n}\n\n// ---------------------------------------------------------------- Server lookups\n\n/** A custom block preview: its HTML and whether the editor's prose styles apply to it. */\nexport interface CustomBlockPreview {\n html: string\n prose: boolean\n}\n\n/**\n * The class that keeps the editor's prose styles (`.alfakit-prose`) off a custom block\n * preview, unless the block's `shouldApplyProseStylingToPreview()` returns true.\n */\nexport const NOT_PROSE_CLASS = 'alfakit-not-prose'\n\n/** Mention search and custom block previews for the RichEditor at `path`. */\nexport class RichEditorRemote {\n private readonly previews = new Map<string, Promise<CustomBlockPreview>>()\n\n constructor(\n private readonly form: FormStore | null,\n private readonly path: string,\n ) {}\n\n async searchMentions(trigger: string, search: string): Promise<SuggestionItem[]> {\n if (!this.form?.optionsUrl) return []\n\n const response = await requestJson<{ options?: Array<{ value: string; label: string }> }>(\n this.form.optionsUrl,\n {\n query: { ...this.form.queryContext(), path: this.path, mention: trigger, search },\n },\n )\n\n return (response.options ?? []).map((option) => ({ id: option.value, label: option.label }))\n }\n\n /** The preview HTML of a block configuration. */\n preview(id: string, config: Record<string, unknown>): Promise<string> {\n return this.loadPreview(id, config).then((preview) => preview.html)\n }\n\n /** The preview of a block configuration, with its prose styling flag. */\n loadPreview(id: string, config: Record<string, unknown>): Promise<CustomBlockPreview> {\n const key = `${id}:${JSON.stringify(config)}`\n let pending = this.previews.get(key)\n\n if (!pending) {\n pending = this.form?.optionsUrl\n ? requestJson<{ preview?: string; prose?: boolean }>(this.form.optionsUrl, {\n query: {\n ...this.form.queryContext(),\n path: this.path,\n block: id,\n config: JSON.stringify(config),\n },\n }).then((response) => ({ html: response.preview ?? '', prose: response.prose === true }))\n : Promise.resolve({ html: '', prose: false })\n\n // Failed lookups are retried on the next render.\n pending.catch(() => this.previews.delete(key))\n this.previews.set(key, pending)\n }\n\n return pending\n }\n\n /** Remember a preview the server already sent (with an insert or edit). */\n primePreview(id: string, config: Record<string, unknown>, html: string, prose = false): void {\n this.previews.set(`${id}:${JSON.stringify(config)}`, Promise.resolve({ html, prose }))\n }\n}\n\n/** The result of a custom block's field action: its configuration and preview. */\nexport interface CustomBlockResult {\n id: string\n config: Record<string, unknown>\n preview?: string\n /** `shouldApplyProseStylingToPreview()` of the block for this configuration. */\n prose?: boolean\n}\n\nexport function isCustomBlockResult(value: unknown): value is CustomBlockResult {\n return (\n !!value &&\n typeof value === 'object' &&\n typeof (value as CustomBlockResult).id === 'string' &&\n typeof (value as CustomBlockResult).config === 'object'\n )\n}\n\n// ---------------------------------------------------------------- Suggestions\n\nexport interface SuggestionItem {\n id: string\n label: string\n}\n\nexport interface SuggestionMatch {\n trigger: string\n query: string\n}\n\n/**\n * The trigger and query typed right before the cursor, e.g. `@ja` → `{ '@', 'ja' }`. A\n * trigger counts at the start of the text or after whitespace or an opening bracket, and\n * the query can't contain whitespace.\n */\nexport function findSuggestionMatch(\n textBefore: string,\n triggers: string[],\n): SuggestionMatch | null {\n let best: { trigger: string; index: number } | null = null\n\n for (const trigger of triggers) {\n const index = textBefore.lastIndexOf(trigger)\n\n if (index === -1 || (best && index <= best.index)) continue\n\n const previous = index === 0 ? '' : (textBefore[index - 1] ?? '')\n\n if (previous !== '' && !/[\\s([{\"'\\u00a0]/.test(previous)) continue\n\n const query = textBefore.slice(index + trigger.length)\n\n if (/\\s/.test(query) || query.length > 50) continue\n\n best = { trigger, index }\n }\n\n return best\n ? { trigger: best.trigger, query: textBefore.slice(best.index + best.trigger.length) }\n : null\n}\n\nexport function filterSuggestionItems(\n items: SuggestionItem[],\n query: string,\n limit = 10,\n): SuggestionItem[] {\n const needle = query.toLocaleLowerCase()\n\n return items\n .filter(\n (item) =>\n needle === '' ||\n item.label.toLocaleLowerCase().includes(needle) ||\n item.id.toLocaleLowerCase().includes(needle),\n )\n .slice(0, limit)\n}\n\nexport interface SuggestionState {\n open: boolean\n kind: 'mention' | 'mergeTag' | null\n trigger: string\n query: string\n items: SuggestionItem[]\n index: number\n loading: boolean\n /** Viewport coordinates of the trigger, for a `position: fixed` popup. */\n coords: { left: number; top: number; bottom: number } | null\n}\n\ninterface ChainLike {\n focus(): ChainLike\n insertContentAt(range: { from: number; to: number }, content: unknown): ChainLike\n run(): boolean\n}\n\n/** The parts of a Tiptap editor the suggestions use. */\nexport interface SuggestionEditor {\n state: {\n selection: {\n from: number\n empty: boolean\n $from: {\n parentOffset: number\n parent: {\n textBetween(from: number, to: number, blockSeparator?: string, leafText?: string): string\n }\n }\n }\n }\n view: { coordsAtPos(pos: number): { left: number; top: number; bottom: number } }\n chain(): ChainLike\n}\n\n/** Viewport coordinates of `pos`; null where layout is unavailable (tests, hidden editors). */\nfunction coordsAt(editor: SuggestionEditor, pos: number): SuggestionState['coords'] {\n try {\n return editor.view.coordsAtPos(pos)\n } catch {\n return null\n }\n}\n\nconst CLOSED: SuggestionState = {\n open: false,\n kind: null,\n trigger: '',\n query: '',\n items: [],\n index: 0,\n loading: false,\n coords: null,\n}\n\n/**\n * The mention and merge tag popup: call `update(editor)` after every editor transaction\n * and `handleKeyDown()` from the editor's key handler; render `store` as a listbox.\n */\nexport class EditorSuggestions {\n public readonly store: Store<SuggestionState> = createStore<SuggestionState>({ ...CLOSED })\n\n private timer: ReturnType<typeof setTimeout> | null = null\n\n private request = 0\n\n private range: { from: number; to: number } | null = null\n\n constructor(\n private readonly options: {\n mentions: RichEditorMentionSchema[]\n mergeTags: RichEditorMergeTagSchema[]\n search: (trigger: string, query: string) => Promise<SuggestionItem[]>\n },\n ) {}\n\n get state(): SuggestionState {\n return this.store.get()\n }\n\n private get triggers(): string[] {\n return [\n ...this.options.mentions.map((mention) => mention.trigger),\n ...(this.options.mergeTags.length > 0 ? [MERGE_TAG_TRIGGER] : []),\n ]\n }\n\n update(editor: SuggestionEditor): void {\n const { selection } = editor.state\n\n if (!selection.empty || this.triggers.length === 0) {\n this.close()\n\n return\n }\n\n const { $from } = selection\n const textBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, '')\n const match = findSuggestionMatch(textBefore, this.triggers)\n\n if (!match) {\n this.close()\n\n return\n }\n\n const from = selection.from - match.trigger.length - match.query.length\n this.range = { from, to: selection.from }\n\n const coords = coordsAt(editor, from)\n\n const current = this.state\n\n if (current.open && current.trigger === match.trigger && current.query === match.query) {\n this.store.update({ coords })\n\n return\n }\n\n if (match.trigger === MERGE_TAG_TRIGGER) {\n this.store.set({\n ...CLOSED,\n open: true,\n kind: 'mergeTag',\n trigger: match.trigger,\n query: match.query,\n coords,\n items: filterSuggestionItems(\n this.options.mergeTags.map((tag) => ({ id: tag.id, label: tag.label })),\n match.query,\n ),\n })\n\n return\n }\n\n const mention = this.options.mentions.find((candidate) => candidate.trigger === match.trigger)\n\n if (!mention) {\n this.close()\n\n return\n }\n\n if (!mention.searchable) {\n this.store.set({\n ...CLOSED,\n open: true,\n kind: 'mention',\n trigger: match.trigger,\n query: match.query,\n coords,\n items: filterSuggestionItems(mention.items ?? [], match.query),\n })\n\n return\n }\n\n this.store.set({\n ...CLOSED,\n open: true,\n kind: 'mention',\n trigger: match.trigger,\n query: match.query,\n coords,\n loading: true,\n items: current.open && current.trigger === match.trigger ? current.items : [],\n })\n\n if (this.timer) clearTimeout(this.timer)\n\n const request = ++this.request\n\n this.timer = setTimeout(() => {\n void this.options\n .search(match.trigger, match.query)\n .then((items) => {\n if (request === this.request && this.state.open) {\n this.store.update({ items, index: 0, loading: false })\n }\n })\n .catch(() => {\n if (request === this.request) this.store.update({ items: [], loading: false })\n })\n }, mention.searchDebounce ?? 250)\n }\n\n /** Arrow keys move, Enter/Tab insert, Escape closes. True when the key was handled. */\n handleKeyDown(event: Pick<KeyboardEvent, 'key'>, editor: SuggestionEditor): boolean {\n const state = this.state\n\n if (!state.open) return false\n\n if (event.key === 'Escape') {\n this.close()\n\n return true\n }\n\n if (state.items.length === 0) return false\n\n if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n const step = event.key === 'ArrowDown' ? 1 : -1\n this.store.update({ index: (state.index + step + state.items.length) % state.items.length })\n\n return true\n }\n\n if (event.key === 'Enter' || event.key === 'Tab') {\n this.select(state.index, editor)\n\n return true\n }\n\n return false\n }\n\n highlight(index: number): void {\n this.store.update({ index })\n }\n\n select(index: number, editor: SuggestionEditor): void {\n const state = this.state\n const item = state.items[index]\n\n if (!state.open || !item || !this.range) return\n\n const content =\n state.kind === 'mergeTag'\n ? [{ type: 'mergeTag', attrs: { id: item.id } }]\n : [\n { type: 'mention', attrs: { id: item.id, label: item.label, char: state.trigger } },\n { type: 'text', text: ' ' },\n ]\n\n editor.chain().focus().insertContentAt(this.range, content).run()\n this.close()\n }\n\n close(): void {\n if (this.timer) clearTimeout(this.timer)\n\n this.timer = null\n this.request++\n this.range = null\n\n if (this.state.open) this.store.set({ ...CLOSED })\n }\n\n destroy(): void {\n this.close()\n }\n}\n\n// ---------------------------------------------------------------- JSON storage\n\n/** Whether a JSON-mode state is a Tiptap document the editor can load. */\nexport function toEditorDocument(value: unknown): Record<string, unknown> | null {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value as Record<string, unknown>\n }\n\n if (typeof value === 'string' && value.trim().startsWith('{')) {\n try {\n const parsed: unknown = JSON.parse(value)\n\n return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null\n } catch {\n return null\n }\n }\n\n return null\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"rich-editor.js","names":[],"sources":["../src/rich-editor.ts"],"sourcesContent":["import type { FormStore } from './form'\nimport { requestJson } from './http'\nimport type {\n RichEditorCustomBlockSchema,\n RichEditorMentionSchema,\n RichEditorMergeTagSchema,\n} from './schema/components'\nimport { createStore, type Store } from './store'\n\n/**\n * Framework-agnostic parts of the RichEditor extensions: Tiptap node configurations for\n * mentions, merge tags and custom blocks (renderers pass them to `Node.create()`), the\n * suggestion popup state for mentions and merge tags, and the server lookups (mention\n * search and custom block previews through the options endpoint).\n *\n * Stored formats (HTML; JSON documents use the same node names and attributes):\n * - mention: `<span data-type=\"mention\" data-id=\"5\" data-label=\"Jane\" data-char=\"@\">@Jane</span>`\n * - merge tag: `<span data-type=\"mergeTag\" data-id=\"name\">{{ name }}</span>`\n * - custom block: `<div data-type=\"customBlock\" data-id=\"hero\" data-config=\"{…}\"></div>`\n */\n\nexport const MERGE_TAG_TRIGGER = '{{'\n\n// ---------------------------------------------------------------- Node configurations\n\ninterface NodeLike {\n attrs: Record<string, unknown>\n nodeSize: number\n type: unknown\n}\n\ntype AttributeSpec = {\n default: unknown\n parseHTML: (element: HTMLElement) => unknown\n renderHTML: (attributes: Record<string, unknown>) => Record<string, string>\n}\n\nfunction dataAttribute(key: string, name: string, fallback: unknown = null): AttributeSpec {\n return {\n default: fallback,\n parseHTML: (element) => element.getAttribute(name) ?? fallback,\n renderHTML: (attributes) =>\n attributes[key] === null || attributes[key] === undefined\n ? {}\n : { [name]: String(attributes[key]) },\n }\n}\n\n/** The text a mention shows: its trigger character and label. */\nexport function mentionText(attrs: Record<string, unknown>): string {\n return `${String(attrs.char ?? '@')}${String(attrs.label ?? attrs.id ?? '')}`\n}\n\nexport function mergeTagText(attrs: Record<string, unknown>): string {\n return `{{ ${String(attrs.id ?? '')} }}`\n}\n\n/** `Node.create(mentionNodeConfig())`: an inline, atomic mention. */\nexport function mentionNodeConfig() {\n return {\n name: 'mention',\n group: 'inline',\n inline: true,\n atom: true,\n selectable: false,\n addAttributes() {\n return {\n id: dataAttribute('id', 'data-id'),\n label: dataAttribute('label', 'data-label'),\n char: dataAttribute('char', 'data-char', '@'),\n }\n },\n parseHTML() {\n return [{ tag: 'span[data-type=\"mention\"]' }]\n },\n renderHTML({\n node,\n HTMLAttributes,\n }: {\n node: NodeLike\n HTMLAttributes: Record<string, unknown>\n }): [string, Record<string, unknown>, string] {\n return [\n 'span',\n { ...HTMLAttributes, 'data-type': 'mention', class: 'alfakit-mention' },\n mentionText(node.attrs),\n ]\n },\n renderText({ node }: { node: NodeLike }) {\n return mentionText(node.attrs)\n },\n }\n}\n\n/** `Node.create(mergeTagNodeConfig())`: an inline, atomic `{{ tag }}`. */\nexport function mergeTagNodeConfig() {\n return {\n name: 'mergeTag',\n group: 'inline',\n inline: true,\n atom: true,\n selectable: false,\n addAttributes() {\n return { id: dataAttribute('id', 'data-id') }\n },\n parseHTML() {\n return [{ tag: 'span[data-type=\"mergeTag\"]' }]\n },\n renderHTML({\n node,\n HTMLAttributes,\n }: {\n node: NodeLike\n HTMLAttributes: Record<string, unknown>\n }): [string, Record<string, unknown>, string] {\n return [\n 'span',\n { ...HTMLAttributes, 'data-type': 'mergeTag', class: 'alfakit-merge-tag' },\n mergeTagText(node.attrs),\n ]\n },\n renderText({ node }: { node: NodeLike }) {\n return mergeTagText(node.attrs)\n },\n }\n}\n\nexport interface CustomBlockNodeOptions {\n blocks: RichEditorCustomBlockSchema[]\n /**\n * Server-rendered preview HTML of a block configuration, and whether it gets the editor's\n * prose styles (`RichContentCustomBlock::shouldApplyProseStylingToPreview()`).\n */\n loadPreview: (id: string, config: Record<string, unknown>) => Promise<string | CustomBlockPreview>\n /** Open the block's modal to edit the configuration of the block at `pos`. */\n onEdit: (block: { id: string; config: Record<string, unknown>; pos: number }) => void\n labels: { edit: string; remove: string }\n /**\n * `minimalCustomBlockControls()`: compact edit and remove buttons over the preview; the\n * block's label stays for screen readers only.\n */\n minimal?: boolean\n}\n\n/** Inline styles of minimal block controls (the node view is plain DOM, outside Tailwind). */\nconst VISUALLY_HIDDEN =\n 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0'\n\ninterface NodeViewEditor {\n isEditable: boolean\n view: { dispatch: (tr: never) => void }\n state: { tr: { delete: (from: number, to: number) => unknown } }\n}\n\nfunction parseConfig(value: unknown): Record<string, unknown> {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value as Record<string, unknown>\n }\n\n if (typeof value === 'string' && value !== '') {\n try {\n const parsed: unknown = JSON.parse(value)\n\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {}\n } catch {\n return {}\n }\n }\n\n return {}\n}\n\n/**\n * `Node.create(customBlockNodeConfig(options))`: an atomic block that shows the server's\n * preview, with edit and remove buttons. The node view is plain DOM, so every renderer\n * shares it.\n */\nexport function customBlockNodeConfig(options: CustomBlockNodeOptions) {\n return {\n name: 'customBlock',\n group: 'block',\n atom: true,\n draggable: true,\n selectable: true,\n addAttributes() {\n return {\n id: dataAttribute('id', 'data-id'),\n config: {\n default: {},\n parseHTML: (element: HTMLElement) => parseConfig(element.getAttribute('data-config')),\n renderHTML: (attributes: Record<string, unknown>) => ({\n 'data-config': JSON.stringify(parseConfig(attributes.config)),\n }),\n },\n }\n },\n parseHTML() {\n return [{ tag: 'div[data-type=\"customBlock\"]' }]\n },\n renderHTML({\n HTMLAttributes,\n }: {\n HTMLAttributes: Record<string, unknown>\n }): [string, Record<string, unknown>] {\n return ['div', { ...HTMLAttributes, 'data-type': 'customBlock' }]\n },\n addNodeView() {\n return ({\n node,\n getPos,\n editor,\n }: {\n node: NodeLike\n getPos: () => number | undefined\n editor: NodeViewEditor\n }) => createCustomBlockView(node, getPos, editor, options)\n },\n }\n}\n\nfunction createCustomBlockView(\n initial: NodeLike,\n getPos: () => number | undefined,\n editor: NodeViewEditor,\n options: CustomBlockNodeOptions,\n) {\n let node = initial\n const dom = document.createElement('div')\n dom.className = 'alfakit-custom-block'\n dom.setAttribute('data-slot', 'rich-editor-custom-block')\n dom.contentEditable = 'false'\n\n const header = document.createElement('div')\n header.className = 'alfakit-custom-block-header'\n const title = document.createElement('span')\n const controls = document.createElement('span')\n controls.className = 'alfakit-custom-block-controls'\n\n const button = (label: string, onClick: () => void) => {\n const element = document.createElement('button')\n element.type = 'button'\n element.textContent = label\n element.addEventListener('mousedown', (event) => event.preventDefault())\n element.addEventListener('click', (event) => {\n event.preventDefault()\n onClick()\n })\n\n return element\n }\n\n const edit = button(options.labels.edit, () => {\n const pos = getPos()\n const block = options.blocks.find((candidate) => candidate.id === node.attrs.id)\n\n if (pos !== undefined && block?.action.hasModal) {\n options.onEdit({ id: block.id, config: parseConfig(node.attrs.config), pos })\n }\n })\n const remove = button(options.labels.remove, () => {\n const pos = getPos()\n\n if (pos !== undefined) {\n editor.view.dispatch(editor.state.tr.delete(pos, pos + node.nodeSize) as never)\n }\n })\n\n controls.append(edit, remove)\n header.append(title, controls)\n\n const preview = document.createElement('div')\n preview.className = `alfakit-custom-block-preview ${NOT_PROSE_CLASS}`\n\n dom.append(header, preview)\n\n if (options.minimal) {\n dom.setAttribute('data-minimal', '')\n dom.style.position = 'relative'\n // Logical properties: the controls sit at the end side in both directions.\n header.style.cssText =\n 'position:absolute;top:0.25rem;inset-inline-end:0.25rem;z-index:1;padding:0;border:0;background:transparent'\n title.style.cssText = VISUALLY_HIDDEN\n controls.style.cssText =\n 'background:var(--background);border:1px solid var(--border);border-radius:calc(var(--radius) - 2px);padding:0.125rem'\n }\n\n let rendered = ''\n\n const render = () => {\n const id = String(node.attrs.id ?? '')\n const block = options.blocks.find((candidate) => candidate.id === id)\n const config = parseConfig(node.attrs.config)\n const key = `${id}:${JSON.stringify(config)}`\n\n title.textContent = block?.label ?? id\n edit.hidden = !editor.isEditable || !block?.action.hasModal\n remove.hidden = !editor.isEditable\n\n if (key === rendered) return\n\n rendered = key\n preview.setAttribute('aria-busy', 'true')\n\n void options\n .loadPreview(id, config)\n .then((loaded) => {\n // The block may have changed while the preview loaded.\n if (rendered === key) {\n const { html, prose } =\n typeof loaded === 'string' ? { html: loaded, prose: false } : loaded\n\n // Without prose styling the block's own markup is shown as is (Filament's default).\n preview.classList.toggle(NOT_PROSE_CLASS, !prose)\n // Trusted: rendered by the block class on the server.\n preview.innerHTML = html\n }\n })\n .catch(() => {\n if (rendered === key) preview.textContent = block?.label ?? id\n })\n .finally(() => preview.removeAttribute('aria-busy'))\n }\n\n render()\n\n return {\n dom,\n update(updated: NodeLike) {\n if (updated.type !== node.type) return false\n\n node = updated\n render()\n\n return true\n },\n stopEvent: () => false,\n ignoreMutation: () => true,\n }\n}\n\n// ---------------------------------------------------------------- Server lookups\n\n/** A custom block preview: its HTML and whether the editor's prose styles apply to it. */\nexport interface CustomBlockPreview {\n html: string\n prose: boolean\n}\n\n/**\n * The class that keeps the editor's prose styles (`.alfakit-prose`) off a custom block\n * preview, unless the block's `shouldApplyProseStylingToPreview()` returns true.\n */\nexport const NOT_PROSE_CLASS = 'alfakit-not-prose'\n\n/** Mention search and custom block previews for the RichEditor at `path`. */\nexport class RichEditorRemote {\n private readonly previews = new Map<string, Promise<CustomBlockPreview>>()\n\n constructor(\n private readonly form: FormStore | null,\n private readonly path: string,\n ) {}\n\n async searchMentions(trigger: string, search: string): Promise<SuggestionItem[]> {\n if (!this.form?.optionsUrl) return []\n\n const response = await requestJson<{ options?: Array<{ value: string; label: string }> }>(\n this.form.optionsUrl,\n {\n query: { ...this.form.queryContext(), path: this.path, mention: trigger, search },\n },\n )\n\n return (response.options ?? []).map((option) => ({ id: option.value, label: option.label }))\n }\n\n /** The preview HTML of a block configuration. */\n preview(id: string, config: Record<string, unknown>): Promise<string> {\n return this.loadPreview(id, config).then((preview) => preview.html)\n }\n\n /** The preview of a block configuration, with its prose styling flag. */\n loadPreview(id: string, config: Record<string, unknown>): Promise<CustomBlockPreview> {\n const key = `${id}:${JSON.stringify(config)}`\n let pending = this.previews.get(key)\n\n if (!pending) {\n pending = this.form?.optionsUrl\n ? requestJson<{ preview?: string; prose?: boolean }>(this.form.optionsUrl, {\n query: {\n ...this.form.queryContext(),\n path: this.path,\n block: id,\n config: JSON.stringify(config),\n },\n }).then((response) => ({ html: response.preview ?? '', prose: response.prose === true }))\n : Promise.resolve({ html: '', prose: false })\n\n // Failed lookups are retried on the next render.\n pending.catch(() => this.previews.delete(key))\n this.previews.set(key, pending)\n }\n\n return pending\n }\n\n /** Remember a preview the server already sent (with an insert or edit). */\n primePreview(id: string, config: Record<string, unknown>, html: string, prose = false): void {\n this.previews.set(`${id}:${JSON.stringify(config)}`, Promise.resolve({ html, prose }))\n }\n}\n\n/** The result of a custom block's field action: its configuration and preview. */\nexport interface CustomBlockResult {\n id: string\n config: Record<string, unknown>\n preview?: string\n /** `shouldApplyProseStylingToPreview()` of the block for this configuration. */\n prose?: boolean\n}\n\nexport function isCustomBlockResult(value: unknown): value is CustomBlockResult {\n return (\n !!value &&\n typeof value === 'object' &&\n typeof (value as CustomBlockResult).id === 'string' &&\n typeof (value as CustomBlockResult).config === 'object'\n )\n}\n\n// ---------------------------------------------------------------- Suggestions\n\nexport interface SuggestionItem {\n id: string\n label: string\n}\n\nexport interface SuggestionMatch {\n trigger: string\n query: string\n}\n\n/**\n * The trigger and query typed right before the cursor, e.g. `@ja` → `{ '@', 'ja' }`. A\n * trigger counts at the start of the text or after whitespace or an opening bracket, and\n * the query can't contain whitespace.\n */\nexport function findSuggestionMatch(\n textBefore: string,\n triggers: string[],\n): SuggestionMatch | null {\n let best: { trigger: string; index: number } | null = null\n\n for (const trigger of triggers) {\n const index = textBefore.lastIndexOf(trigger)\n\n if (index === -1 || (best && index <= best.index)) continue\n\n const previous = index === 0 ? '' : (textBefore[index - 1] ?? '')\n\n if (previous !== '' && !/[\\s([{\"'\\u00a0]/.test(previous)) continue\n\n const query = textBefore.slice(index + trigger.length)\n\n if (/\\s/.test(query) || query.length > 50) continue\n\n best = { trigger, index }\n }\n\n return best\n ? { trigger: best.trigger, query: textBefore.slice(best.index + best.trigger.length) }\n : null\n}\n\nexport function filterSuggestionItems(\n items: SuggestionItem[],\n query: string,\n limit = 10,\n): SuggestionItem[] {\n const needle = query.toLocaleLowerCase()\n\n return items\n .filter(\n (item) =>\n needle === '' ||\n item.label.toLocaleLowerCase().includes(needle) ||\n item.id.toLocaleLowerCase().includes(needle),\n )\n .slice(0, limit)\n}\n\nexport interface SuggestionState {\n open: boolean\n kind: 'mention' | 'mergeTag' | null\n trigger: string\n query: string\n items: SuggestionItem[]\n index: number\n loading: boolean\n /** Viewport coordinates of the trigger, for a `position: fixed` popup. */\n coords: { left: number; top: number; bottom: number } | null\n}\n\ninterface ChainLike {\n focus(): ChainLike\n insertContentAt(range: { from: number; to: number }, content: unknown): ChainLike\n run(): boolean\n}\n\n/** The parts of a Tiptap editor the suggestions use. */\nexport interface SuggestionEditor {\n state: {\n selection: {\n from: number\n empty: boolean\n $from: {\n parentOffset: number\n parent: {\n textBetween(from: number, to: number, blockSeparator?: string, leafText?: string): string\n }\n }\n }\n }\n view: { coordsAtPos(pos: number): { left: number; top: number; bottom: number } }\n chain(): ChainLike\n}\n\n/** Viewport coordinates of `pos`; null where layout is unavailable (tests, hidden editors). */\nfunction coordsAt(editor: SuggestionEditor, pos: number): SuggestionState['coords'] {\n try {\n return editor.view.coordsAtPos(pos)\n } catch {\n return null\n }\n}\n\nconst CLOSED: SuggestionState = {\n open: false,\n kind: null,\n trigger: '',\n query: '',\n items: [],\n index: 0,\n loading: false,\n coords: null,\n}\n\n/**\n * The mention and merge tag popup: call `update(editor)` after every editor transaction\n * and `handleKeyDown()` from the editor's key handler; render `store` as a listbox.\n */\n/**\n * Where a `position: fixed` popup placed at viewport coordinates next to `element` must be\n * moved by: a transformed ancestor (a modal's centring `translate`, its zoom animation)\n * becomes the containing block of fixed descendants, so viewport coordinates would be\n * offset by that ancestor's position (the mention list of an editor in a modal showed\n * outside it). Zero when no ancestor does that, and during a server render.\n */\nexport function fixedContainingBlockOffset(element: Element | null | undefined): {\n x: number\n y: number\n} {\n if (!element || typeof window === 'undefined') return { x: 0, y: 0 }\n\n for (let node = element.parentElement; node; node = node.parentElement) {\n const style = window.getComputedStyle(node)\n const translate = style.getPropertyValue('translate')\n\n if (\n (style.transform && style.transform !== 'none') ||\n (translate !== '' && translate !== 'none') ||\n (style.filter && style.filter !== 'none') ||\n (style.perspective && style.perspective !== 'none') ||\n /\\b(paint|layout|strict|content)\\b/.test(style.contain ?? '') ||\n (style.willChange ?? '').includes('transform')\n ) {\n const rect = node.getBoundingClientRect()\n\n return { x: rect.left + node.clientLeft, y: rect.top + node.clientTop }\n }\n }\n\n return { x: 0, y: 0 }\n}\n\nexport class EditorSuggestions {\n public readonly store: Store<SuggestionState> = createStore<SuggestionState>({ ...CLOSED })\n\n private timer: ReturnType<typeof setTimeout> | null = null\n\n private request = 0\n\n private range: { from: number; to: number } | null = null\n\n constructor(\n private readonly options: {\n mentions: RichEditorMentionSchema[]\n mergeTags: RichEditorMergeTagSchema[]\n search: (trigger: string, query: string) => Promise<SuggestionItem[]>\n },\n ) {}\n\n get state(): SuggestionState {\n return this.store.get()\n }\n\n private get triggers(): string[] {\n return [\n ...this.options.mentions.map((mention) => mention.trigger),\n ...(this.options.mergeTags.length > 0 ? [MERGE_TAG_TRIGGER] : []),\n ]\n }\n\n update(editor: SuggestionEditor): void {\n const { selection } = editor.state\n\n if (!selection.empty || this.triggers.length === 0) {\n this.close()\n\n return\n }\n\n const { $from } = selection\n const textBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, '')\n const match = findSuggestionMatch(textBefore, this.triggers)\n\n if (!match) {\n this.close()\n\n return\n }\n\n const from = selection.from - match.trigger.length - match.query.length\n this.range = { from, to: selection.from }\n\n const coords = coordsAt(editor, from)\n\n const current = this.state\n\n if (current.open && current.trigger === match.trigger && current.query === match.query) {\n this.store.update({ coords })\n\n return\n }\n\n if (match.trigger === MERGE_TAG_TRIGGER) {\n this.store.set({\n ...CLOSED,\n open: true,\n kind: 'mergeTag',\n trigger: match.trigger,\n query: match.query,\n coords,\n items: filterSuggestionItems(\n this.options.mergeTags.map((tag) => ({ id: tag.id, label: tag.label })),\n match.query,\n ),\n })\n\n return\n }\n\n const mention = this.options.mentions.find((candidate) => candidate.trigger === match.trigger)\n\n if (!mention) {\n this.close()\n\n return\n }\n\n if (!mention.searchable) {\n this.store.set({\n ...CLOSED,\n open: true,\n kind: 'mention',\n trigger: match.trigger,\n query: match.query,\n coords,\n items: filterSuggestionItems(mention.items ?? [], match.query),\n })\n\n return\n }\n\n this.store.set({\n ...CLOSED,\n open: true,\n kind: 'mention',\n trigger: match.trigger,\n query: match.query,\n coords,\n loading: true,\n items: current.open && current.trigger === match.trigger ? current.items : [],\n })\n\n if (this.timer) clearTimeout(this.timer)\n\n const request = ++this.request\n\n this.timer = setTimeout(() => {\n void this.options\n .search(match.trigger, match.query)\n .then((items) => {\n if (request === this.request && this.state.open) {\n this.store.update({ items, index: 0, loading: false })\n }\n })\n .catch(() => {\n if (request === this.request) this.store.update({ items: [], loading: false })\n })\n }, mention.searchDebounce ?? 250)\n }\n\n /** Arrow keys move, Enter/Tab insert, Escape closes. True when the key was handled. */\n handleKeyDown(event: Pick<KeyboardEvent, 'key'>, editor: SuggestionEditor): boolean {\n const state = this.state\n\n if (!state.open) return false\n\n if (event.key === 'Escape') {\n this.close()\n\n return true\n }\n\n if (state.items.length === 0) return false\n\n if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n const step = event.key === 'ArrowDown' ? 1 : -1\n this.store.update({ index: (state.index + step + state.items.length) % state.items.length })\n\n return true\n }\n\n if (event.key === 'Enter' || event.key === 'Tab') {\n this.select(state.index, editor)\n\n return true\n }\n\n return false\n }\n\n highlight(index: number): void {\n this.store.update({ index })\n }\n\n select(index: number, editor: SuggestionEditor): void {\n const state = this.state\n const item = state.items[index]\n\n if (!state.open || !item || !this.range) return\n\n const content =\n state.kind === 'mergeTag'\n ? [{ type: 'mergeTag', attrs: { id: item.id } }]\n : [\n { type: 'mention', attrs: { id: item.id, label: item.label, char: state.trigger } },\n { type: 'text', text: ' ' },\n ]\n\n editor.chain().focus().insertContentAt(this.range, content).run()\n this.close()\n }\n\n close(): void {\n if (this.timer) clearTimeout(this.timer)\n\n this.timer = null\n this.request++\n this.range = null\n\n if (this.state.open) this.store.set({ ...CLOSED })\n }\n\n destroy(): void {\n this.close()\n }\n}\n\n// ---------------------------------------------------------------- JSON storage\n\n/** Whether a JSON-mode state is a Tiptap document the editor can load. */\nexport function toEditorDocument(value: unknown): Record<string, unknown> | null {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value as Record<string, unknown>\n }\n\n if (typeof value === 'string' && value.trim().startsWith('{')) {\n try {\n const parsed: unknown = JSON.parse(value)\n\n return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null\n } catch {\n return null\n }\n }\n\n return null\n}\n"],"mappings":"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"}
@@ -81,6 +81,13 @@ export interface StarterKitNavigationSchema {
81
81
  brandName: string;
82
82
  /** Empty for tenancy panels outside a tenant (their items link to tenant routes). */
83
83
  groups: NavigationGroupSchema[];
84
+ /**
85
+ * The panel's own user menu links (`userMenuItems()`, plugin entries such as Billing's),
86
+ * for `<AlfakitUserMenuItems />` in the kit's user menu. Links only (entries with server
87
+ * logic or a modal need an AlfaKit page); omitted when there are none, and for tenancy
88
+ * panels outside a tenant.
89
+ */
90
+ userMenu?: MenuItemSchema[];
84
91
  /** What the drop-in header components render before they load. */
85
92
  widgets?: StarterKitWidgetsSchema;
86
93
  }
@@ -1 +1 @@
1
- {"version":3,"file":"panel.d.ts","names":[],"sources":["../../src/schema/panel.ts"],"mappings":"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"}
1
+ {"version":3,"file":"panel.d.ts","names":[],"sources":["../../src/schema/panel.ts"],"mappings":"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"}