@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +32 -0
- package/dist/Page.d.ts +40 -0
- package/dist/Page.d.ts.map +1 -1
- package/dist/Page.js +32 -0
- package/dist/Page.js.map +1 -1
- package/dist/fields/MarkdownField.d.ts +22 -11
- package/dist/fields/MarkdownField.d.ts.map +1 -1
- package/dist/fields/MarkdownField.js +22 -11
- package/dist/fields/MarkdownField.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pageData/navigation.d.ts +15 -1
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +15 -0
- package/dist/pageData/navigation.js.map +1 -1
- package/dist/react/AppShell.d.ts +5 -0
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +2 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/CustomPageWrapperGate.d.ts +33 -0
- package/dist/react/CustomPageWrapperGate.d.ts.map +1 -0
- package/dist/react/CustomPageWrapperGate.js +50 -0
- package/dist/react/CustomPageWrapperGate.js.map +1 -0
- package/dist/react/CustomPageWrapperRegistry.d.ts +34 -0
- package/dist/react/CustomPageWrapperRegistry.d.ts.map +1 -0
- package/dist/react/CustomPageWrapperRegistry.js +19 -0
- package/dist/react/CustomPageWrapperRegistry.js.map +1 -0
- package/dist/react/MarkdownEditorRegistry.d.ts +77 -0
- package/dist/react/MarkdownEditorRegistry.d.ts.map +1 -0
- package/dist/react/MarkdownEditorRegistry.js +17 -0
- package/dist/react/MarkdownEditorRegistry.js.map +1 -0
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +36 -0
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Page.test.ts +64 -0
- package/src/Page.ts +60 -0
- package/src/fields/MarkdownField.ts +22 -11
- package/src/index.ts +1 -1
- package/src/pageData/navigation.ts +31 -1
- package/src/pageData.test.ts +42 -0
- package/src/react/AppShell.tsx +12 -1
- package/src/react/CustomPageWrapperGate.tsx +69 -0
- package/src/react/CustomPageWrapperRegistry.ts +45 -0
- package/src/react/MarkdownEditorRegistry.test.ts +38 -0
- package/src/react/MarkdownEditorRegistry.ts +87 -0
- package/src/react/fields/MarkdownInput.tsx +88 -0
- 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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* needed.
|
|
42
|
-
*
|
|
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
|
|
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
|
|
49
|
-
* `FileUpload` uses) — the toolbar button
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* server-side so apps without uploads never see
|
|
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 `` 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
|
}
|
package/src/pageData.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/react/AppShell.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -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,
|