@pilotiq/pilotiq 0.13.1 → 0.15.0

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 (54) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +32 -0
  3. package/dist/Page.d.ts +40 -0
  4. package/dist/Page.d.ts.map +1 -1
  5. package/dist/Page.js +32 -0
  6. package/dist/Page.js.map +1 -1
  7. package/dist/fields/MarkdownField.d.ts +22 -11
  8. package/dist/fields/MarkdownField.d.ts.map +1 -1
  9. package/dist/fields/MarkdownField.js +22 -11
  10. package/dist/fields/MarkdownField.js.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/pageData/navigation.d.ts +15 -1
  15. package/dist/pageData/navigation.d.ts.map +1 -1
  16. package/dist/pageData/navigation.js +15 -0
  17. package/dist/pageData/navigation.js.map +1 -1
  18. package/dist/react/AppShell.d.ts +5 -0
  19. package/dist/react/AppShell.d.ts.map +1 -1
  20. package/dist/react/AppShell.js +2 -1
  21. package/dist/react/AppShell.js.map +1 -1
  22. package/dist/react/CustomPageWrapperGate.d.ts +33 -0
  23. package/dist/react/CustomPageWrapperGate.d.ts.map +1 -0
  24. package/dist/react/CustomPageWrapperGate.js +50 -0
  25. package/dist/react/CustomPageWrapperGate.js.map +1 -0
  26. package/dist/react/CustomPageWrapperRegistry.d.ts +34 -0
  27. package/dist/react/CustomPageWrapperRegistry.d.ts.map +1 -0
  28. package/dist/react/CustomPageWrapperRegistry.js +19 -0
  29. package/dist/react/CustomPageWrapperRegistry.js.map +1 -0
  30. package/dist/react/MarkdownEditorRegistry.d.ts +77 -0
  31. package/dist/react/MarkdownEditorRegistry.d.ts.map +1 -0
  32. package/dist/react/MarkdownEditorRegistry.js +17 -0
  33. package/dist/react/MarkdownEditorRegistry.js.map +1 -0
  34. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  35. package/dist/react/fields/MarkdownInput.js +36 -0
  36. package/dist/react/fields/MarkdownInput.js.map +1 -1
  37. package/dist/react/index.d.ts +3 -0
  38. package/dist/react/index.d.ts.map +1 -1
  39. package/dist/react/index.js +3 -0
  40. package/dist/react/index.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/Page.test.ts +64 -0
  43. package/src/Page.ts +60 -0
  44. package/src/fields/MarkdownField.ts +22 -11
  45. package/src/index.ts +1 -1
  46. package/src/pageData/navigation.ts +31 -1
  47. package/src/pageData.test.ts +42 -0
  48. package/src/react/AppShell.tsx +12 -1
  49. package/src/react/CustomPageWrapperGate.tsx +69 -0
  50. package/src/react/CustomPageWrapperRegistry.ts +45 -0
  51. package/src/react/MarkdownEditorRegistry.test.ts +38 -0
  52. package/src/react/MarkdownEditorRegistry.ts +87 -0
  53. package/src/react/fields/MarkdownInput.tsx +88 -0
  54. package/src/react/index.ts +16 -0
package/src/Page.ts CHANGED
@@ -4,6 +4,34 @@ import type { ResourceClass, NavigationBadgeColor, NavigationBadgeHandler } from
4
4
  import type { ClusterClass } from './Cluster.js'
5
5
  import { type IconValue, serializeIcon } from './icons/types.js'
6
6
 
7
+ /**
8
+ * Per-custom-page collab configuration. Custom pages have no record id
9
+ * to derive a room from, so `room` is required and must be supplied as
10
+ * a literal string (e.g. `'team-settings'`). The plugin reads this off
11
+ * the panel-level `pageCollab` map and uses it to seed the page's
12
+ * Y.Doc.
13
+ *
14
+ * room — literal room identifier; namespaced internally so two
15
+ * pages with the same `room` literal collide deliberately.
16
+ * presence — when false, suppress the awareness layer (focus chips,
17
+ * cursor positions) while keeping value-sync. Defaults to true.
18
+ *
19
+ * Field-level `.collab(false)` always wins per field. Resource-bound
20
+ * default pages (List/Create/Edit/View) ignore this — record-scoped
21
+ * collab is governed by `Resource.collab`.
22
+ */
23
+ export interface PageCollabConfig {
24
+ room: string
25
+ presence: boolean
26
+ }
27
+
28
+ /** Raw shape accepted by `static collab` before normalization. Unlike
29
+ * `Resource.collab` there is no `true` shorthand — `room` is required. */
30
+ export type PageCollabInput = {
31
+ room: string
32
+ presence?: boolean
33
+ }
34
+
7
35
  /**
8
36
  * Discriminator the framework uses for default rendering, route generation,
9
37
  * and breadcrumbs. `'custom'` is for standalone Pages that don't belong to
@@ -107,6 +135,38 @@ export class Page {
107
135
  return this._schemaDef !== undefined || this.schema !== Page.schema
108
136
  }
109
137
 
138
+ // ─── Realtime collab opt-in ────────────────────────────────
139
+ // Per-page opt-in for custom Pages. Resource-bound default pages
140
+ // (List/Create/Edit/View) ignore this — record-scoped collab is
141
+ // governed by `Resource.collab`. Set on a custom Page subclass to
142
+ // mount the plugin-registered custom-page wrapper around its content
143
+ // area at runtime.
144
+
145
+ /** Enable collab on this custom page. `room` is required (no recordId
146
+ * to derive one from). Omitting `static collab` keeps the page
147
+ * collab-free even when the `@pilotiq-pro/collab` plugin is installed. */
148
+ static collab: PageCollabInput | null = null
149
+
150
+ /**
151
+ * Normalize `static collab` into the canonical wire shape, or return
152
+ * `null` when the page has not opted in. Centralizes the `presence`
153
+ * default. Resource-bound default pages always return `null` here —
154
+ * use `Resource.collab` for those.
155
+ *
156
+ * Result lands on `panelInfo().pageCollab[slug]`; the gate reads it to
157
+ * decide whether to mount the custom-page wrapper.
158
+ */
159
+ static getResolvedCollabConfig(): PageCollabConfig | null {
160
+ if (this.getMode() !== 'custom') return null
161
+ const raw = this.collab
162
+ if (raw === null || raw === undefined) return null
163
+ if (typeof raw.room !== 'string' || raw.room.length === 0) return null
164
+ return {
165
+ room: raw.room,
166
+ presence: raw.presence ?? true,
167
+ }
168
+ }
169
+
110
170
  /** Plan #10: authorization. Custom pages get a single `canAccess` gate
111
171
  * (no per-record predicates — pages are too freeform to assume a
112
172
  * record concept). Resource-bound default page subclasses can still
@@ -36,20 +36,31 @@ export const DEFAULT_MARKDOWN_TOOLBAR: readonly MarkdownToolbarButton[] = [
36
36
  ] as const
37
37
 
38
38
  /**
39
- * Plain-markdown editor. The wire format is identical to `TextareaField`
40
- * a single string under the field name so no new coerce branch is
41
- * needed. The client mounts a `<textarea>` plus a formatting toolbar and
42
- * a live HTML preview pane (rendered via `marked`).
39
+ * Markdown editor. The wire format is a plain markdown string under the
40
+ * field name same shape as `TextareaField`, so no new coerce branch
41
+ * is needed.
42
+ *
43
+ * **Renderer is pluggable.** When `@pilotiq/tiptap` is installed, its
44
+ * `registerTiptap()` hook wires a WYSIWYG editor that parses the
45
+ * markdown into a Tiptap document, exposes a rich-text toolbar, and
46
+ * serializes back to markdown on every change via `tiptap-markdown`.
47
+ * Editor / Source / Preview tabs let users switch between the WYSIWYG
48
+ * view, raw markdown, and a rendered preview without leaving the form.
49
+ * Collab is automatic — when a `<RecordCollabRoom>` is mounted up-tree
50
+ * the editor binds to the shared `Y.XmlFragment` and all peers see live
51
+ * edits, the same way `RichTextField` does. Without `@pilotiq/tiptap`,
52
+ * `MarkdownInput` falls back to a textarea with a formatting toolbar +
53
+ * a preview tab — works fine for panels that don't install the adapter.
43
54
  *
44
55
  * The toolbar is configurable via `toolbarButtons([…])` /
45
- * `disableToolbarButtons([…])`; pass `[]` to ship a chrome-less textarea
46
- * with just a preview tab.
56
+ * `disableToolbarButtons([…])`; pass `[]` to hide the toolbar entirely.
47
57
  *
48
- * `attachFiles` integrates with the panel's `UploadAdapter` (the same one
49
- * `FileUpload` uses) — the toolbar button + paste-image handler upload
50
- * the file via `POST {base}/_uploads` and splice an `![alt](url)` reference
51
- * at the cursor. When no adapter is registered, the button is stripped
52
- * server-side so apps without uploads never see a broken affordance.
58
+ * `attachFiles` integrates with the panel's `UploadAdapter` (the same
59
+ * one `FileUpload` uses) — the toolbar button uploads the file via
60
+ * `POST {base}/_uploads` and inserts an `![alt](url)` image (or a
61
+ * `[name](url)` link for non-images). When no adapter is registered,
62
+ * the button is stripped server-side so apps without uploads never see
63
+ * a broken affordance.
53
64
  *
54
65
  * @example
55
66
  * ```ts
package/src/index.ts CHANGED
@@ -83,7 +83,7 @@ export {
83
83
  } from './RelationManager.js'
84
84
  export { Global, type GlobalPages, type GlobalClass } from './Global.js'
85
85
  export { Cluster, type ClusterClass } from './Cluster.js'
86
- export { Page, type PageMeta, type PageMode } from './Page.js'
86
+ export { Page, type PageMeta, type PageMode, type PageCollabConfig, type PageCollabInput } from './Page.js'
87
87
  export {
88
88
  // Page base classes — extend these to bind a Page to a Resource.
89
89
  ListPage, CreatePage, EditPage, ViewPage,
@@ -1,5 +1,5 @@
1
1
  import type { Pilotiq, PilotiqConfig } from '../Pilotiq.js'
2
- import type { Page } from '../Page.js'
2
+ import type { Page, PageCollabConfig } from '../Page.js'
3
3
  import type { ResourceClass, NavigationBadgeColor, ResourceCollabConfig } from '../Resource.js'
4
4
  import type { GlobalClass } from '../Global.js'
5
5
  import type { ClusterClass } from '../Cluster.js'
@@ -223,6 +223,34 @@ function buildRecordCollabMap(cfg: Readonly<PilotiqConfig>): RecordCollabMap | u
223
223
  return Object.keys(map).length > 0 ? map : undefined
224
224
  }
225
225
 
226
+ /**
227
+ * Per-custom-page collab opt-in map, keyed by the page's URL slug
228
+ * (cluster-prefixed for clustered pages). `CustomPageWrapperGate`
229
+ * reads this to decide whether to mount the plugin-registered
230
+ * custom-page wrapper (collab room, audit trail, …) around the page
231
+ * content area.
232
+ *
233
+ * Resource-bound default pages (List/Create/Edit/View) never appear
234
+ * here — `Page.getResolvedCollabConfig()` returns `null` for them.
235
+ * Record-scoped collab is governed by `Resource.collab` and lands on
236
+ * `recordCollab`.
237
+ */
238
+ export type PageCollabMap = Record<string, PageCollabConfig>
239
+
240
+ function pageSlugForGate(P: typeof Page): string {
241
+ const slug = P.getSlug()
242
+ return P.cluster ? `${P.cluster.getSlug()}/${slug}` : slug
243
+ }
244
+
245
+ function buildPageCollabMap(cfg: Readonly<PilotiqConfig>): PageCollabMap | undefined {
246
+ const map: PageCollabMap = {}
247
+ for (const P of cfg.pages) {
248
+ const collab = P.getResolvedCollabConfig()
249
+ if (collab) map[pageSlugForGate(P)] = collab
250
+ }
251
+ return Object.keys(map).length > 0 ? map : undefined
252
+ }
253
+
226
254
  export async function panelInfo(
227
255
  pilotiq: Pilotiq,
228
256
  req?: unknown,
@@ -240,6 +268,7 @@ export async function panelInfo(
240
268
  ])
241
269
  const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user)
242
270
  const recordCollab = buildRecordCollabMap(cfg)
271
+ const pageCollab = buildPageCollabMap(cfg)
243
272
  // AI suggestion mode — sparse: omit when 'auto' (the default) so the
244
273
  // wire shape stays minimal for panels that don't opt into review mode.
245
274
  // Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
@@ -256,6 +285,7 @@ export async function panelInfo(
256
285
  ...(databaseNotifications ? { databaseNotifications } : {}),
257
286
  ...(rightSidebar ? { rightSidebar } : {}),
258
287
  ...(recordCollab ? { recordCollab } : {}),
288
+ ...(pageCollab ? { pageCollab } : {}),
259
289
  ...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
260
290
  ...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
261
291
  }
@@ -1406,3 +1406,45 @@ describe('panelInfo — recordCollab map (resource collab opt-in)', () => {
1406
1406
  })
1407
1407
  })
1408
1408
  })
1409
+
1410
+ describe('panelInfo — pageCollab map (custom-page collab opt-in)', () => {
1411
+ it('absent when no page opts in', async () => {
1412
+ class Analytics extends Page {
1413
+ static override slug = 'analytics'
1414
+ static override label = 'Analytics'
1415
+ }
1416
+ const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Analytics]))
1417
+ assert.equal((info as { pageCollab?: unknown }).pageCollab, undefined)
1418
+ })
1419
+
1420
+ it('emits an entry per opted-in custom page keyed by URL slug', async () => {
1421
+ class Settings extends Page {
1422
+ static override slug = 'settings'
1423
+ static override label = 'Settings'
1424
+ static override collab = { room: 'settings-general' }
1425
+ }
1426
+ class Analytics extends Page {
1427
+ static override slug = 'analytics'
1428
+ static override label = 'Analytics'
1429
+ // No collab — should NOT appear in the map.
1430
+ }
1431
+ const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings, Analytics]))
1432
+ const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
1433
+ assert.deepEqual(map, {
1434
+ settings: { room: 'settings-general', presence: true },
1435
+ })
1436
+ })
1437
+
1438
+ it('object form can suppress presence', async () => {
1439
+ class Settings extends Page {
1440
+ static override slug = 'settings'
1441
+ static override label = 'Settings'
1442
+ static override collab = { room: 'settings', presence: false }
1443
+ }
1444
+ const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings]))
1445
+ const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
1446
+ assert.deepEqual(map, {
1447
+ settings: { room: 'settings', presence: false },
1448
+ })
1449
+ })
1450
+ })
@@ -11,6 +11,7 @@ import { RightPanelRegistryProvider } from './right-panel-registry.js'
11
11
  import { RightSidebarProvider, useRightSidebarOptional } from './RightSidebarContext.js'
12
12
  import { RightSidebar } from './RightSidebar.js'
13
13
  import { RecordWrapperGate, type RecordCollabMap } from './RecordWrapperGate.js'
14
+ import { CustomPageWrapperGate, type PageCollabMap } from './CustomPageWrapperGate.js'
14
15
  import { useIsMobile } from './hooks/use-mobile.js'
15
16
  import type { NavItem, UserMenuMeta, DatabaseNotificationsMeta, RightSidebarMeta } from '../pageData.js'
16
17
  import type { RenderHookMap } from '../RenderHook.js'
@@ -39,6 +40,10 @@ export interface AppShellProps {
39
40
  * decide whether to mount the plugin-registered RecordWrapper on
40
41
  * a record edit/view URL. Absent when no resource opted in. */
41
42
  recordCollab?: RecordCollabMap
43
+ /** Per-custom-page collab opt-in map — read by `CustomPageWrapperGate`
44
+ * to decide whether to mount the plugin-registered CustomPageWrapper
45
+ * on a custom-page URL. Absent when no page opted in. */
46
+ pageCollab?: PageCollabMap
42
47
  /** Pre-resolved render-hook slots for the panel chrome (body /
43
48
  * topbar / sidebar / user-menu / footer / head). Sparse map —
44
49
  * slots with no registered entries are absent. Built by
@@ -137,7 +142,13 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
137
142
  {...(props.currentPath !== undefined ? { currentPath: props.currentPath } : {})}
138
143
  {...(props.panel.recordCollab !== undefined ? { recordCollab: props.panel.recordCollab } : {})}
139
144
  >
140
- {props.children}
145
+ <CustomPageWrapperGate
146
+ basePath={props.basePath}
147
+ {...(props.currentPath !== undefined ? { currentPath: props.currentPath } : {})}
148
+ {...(props.panel.pageCollab !== undefined ? { pageCollab: props.panel.pageCollab } : {})}
149
+ >
150
+ {props.children}
151
+ </CustomPageWrapperGate>
141
152
  </RecordWrapperGate>
142
153
  ),
143
154
  }
@@ -0,0 +1,69 @@
1
+ import { type ReactNode } from 'react'
2
+ import { getCustomPageWrapper } from './CustomPageWrapperRegistry.js'
3
+ import type { PageCollabConfig } from '../Page.js'
4
+
5
+ /** Per-custom-page collab opt-in keyed by URL slug (`P.getSlug()` for
6
+ * non-clustered, `${cluster.slug}/${P.getSlug()}` for clustered). Built
7
+ * server-side by `panelInfo()` as `pageCollab`. */
8
+ export type PageCollabMap = Record<string, PageCollabConfig>
9
+
10
+ export interface CustomPageWrapperGateProps {
11
+ currentPath?: string
12
+ basePath: string
13
+ /** Per-page opt-in map. Absent means no custom page opted in — gate
14
+ * always passes through. */
15
+ pageCollab?: PageCollabMap
16
+ children: ReactNode
17
+ }
18
+
19
+ /**
20
+ * Strip `basePath` from `currentPath` and return the remaining slash-
21
+ * joined tail. Returns `null` when the path is empty / doesn't start
22
+ * with `basePath` / has no tail. Mirrors `parseRecordPageUrl`'s
23
+ * normalization (trailing slash on either side is tolerated).
24
+ */
25
+ function pageSlugFromUrl(currentPath: string, basePath: string): string | null {
26
+ if (!currentPath) return null
27
+ const trimmedPath = currentPath.replace(/\/+$/, '')
28
+ const trimmedBase = basePath.replace(/\/+$/, '')
29
+
30
+ if (trimmedBase !== '' && !trimmedPath.startsWith(trimmedBase)) return null
31
+
32
+ const tail = trimmedPath.slice(trimmedBase.length).replace(/^\/+/, '')
33
+ if (tail.length === 0) return null
34
+ return tail
35
+ }
36
+
37
+ /**
38
+ * Conditionally wraps the page tree with the plugin-registered
39
+ * `CustomPageWrapper` when the current URL resolves to a custom page
40
+ * (a `Page` subclass with `static collab = { room: '…' }`).
41
+ * Pass-through in every other case:
42
+ *
43
+ * - no plugin registered a wrapper (`getCustomPageWrapper() === null`)
44
+ * - `currentPath` not yet known on the very first SSR render
45
+ * - `pageCollab` map absent (no page opted in)
46
+ * - the URL tail doesn't match any registered page slug (resource
47
+ * list/edit/view pages, dashboard, theme editor, etc.)
48
+ *
49
+ * Mounted inside `AppShell` around the page content area, beside
50
+ * `RecordWrapperGate`. The two gates are mutually exclusive in
51
+ * practice — record routes have 3+ segments ending in /edit or /view,
52
+ * custom-page routes are 1-2 segments matching a registered page slug.
53
+ */
54
+ export function CustomPageWrapperGate({ currentPath, basePath, pageCollab, children }: CustomPageWrapperGateProps) {
55
+ const Wrapper = getCustomPageWrapper()
56
+ if (!Wrapper || !currentPath || !pageCollab) return <>{children}</>
57
+
58
+ const slug = pageSlugFromUrl(currentPath, basePath)
59
+ if (!slug) return <>{children}</>
60
+
61
+ const cfg = pageCollab[slug]
62
+ if (!cfg) return <>{children}</>
63
+
64
+ return (
65
+ <Wrapper pageSlug={slug} room={cfg.room} presence={cfg.presence}>
66
+ {children}
67
+ </Wrapper>
68
+ )
69
+ }
@@ -0,0 +1,45 @@
1
+ import type { ComponentType, ReactNode } from 'react'
2
+
3
+ /**
4
+ * Props the custom-page wrapper receives from `CustomPageWrapperGate`
5
+ * when the current URL resolves to an opted-in custom page (a `Page`
6
+ * subclass with `static collab = { room: '…' }`).
7
+ *
8
+ * The wrapper owns whatever page-scoped context the plugin provides —
9
+ * `@pilotiq-pro/collab` mounts a collab room here so every collab
10
+ * field inside the page tree shares one Y.Doc + WS connection. Other
11
+ * plugins could mount per-page presence, audit logging, etc.
12
+ *
13
+ * `pageSlug` is the gate's URL slug (cluster-prefixed for clustered
14
+ * pages). `room` is the literal `room` value the page declared on
15
+ * `static collab` — opaque to pilotiq; the plugin is free to namespace
16
+ * it internally before opening the WS.
17
+ */
18
+ export interface CustomPageWrapperProps {
19
+ pageSlug: string
20
+ room: string
21
+ presence: boolean
22
+ children: ReactNode
23
+ }
24
+
25
+ let _component: ComponentType<CustomPageWrapperProps> | null = null
26
+
27
+ /**
28
+ * Register a component that wraps the page tree on every opted-in
29
+ * custom-page route. Called once at boot by a plugin (e.g.
30
+ * `@pilotiq-pro/collab`). No-op when no plugin registers —
31
+ * `CustomPageWrapperGate` passes through unchanged.
32
+ */
33
+ export function registerCustomPageWrapper(C: ComponentType<CustomPageWrapperProps>): void {
34
+ _component = C
35
+ }
36
+
37
+ /** Returns the registered wrapper component, or `null`. */
38
+ export function getCustomPageWrapper(): ComponentType<CustomPageWrapperProps> | null {
39
+ return _component
40
+ }
41
+
42
+ /** Test-only — drops any registered wrapper so tests stay isolated. */
43
+ export function _resetCustomPageWrapper(): void {
44
+ _component = null
45
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ registerMarkdownEditor,
6
+ getMarkdownEditor,
7
+ } from './MarkdownEditorRegistry.js'
8
+
9
+ describe('MarkdownEditorRegistry', () => {
10
+ beforeEach(() => {
11
+ registerMarkdownEditor(null)
12
+ })
13
+
14
+ it('returns null when no editor is registered', () => {
15
+ assert.equal(getMarkdownEditor(), null)
16
+ })
17
+
18
+ it('stores the registered editor component', () => {
19
+ const Component = () => null
20
+ registerMarkdownEditor(Component)
21
+ assert.equal(getMarkdownEditor(), Component)
22
+ })
23
+
24
+ it('clears when called with null', () => {
25
+ const Component = () => null
26
+ registerMarkdownEditor(Component)
27
+ registerMarkdownEditor(null)
28
+ assert.equal(getMarkdownEditor(), null)
29
+ })
30
+
31
+ it('last registration wins', () => {
32
+ const A = () => null
33
+ const B = () => null
34
+ registerMarkdownEditor(A)
35
+ registerMarkdownEditor(B)
36
+ assert.equal(getMarkdownEditor(), B)
37
+ })
38
+ })
@@ -0,0 +1,87 @@
1
+ import type { ComponentType } from 'react'
2
+
3
+ /**
4
+ * Module-level registry slot for the WYSIWYG markdown editor renderer.
5
+ *
6
+ * Wiring posture (mirrors `CollabTextRendererRegistry` /
7
+ * `FormCollabBindingRegistry`):
8
+ * - `@pilotiq/tiptap`'s `registerTiptap(...)` calls `registerMarkdownEditor(...)`
9
+ * once at boot. The registered component closes over `@tiptap/*` +
10
+ * `tiptap-markdown` imports so pilotiq core stays free of any tiptap peer
11
+ * dep — same posture as `CollabTextRendererRegistry`.
12
+ * - `MarkdownInput` checks for the registered component on every mount. When
13
+ * present, it replaces the legacy textarea + manual-toolbar path with a
14
+ * real WYSIWYG editor that serializes to / parses from markdown on the
15
+ * wire. When absent, `MarkdownInput` falls back to today's textarea path so
16
+ * panels that don't install `@pilotiq/tiptap` still get a working editor.
17
+ * - The adapter handles collab itself via the existing `useCollabRoom()` +
18
+ * `getCollabExtensions()` plumbing — when a `<RecordCollabRoom>` is mounted
19
+ * up-tree it binds the editor to the shared `Y.XmlFragment`. Markdown
20
+ * serialization runs locally on every change; only the ProseMirror tree
21
+ * ships over the wire.
22
+ *
23
+ * Wire format on the form remains a plain markdown string under the field
24
+ * `name` — `MarkdownField` doesn't add a new coerce branch. The editor is
25
+ * expected to write the current markdown to a hidden input (or call
26
+ * `onChange` and let the host stamp it) so the form submission round-trip
27
+ * stays identical to the textarea path.
28
+ */
29
+ export interface MarkdownEditorProps {
30
+ /** Field name — drives the hidden form-input name AND the
31
+ * `Y.XmlFragment` selector when collab is active. */
32
+ name: string
33
+ /**
34
+ * Server-rendered default value (raw markdown string). The renderer parses
35
+ * this into the editor's initial document. When collab is active and the
36
+ * room has no persisted state for this field, the renderer also seeds the
37
+ * `Y.XmlFragment` from this value.
38
+ */
39
+ defaultValue: string
40
+ /** Optional placeholder hint shown in the editor when empty. */
41
+ placeholder?: string
42
+ /** Disabled / read-only state. */
43
+ disabled?: boolean
44
+ /** Fired on every editor change with the current serialized markdown. */
45
+ onChange: (markdown: string) => void
46
+ /** Fired on editor blur — host wires this to live-onBlur trigger semantics. */
47
+ onBlur?: () => void
48
+
49
+ /**
50
+ * Configured toolbar buttons. The adapter is free to map these onto its own
51
+ * command set; unknown / unsupported ids are skipped. Empty array hides
52
+ * the toolbar entirely. See `MarkdownField.toolbarButtons()`.
53
+ */
54
+ toolbarButtons: ReadonlyArray<string>
55
+ /** CSS height for the editor's collapsed state. */
56
+ minHeight?: string
57
+ /** CSS cap on grown height. */
58
+ maxHeight?: string
59
+ /** Sub-directory hint forwarded to the upload adapter on paste-image. */
60
+ fileAttachmentsDirectory?: string
61
+ /** Adapter-defined visibility hint (`'public'` or `'private'`). */
62
+ fileAttachmentsVisibility?: string
63
+ /** Panel upload route — stamped by `pageData.uploadCtx` only when an
64
+ * `UploadAdapter` is registered. Absent → adapter hides upload affordances. */
65
+ uploadUrl?: string
66
+ }
67
+
68
+ export type MarkdownEditor = ComponentType<MarkdownEditorProps>
69
+
70
+ let _editor: MarkdownEditor | null = null
71
+
72
+ /**
73
+ * Register the WYSIWYG markdown editor component. Called once at boot by
74
+ * `@pilotiq/tiptap`'s `registerTiptap()` (or directly by an app that imports
75
+ * the renderer). Calling with `null` clears the registry — useful for tests.
76
+ *
77
+ * When no editor is registered, `MarkdownInput` falls back to the textarea +
78
+ * manual-toolbar path that's worked since `MarkdownField` shipped.
79
+ */
80
+ export function registerMarkdownEditor(component: MarkdownEditor | null): void {
81
+ _editor = component
82
+ }
83
+
84
+ /** Returns the registered editor component, or `null` when no adapter is installed. */
85
+ export function getMarkdownEditor(): MarkdownEditor | null {
86
+ return _editor
87
+ }
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { marked } from 'marked'
3
+ import type { MarkdownEditor as MarkdownEditorComponent } from '../MarkdownEditorRegistry.js'
3
4
  import {
4
5
  BoldIcon, ItalicIcon, StrikethroughIcon, LinkIcon,
5
6
  HeadingIcon, ListIcon, ListOrderedIcon, QuoteIcon,
@@ -8,6 +9,7 @@ import {
8
9
  import { useFieldState } from '../FormStateContext.js'
9
10
  import { useCollabRoom } from '../CollabRoomContext.js'
10
11
  import { getCollabTextRenderer, type CollabTextRenderer } from '../CollabTextRendererRegistry.js'
12
+ import { getMarkdownEditor } from '../MarkdownEditorRegistry.js'
11
13
  import { useRowCoords } from '../RowCoordsContext.js'
12
14
  import { parseRowFieldPath } from '../formStateHelpers.js'
13
15
  import { useToast } from '../Toaster.js'
@@ -51,8 +53,37 @@ export function MarkdownInput({
51
53
  const fs = useFieldState(name)
52
54
  const room = useCollabRoom()
53
55
  const collabRenderer = getCollabTextRenderer()
56
+ const markdownEditor = getMarkdownEditor()
54
57
  const rowCoords = useRowCoords()
55
58
 
59
+ // Plug-supplied WYSIWYG markdown editor (typically `@pilotiq/tiptap`'s
60
+ // Tiptap + tiptap-markdown integration). When registered, it replaces
61
+ // BOTH the legacy non-collab textarea path AND the prior collab plain-text
62
+ // path with a single rich editor that handles WYSIWYG editing, markdown
63
+ // serialization, and collab binding (via its own `useCollabRoom()` read)
64
+ // internally. Repeater/Builder row leaves still bypass it — dotted-path
65
+ // field names don't have a stable Y.XmlFragment key today; see
66
+ // `MarkdownCollabInput` below for the prior row-aware path that stays as
67
+ // the fallback inside Repeater/Builder rows.
68
+ const isRowLeaf = name.includes('.')
69
+ if (markdownEditor && !isRowLeaf) {
70
+ return (
71
+ <MarkdownEditorHost
72
+ Editor={markdownEditor}
73
+ name={name}
74
+ defaultValue={defaultValue}
75
+ disabled={disabled}
76
+ {...(placeholder !== undefined ? { placeholder } : {})}
77
+ toolbarButtons={toolbarButtons}
78
+ {...(minHeight !== undefined ? { minHeight } : {})}
79
+ {...(maxHeight !== undefined ? { maxHeight } : {})}
80
+ {...(fileAttachmentsDirectory !== undefined ? { fileAttachmentsDirectory } : {})}
81
+ {...(fileAttachmentsVisibility !== undefined ? { fileAttachmentsVisibility } : {})}
82
+ {...(uploadUrl !== undefined ? { uploadUrl } : {})}
83
+ />
84
+ )
85
+ }
86
+
56
87
  // Tiptap-backed plain-text editor for markdown source when collab is on.
57
88
  // Same architectural fix as `TextLikeInput`'s `CollabTextField`:
58
89
  // y-prosemirror's `RelativePosition` cursor anchoring against a
@@ -457,3 +488,60 @@ function stringValue(v: unknown): string {
457
488
  if (typeof v === 'string') return v
458
489
  return String(v)
459
490
  }
491
+
492
+ /**
493
+ * Bridges the registered adapter editor (`@pilotiq/tiptap`'s `MarkdownEditor`)
494
+ * to pilotiq's form-state + form-submit wire shape. The adapter owns the
495
+ * editor surface (WYSIWYG, toolbar, paste-image, optional source / preview
496
+ * tabs) and its own collab binding via `useCollabRoom()`; this host just
497
+ * threads form-state updates and writes the current markdown to a hidden
498
+ * input so submit picks it up unchanged.
499
+ */
500
+ function MarkdownEditorHost({
501
+ Editor, name, defaultValue, disabled, placeholder,
502
+ toolbarButtons, minHeight, maxHeight,
503
+ fileAttachmentsDirectory, fileAttachmentsVisibility, uploadUrl,
504
+ }: {
505
+ Editor: MarkdownEditorComponent
506
+ name: string
507
+ defaultValue: unknown
508
+ disabled: boolean
509
+ placeholder?: string
510
+ toolbarButtons: ReadonlyArray<string>
511
+ minHeight?: string
512
+ maxHeight?: string
513
+ fileAttachmentsDirectory?: string
514
+ fileAttachmentsVisibility?: string
515
+ uploadUrl?: string
516
+ }): React.ReactElement {
517
+ const fs = useFieldState(name)
518
+ const initial = useMemo(() => stringValue(defaultValue), [])
519
+ const [text, setText] = useState<string>(initial)
520
+
521
+ const handleChange = (next: string): void => {
522
+ setText(next)
523
+ if (fs.controlled) fs.setValue(next)
524
+ fs.triggerLive(next)
525
+ }
526
+ const handleBlur = (): void => { /* live trigger already ran on change */ }
527
+
528
+ return (
529
+ <>
530
+ <input type="hidden" name={name} value={text} readOnly />
531
+ <Editor
532
+ name={name}
533
+ defaultValue={initial}
534
+ disabled={disabled}
535
+ {...(placeholder !== undefined ? { placeholder } : {})}
536
+ toolbarButtons={toolbarButtons}
537
+ {...(minHeight !== undefined ? { minHeight } : {})}
538
+ {...(maxHeight !== undefined ? { maxHeight } : {})}
539
+ {...(fileAttachmentsDirectory !== undefined ? { fileAttachmentsDirectory } : {})}
540
+ {...(fileAttachmentsVisibility !== undefined ? { fileAttachmentsVisibility } : {})}
541
+ {...(uploadUrl !== undefined ? { uploadUrl } : {})}
542
+ onChange={handleChange}
543
+ onBlur={handleBlur}
544
+ />
545
+ </>
546
+ )
547
+ }
@@ -49,6 +49,12 @@ export {
49
49
  type CollabTextRenderer,
50
50
  type CollabTextRendererProps,
51
51
  } from './CollabTextRendererRegistry.js'
52
+ export {
53
+ registerMarkdownEditor,
54
+ getMarkdownEditor,
55
+ type MarkdownEditor,
56
+ type MarkdownEditorProps,
57
+ } from './MarkdownEditorRegistry.js'
52
58
  export {
53
59
  registerFormCollabBinding,
54
60
  getFormCollabBinding,
@@ -78,6 +84,16 @@ export {
78
84
  RecordWrapperGate,
79
85
  type RecordWrapperGateProps,
80
86
  } from './RecordWrapperGate.js'
87
+ export {
88
+ registerCustomPageWrapper,
89
+ getCustomPageWrapper,
90
+ type CustomPageWrapperProps,
91
+ } from './CustomPageWrapperRegistry.js'
92
+ export {
93
+ CustomPageWrapperGate,
94
+ type CustomPageWrapperGateProps,
95
+ type PageCollabMap,
96
+ } from './CustomPageWrapperGate.js'
81
97
  export {
82
98
  parseRecordPageUrl,
83
99
  parseRecordEditUrl,