@actuate-media/cms-admin 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -21
  2. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  3. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/comment-mark.test.js +89 -0
  5. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  7. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  9. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  11. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  12. package/dist/__tests__/components/notification-bell.test.js +200 -0
  13. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  15. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  16. package/dist/__tests__/components/offline-status.test.js +56 -0
  17. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  21. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  22. package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
  23. package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
  24. package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
  25. package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
  26. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  27. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  28. package/dist/__tests__/lib/active-editor.test.js +57 -0
  29. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  30. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  31. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  32. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  33. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  34. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  35. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  36. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  37. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  38. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  39. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  40. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  41. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  42. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  43. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  44. package/dist/__tests__/lib/comments-client.test.js +202 -0
  45. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  46. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  47. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  48. package/dist/__tests__/lib/notifications-client.test.js +155 -0
  49. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  50. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  51. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  52. package/dist/__tests__/lib/slash-command.test.js +123 -0
  53. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  54. package/dist/actuate-admin.css +1 -1
  55. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  56. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  57. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  58. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  59. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  60. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  61. package/dist/components/CoauthorResultPopover.js +42 -0
  62. package/dist/components/CoauthorResultPopover.js.map +1 -0
  63. package/dist/components/CommandPalette.d.ts +1 -1
  64. package/dist/components/CommandPalette.d.ts.map +1 -1
  65. package/dist/components/CommandPalette.js +138 -40
  66. package/dist/components/CommandPalette.js.map +1 -1
  67. package/dist/components/CommentMark.d.ts +54 -0
  68. package/dist/components/CommentMark.d.ts.map +1 -0
  69. package/dist/components/CommentMark.js +104 -0
  70. package/dist/components/CommentMark.js.map +1 -0
  71. package/dist/components/CommentSidePanel.d.ts +86 -0
  72. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  73. package/dist/components/CommentSidePanel.js +165 -0
  74. package/dist/components/CommentSidePanel.js.map +1 -0
  75. package/dist/components/LivePreview.d.ts.map +1 -1
  76. package/dist/components/LivePreview.js +80 -16
  77. package/dist/components/LivePreview.js.map +1 -1
  78. package/dist/components/NotificationBell.d.ts +45 -0
  79. package/dist/components/NotificationBell.d.ts.map +1 -0
  80. package/dist/components/NotificationBell.js +90 -0
  81. package/dist/components/NotificationBell.js.map +1 -0
  82. package/dist/components/OfflineStatus.d.ts +39 -0
  83. package/dist/components/OfflineStatus.d.ts.map +1 -0
  84. package/dist/components/OfflineStatus.js +47 -0
  85. package/dist/components/OfflineStatus.js.map +1 -0
  86. package/dist/components/PresenceChips.d.ts +31 -0
  87. package/dist/components/PresenceChips.d.ts.map +1 -0
  88. package/dist/components/PresenceChips.js +58 -0
  89. package/dist/components/PresenceChips.js.map +1 -0
  90. package/dist/components/TipTapEditor.d.ts +61 -1
  91. package/dist/components/TipTapEditor.d.ts.map +1 -1
  92. package/dist/components/TipTapEditor.js +211 -8
  93. package/dist/components/TipTapEditor.js.map +1 -1
  94. package/dist/components/ui/Badge.d.ts +1 -1
  95. package/dist/fields/ComponentBlockField.d.ts +25 -0
  96. package/dist/fields/ComponentBlockField.d.ts.map +1 -0
  97. package/dist/fields/ComponentBlockField.js +74 -0
  98. package/dist/fields/ComponentBlockField.js.map +1 -0
  99. package/dist/fields/FieldRenderer.d.ts +3 -0
  100. package/dist/fields/FieldRenderer.d.ts.map +1 -1
  101. package/dist/fields/FieldRenderer.js +3 -1
  102. package/dist/fields/FieldRenderer.js.map +1 -1
  103. package/dist/fields/PropInput.d.ts +14 -0
  104. package/dist/fields/PropInput.d.ts.map +1 -0
  105. package/dist/fields/PropInput.js +163 -0
  106. package/dist/fields/PropInput.js.map +1 -0
  107. package/dist/fields/component-block-helpers.d.ts +96 -0
  108. package/dist/fields/component-block-helpers.d.ts.map +1 -0
  109. package/dist/fields/component-block-helpers.js +323 -0
  110. package/dist/fields/component-block-helpers.js.map +1 -0
  111. package/dist/fields/index.d.ts +4 -0
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +2 -0
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/index.d.ts +10 -0
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +5 -0
  118. package/dist/index.js.map +1 -1
  119. package/dist/lib/active-editor.d.ts +6 -0
  120. package/dist/lib/active-editor.d.ts.map +1 -0
  121. package/dist/lib/active-editor.js +35 -0
  122. package/dist/lib/active-editor.js.map +1 -0
  123. package/dist/lib/coauthor-client.d.ts +72 -0
  124. package/dist/lib/coauthor-client.d.ts.map +1 -0
  125. package/dist/lib/coauthor-client.js +98 -0
  126. package/dist/lib/coauthor-client.js.map +1 -0
  127. package/dist/lib/collaboration-provider.d.ts +142 -0
  128. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  129. package/dist/lib/collaboration-provider.js +168 -0
  130. package/dist/lib/collaboration-provider.js.map +1 -0
  131. package/dist/lib/comment-anchor.d.ts +86 -0
  132. package/dist/lib/comment-anchor.d.ts.map +1 -0
  133. package/dist/lib/comment-anchor.js +139 -0
  134. package/dist/lib/comment-anchor.js.map +1 -0
  135. package/dist/lib/comments-client.d.ts +106 -0
  136. package/dist/lib/comments-client.d.ts.map +1 -0
  137. package/dist/lib/comments-client.js +138 -0
  138. package/dist/lib/comments-client.js.map +1 -0
  139. package/dist/lib/notifications-client.d.ts +68 -0
  140. package/dist/lib/notifications-client.d.ts.map +1 -0
  141. package/dist/lib/notifications-client.js +85 -0
  142. package/dist/lib/notifications-client.js.map +1 -0
  143. package/dist/lib/slash-command.d.ts +39 -0
  144. package/dist/lib/slash-command.d.ts.map +1 -0
  145. package/dist/lib/slash-command.js +50 -0
  146. package/dist/lib/slash-command.js.map +1 -0
  147. package/package.json +20 -4
  148. package/src/__tests__/components/comment-mark.test.ts +99 -0
  149. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  150. package/src/__tests__/components/notification-bell.test.tsx +213 -0
  151. package/src/__tests__/components/offline-status.test.tsx +66 -0
  152. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  153. package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
  154. package/src/__tests__/lib/active-editor.test.ts +70 -0
  155. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  156. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  157. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  158. package/src/__tests__/lib/comments-client.test.ts +239 -0
  159. package/src/__tests__/lib/notifications-client.test.ts +195 -0
  160. package/src/__tests__/lib/slash-command.test.ts +146 -0
  161. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  162. package/src/components/CoauthorResultPopover.tsx +193 -0
  163. package/src/components/CommandPalette.tsx +397 -209
  164. package/src/components/CommentMark.ts +138 -0
  165. package/src/components/CommentSidePanel.tsx +521 -0
  166. package/src/components/LivePreview.tsx +117 -11
  167. package/src/components/NotificationBell.tsx +242 -0
  168. package/src/components/OfflineStatus.tsx +90 -0
  169. package/src/components/PresenceChips.tsx +150 -0
  170. package/src/components/TipTapEditor.tsx +310 -12
  171. package/src/fields/ComponentBlockField.tsx +179 -0
  172. package/src/fields/FieldRenderer.tsx +8 -0
  173. package/src/fields/PropInput.tsx +552 -0
  174. package/src/fields/component-block-helpers.ts +341 -0
  175. package/src/fields/index.ts +4 -0
  176. package/src/index.ts +27 -0
  177. package/src/lib/active-editor.ts +39 -0
  178. package/src/lib/coauthor-client.ts +160 -0
  179. package/src/lib/collaboration-provider.ts +306 -0
  180. package/src/lib/comment-anchor.ts +179 -0
  181. package/src/lib/comments-client.ts +208 -0
  182. package/src/lib/notifications-client.ts +146 -0
  183. package/src/lib/slash-command.ts +67 -0
@@ -1,8 +1,24 @@
1
1
  'use client'
2
2
 
3
- import React, { useState } from 'react'
3
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { useEditor, EditorContent, type Editor } from '@tiptap/react'
5
5
  import { MediaPickerModal } from './MediaPickerModal.js'
6
+ import { AICoauthorBubbleMenu } from './AICoauthorBubbleMenu.js'
7
+ import { PresenceChips } from './PresenceChips.js'
8
+ import { OfflineStatus } from './OfflineStatus.js'
9
+ import { CommentMark, COMMENT_MARK_STYLES } from './CommentMark.js'
10
+ import { CommentSidePanel } from './CommentSidePanel.js'
11
+ import { clearActiveEditorIf, setActiveEditor } from '../lib/active-editor.js'
12
+ import {
13
+ createCollaborationProvider,
14
+ type CollaborationContext,
15
+ type CollaborationStatus,
16
+ type CollaborationUser,
17
+ type OfflinePersistenceStatus,
18
+ } from '../lib/collaboration-provider.js'
19
+ import { encodeAnchor } from '../lib/comment-anchor.js'
20
+ import type { CommentAnchor } from '../lib/comments-client.js'
21
+ import * as Y from 'yjs'
6
22
  import StarterKit from '@tiptap/starter-kit'
7
23
  import UnderlineExt from '@tiptap/extension-underline'
8
24
  import Link from '@tiptap/extension-link'
@@ -15,6 +31,8 @@ import TextAlign from '@tiptap/extension-text-align'
15
31
  import Placeholder from '@tiptap/extension-placeholder'
16
32
  import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
17
33
  import HorizontalRule from '@tiptap/extension-horizontal-rule'
34
+ import Collaboration from '@tiptap/extension-collaboration'
35
+ import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
18
36
  import { common, createLowlight } from 'lowlight'
19
37
  import {
20
38
  Bold,
@@ -45,12 +63,73 @@ import {
45
63
 
46
64
  const lowlight = createLowlight(common)
47
65
 
66
+ /**
67
+ * Configuration for real-time collaboration on this editor.
68
+ *
69
+ * When supplied the editor switches into CRDT mode: history is delegated to
70
+ * Yjs, edits sync through the cms-core realtime gateway, and a presence
71
+ * strip with live cursors appears beside the toolbar. The `content` prop is
72
+ * ignored in collaboration mode (the gateway is the source of truth);
73
+ * `onChange` still fires on every local update so existing autosave wiring
74
+ * keeps working.
75
+ */
76
+ export interface TipTapCollaborationConfig {
77
+ documentId: string
78
+ /**
79
+ * Base URL of the realtime gateway. `wss://your.app/api/cms/realtime/sync`
80
+ * in production, `ws://localhost:3000/api/cms/realtime/sync` for dev.
81
+ */
82
+ url: string
83
+ user: CollaborationUser
84
+ /** Extra query params (e.g. auth token for cross-origin deployments). */
85
+ params?: Record<string, string>
86
+ }
87
+
48
88
  export interface TipTapEditorProps {
49
89
  content: string
50
90
  onChange: (html: string) => void
51
91
  placeholder?: string
52
92
  editable?: boolean
53
93
  className?: string
94
+ /**
95
+ * Toggle the AI co-author bubble menu on text selection.
96
+ * Defaults to `true` when the editor is editable. Set to `false` to opt
97
+ * out (e.g. for preview-only renders, or environments where the
98
+ * `/ai/coauthor` endpoint is intentionally disabled).
99
+ */
100
+ enableAI?: boolean
101
+ /**
102
+ * Enable real-time collaboration via the cms-core realtime gateway. When
103
+ * set, the editor swaps in Yjs-backed history + the collaboration cursor
104
+ * extension and renders a presence strip above the toolbar.
105
+ */
106
+ collaboration?: TipTapCollaborationConfig
107
+ /**
108
+ * Enable inline comments. When set, the `CommentMark` extension is
109
+ * loaded and a `CommentSidePanel` renders next to the editor. Anchors
110
+ * are encoded against the active Yjs text node when collaboration is
111
+ * also on, so highlights survive concurrent edits.
112
+ *
113
+ * Comments are independent of the collaboration prop — you can attach
114
+ * comments to a static document, but cross-user "live" highlighting
115
+ * only kicks in when collaboration is also enabled.
116
+ */
117
+ comments?: TipTapCommentsConfig
118
+ }
119
+
120
+ /**
121
+ * Configuration for the comments side panel. The panel issues REST calls
122
+ * against the slice-4 endpoints; this prop is just the small set of
123
+ * presentation + identity bits the panel can't infer from the editor.
124
+ */
125
+ export interface TipTapCommentsConfig {
126
+ documentId: string
127
+ /** Logged-in user id — gates edit + delete affordances. */
128
+ currentUserId: string
129
+ /** Set to true to expose moderation actions on every comment. */
130
+ isAdmin?: boolean
131
+ /** Error sink so the host shell can show a toast. */
132
+ onError?: (message: string) => void
54
133
  }
55
134
 
56
135
  const editorStyles = `
@@ -401,14 +480,66 @@ export function TipTapEditor({
401
480
  placeholder,
402
481
  editable = true,
403
482
  className,
483
+ enableAI = true,
484
+ collaboration,
485
+ comments,
404
486
  }: TipTapEditorProps) {
405
487
  const [mediaPickerOpen, setMediaPickerOpen] = useState(false)
488
+ // The collaboration context is created once per (documentId, url) tuple
489
+ // and torn down on prop changes / unmount. We deliberately key on a
490
+ // stringified tuple so swapping users keeps the same Y.Doc (their seat at
491
+ // the document doesn't drop) while swapping documents creates a new one.
492
+ const collabKey = collaboration ? `${collaboration.documentId}::${collaboration.url}` : null
493
+ const collabRef = useRef<CollaborationContext | null>(null)
494
+ const [collabStatus, setCollabStatus] = useState<CollaborationStatus>('connecting')
495
+ const [offlineStatus, setOfflineStatus] = useState<OfflinePersistenceStatus>('pending')
496
+ useEffect(() => {
497
+ if (!collaboration) {
498
+ collabRef.current = null
499
+ return
500
+ }
501
+ const ctx = createCollaborationProvider({
502
+ documentId: collaboration.documentId,
503
+ url: collaboration.url,
504
+ user: collaboration.user,
505
+ params: collaboration.params,
506
+ onStatus: setCollabStatus,
507
+ onOfflineStatus: setOfflineStatus,
508
+ })
509
+ collabRef.current = ctx
510
+ return () => {
511
+ ctx.destroy()
512
+ if (collabRef.current === ctx) collabRef.current = null
513
+ }
514
+ // We rely on collabKey to scope effect identity. user / params are
515
+ // tolerated to mutate freely; the Yjs awareness state is updated
516
+ // separately in the awareness-seeding effect below.
517
+ // eslint-disable-next-line react-hooks/exhaustive-deps
518
+ }, [collabKey])
519
+
520
+ // Keep the local awareness state in sync with user info changes without
521
+ // tearing down the collaboration session.
522
+ useEffect(() => {
523
+ if (!collaboration || !collabRef.current) return
524
+ collabRef.current.awareness.setLocalState({ user: collaboration.user })
525
+ }, [
526
+ collaboration?.user.name,
527
+ collaboration?.user.color,
528
+ collaboration?.user.avatarUrl,
529
+ collaboration?.user.userId,
530
+ collaboration,
531
+ ])
406
532
 
407
- const editor = useEditor({
408
- extensions: [
533
+ const extensions = useMemo(() => {
534
+ // The TipTap extension union is hostile to mutation (the inferred
535
+ // element type is too narrow). We collect into `unknown[]` and let
536
+ // `useEditor`'s prop typing carry the contract from the call site.
537
+ const base: unknown[] = [
409
538
  StarterKit.configure({
410
539
  codeBlock: false,
411
540
  horizontalRule: false,
541
+ // History is owned by Yjs when collaboration is enabled.
542
+ history: collaboration ? false : undefined,
412
543
  }),
413
544
  UnderlineExt,
414
545
  Link.configure({
@@ -429,13 +560,54 @@ export function TipTapEditor({
429
560
  }),
430
561
  CodeBlockLowlight.configure({ lowlight }),
431
562
  HorizontalRule,
432
- ],
433
- content,
434
- editable: editable !== false,
435
- onUpdate: ({ editor: ed }) => {
436
- onChange(ed.getHTML())
563
+ ]
564
+ if (collaboration && collabRef.current) {
565
+ base.push(
566
+ Collaboration.configure({ document: collabRef.current.ydoc }),
567
+ CollaborationCursor.configure({
568
+ provider: collabRef.current.provider,
569
+ user: collaboration.user,
570
+ }),
571
+ )
572
+ }
573
+ if (comments) {
574
+ base.push(CommentMark)
575
+ }
576
+ return base
577
+ // Re-derive extensions when the collaboration session or comments
578
+ // toggle changes.
579
+ // eslint-disable-next-line react-hooks/exhaustive-deps
580
+ }, [collabKey, placeholder, comments != null])
581
+
582
+ const editor = useEditor(
583
+ {
584
+ // TipTap's `Extensions` type is the union of every loaded extension's
585
+ // typed shape, which we can't satisfy when we conditionally append
586
+ // collaboration extensions. The runtime contract is correct — TipTap
587
+ // duck-types — so we cast through the narrow `any` slot.
588
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
589
+ extensions: extensions as any[],
590
+ // When collaboration is enabled the Y.Doc is the source of truth; we
591
+ // pass `content: ''` to avoid double-applying the initial HTML.
592
+ content: collaboration ? '' : content,
593
+ editable: editable !== false,
594
+ onUpdate: ({ editor: ed }) => {
595
+ onChange(ed.getHTML())
596
+ },
597
+ onFocus: ({ editor: ed }) => {
598
+ setActiveEditor(ed)
599
+ },
437
600
  },
438
- })
601
+ // Force re-init when collaboration extensions change.
602
+ [collabKey],
603
+ )
604
+
605
+ useEffect(() => {
606
+ if (!editor) return
607
+ return () => {
608
+ clearActiveEditorIf(editor)
609
+ }
610
+ }, [editor])
439
611
 
440
612
  if (!editor) return null
441
613
 
@@ -447,13 +619,55 @@ export function TipTapEditor({
447
619
  .run()
448
620
  }
449
621
 
450
- return (
622
+ const awareness = collabRef.current?.awareness ?? null
623
+
624
+ /**
625
+ * Encode an anchor for the current selection. Returns `null` when the
626
+ * selection is empty (the panel falls back to a doc-level comment),
627
+ * or when collaboration isn't enabled (we have no Yjs text node to
628
+ * anchor against — slice 5's anchors are CRDT-relative by design).
629
+ */
630
+ const composeAnchor = (): CommentAnchor | null => {
631
+ if (!collabRef.current) return null
632
+ const sel = editor.state.selection
633
+ if (sel.empty) return null
634
+ // The Y.Doc shape that TipTap collaboration uses is
635
+ // doc.getXmlFragment('default') → [paragraph, …] → [text, …]
636
+ // We anchor against the first XmlText child of the paragraph that
637
+ // contains the selection start. This is the simple-but-correct
638
+ // single-node case; multi-paragraph selections fall back to a
639
+ // doc-level comment (anchor: null) and the user can re-anchor
640
+ // later.
641
+ const fragment = collabRef.current.ydoc.getXmlFragment('default')
642
+ const yType = findTextNodeForPosition(fragment, sel.from)
643
+ if (!yType) return null
644
+ // The relative offset within the text node mirrors the PM offset
645
+ // minus the paragraph's opening token. For the canonical single-
646
+ // text-child case this is `sel.from - paragraphStart - 1`.
647
+ const offset = computeTextOffset(fragment, sel.from)
648
+ if (offset === null) return null
649
+ return encodeAnchor({
650
+ doc: collabRef.current.ydoc,
651
+ yType,
652
+ from: offset,
653
+ to: offset + (sel.to - sel.from),
654
+ })
655
+ }
656
+
657
+ const editorPane = (
451
658
  <div
452
- className={`overflow-hidden rounded-lg border border-gray-200 bg-white ${className ?? ''}`}
659
+ className={`flex-1 overflow-hidden rounded-lg border border-gray-200 bg-white ${className ?? ''}`}
453
660
  >
661
+ {collaboration && awareness && (
662
+ <div className="flex items-center justify-between gap-2 border-b border-gray-100 bg-white px-2 py-1.5">
663
+ <PresenceChips awareness={awareness} status={collabStatus} />
664
+ <OfflineStatus connection={collabStatus} offline={offlineStatus} />
665
+ </div>
666
+ )}
454
667
  {editable && <Toolbar editor={editor} onOpenMediaPicker={() => setMediaPickerOpen(true)} />}
668
+ {editable && enableAI && <AICoauthorBubbleMenu editor={editor} />}
455
669
  <EditorContent editor={editor} />
456
- <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
670
+ <style dangerouslySetInnerHTML={{ __html: editorStyles + COMMENT_MARK_STYLES }} />
457
671
  <MediaPickerModal
458
672
  open={mediaPickerOpen}
459
673
  onClose={() => setMediaPickerOpen(false)}
@@ -462,4 +676,88 @@ export function TipTapEditor({
462
676
  />
463
677
  </div>
464
678
  )
679
+
680
+ if (!comments) return editorPane
681
+
682
+ return (
683
+ <div className="flex h-full gap-3">
684
+ {editorPane}
685
+ <div className="w-80 shrink-0">
686
+ <CommentSidePanel
687
+ documentId={comments.documentId}
688
+ currentUserId={comments.currentUserId}
689
+ isAdmin={comments.isAdmin}
690
+ onComposeAnchor={composeAnchor}
691
+ onError={comments.onError}
692
+ onActiveCommentChange={(id) => {
693
+ // When a thread is opened in the panel, paint the matching
694
+ // CommentMark with the active modifier so it stands out in
695
+ // the doc. We do this by toggling a class on the rendered
696
+ // span — purely visual; no doc mutation.
697
+ if (typeof document === 'undefined') return
698
+ document
699
+ .querySelectorAll('span.actuate-comment--active')
700
+ .forEach((el) => el.classList.remove('actuate-comment--active'))
701
+ if (id) {
702
+ document
703
+ .querySelectorAll(`span[data-comment-id="${cssEscape(id)}"]`)
704
+ .forEach((el) => el.classList.add('actuate-comment--active'))
705
+ }
706
+ }}
707
+ />
708
+ </div>
709
+ </div>
710
+ )
711
+ }
712
+
713
+ /**
714
+ * Walk the XmlFragment's children until we reach the paragraph
715
+ * containing the given ProseMirror position. Return its first XmlText
716
+ * child, or null when the structure is anything other than the canonical
717
+ * `paragraph → [text]` shape we anchor against.
718
+ *
719
+ * This is the *simple-case* mapping — slice 5 anchors to a single text
720
+ * node per comment. Selections that span multiple paragraphs fall back
721
+ * to a document-level comment (anchor: null) and the user can re-anchor
722
+ * later from the side panel. y-prosemirror has a richer mapping we may
723
+ * pull in once slice 5 ships.
724
+ */
725
+ function findTextNodeForPosition(fragment: Y.XmlFragment, pmPos: number): Y.XmlText | null {
726
+ let walked = 0
727
+ for (const child of fragment.toArray()) {
728
+ if (!(child instanceof Y.XmlElement)) continue
729
+ const textChild = child.firstChild
730
+ const textLen = textChild instanceof Y.XmlText ? textChild.length : 0
731
+ // Each paragraph contributes `1 + textLen + 1` PM positions
732
+ // (open token, contents, close token).
733
+ const blockSpan = 2 + textLen
734
+ if (pmPos <= walked + blockSpan) {
735
+ return textChild instanceof Y.XmlText ? textChild : null
736
+ }
737
+ walked += blockSpan
738
+ }
739
+ return null
740
+ }
741
+
742
+ function computeTextOffset(fragment: Y.XmlFragment, pmPos: number): number | null {
743
+ let walked = 0
744
+ for (const child of fragment.toArray()) {
745
+ if (!(child instanceof Y.XmlElement)) continue
746
+ const textChild = child.firstChild
747
+ const textLen = textChild instanceof Y.XmlText ? textChild.length : 0
748
+ const blockSpan = 2 + textLen
749
+ if (pmPos <= walked + blockSpan) {
750
+ // PM positions skip the paragraph's open token (1 char).
751
+ const inside = pmPos - walked - 1
752
+ if (inside < 0 || inside > textLen) return null
753
+ return inside
754
+ }
755
+ walked += blockSpan
756
+ }
757
+ return null
758
+ }
759
+
760
+ /** Cheap CSS attr-value escaper for `data-comment-id` lookups. */
761
+ function cssEscape(value: string): string {
762
+ return value.replace(/(["\\])/g, '\\$1')
465
763
  }
@@ -0,0 +1,179 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Top-level admin field for component-aware blocks. Consumes a
5
+ * {@link Manifest} produced by `@actuate-media/component-blocks`,
6
+ * renders a component picker (when more than one component is allowed)
7
+ * and a recursive props form via {@link PropInput}.
8
+ *
9
+ * Storage shape:
10
+ * { component: 'Hero', props: { title: 'Welcome', alignment: 'center' } }
11
+ *
12
+ * Live validation runs through `validateComponentBlockValue` from
13
+ * `@actuate-media/cms-core`. The whole-value error is shown at the top
14
+ * of the form, and the per-prop error map (parsed out of the message
15
+ * string) is forwarded into `PropInput` so each input can render its
16
+ * own inline error.
17
+ */
18
+
19
+ import { useEffect, useMemo, useRef, useState } from 'react'
20
+
21
+ import type { Manifest } from '@actuate-media/component-blocks'
22
+
23
+ import {
24
+ buildClientValidator,
25
+ getAllowedComponents,
26
+ parsePerPropErrors,
27
+ seedPropsForComponent,
28
+ } from './component-block-helpers.js'
29
+ import { PropInput } from './PropInput.js'
30
+
31
+ export interface ComponentBlockValue {
32
+ component: string
33
+ props: Record<string, unknown>
34
+ }
35
+
36
+ export interface ComponentBlockFieldProps {
37
+ label?: string
38
+ manifest: Manifest
39
+ /** Optional whitelist of component names. */
40
+ allow?: string[]
41
+ /** Default component picked when the field is first used. */
42
+ defaultComponent?: string
43
+ value?: ComponentBlockValue
44
+ onChange: (value: ComponentBlockValue) => void
45
+ /**
46
+ * Optional async validator hook. Receives the proposed value and the
47
+ * field config; should return `true` for ok or a string error.
48
+ * Falls back to a built-in structural validator when not provided —
49
+ * which is what cms-core would do server-side at save time anyway.
50
+ */
51
+ validate?: (value: ComponentBlockValue) => string | true
52
+ helpText?: string
53
+ }
54
+
55
+ export function ComponentBlockField({
56
+ label,
57
+ manifest,
58
+ allow,
59
+ defaultComponent,
60
+ value,
61
+ onChange,
62
+ validate,
63
+ helpText,
64
+ }: ComponentBlockFieldProps) {
65
+ const allowedComponents = useMemo(() => getAllowedComponents(manifest, allow), [manifest, allow])
66
+ // Track the initial seed application so we don't repeatedly stomp
67
+ // the editor's prop values on every render. The defaultComponent
68
+ // seed only fires once, when value is undefined on mount.
69
+ const seededRef = useRef(false)
70
+
71
+ useEffect(() => {
72
+ if (seededRef.current) return
73
+ if (value) {
74
+ seededRef.current = true
75
+ return
76
+ }
77
+ const picked =
78
+ allowedComponents.find((c) => c.name === defaultComponent) ?? allowedComponents[0]
79
+ if (!picked) return
80
+ seededRef.current = true
81
+ onChange({ component: picked.name, props: seedPropsForComponent(picked) })
82
+ }, [allowedComponents, defaultComponent, value, onChange])
83
+
84
+ const currentSpec =
85
+ (value && manifest.components.find((c) => c.name === value.component)) ?? allowedComponents[0]
86
+
87
+ // Pure structural validation when caller didn't provide their own.
88
+ // Keeps the form usable even without a live validator wired in.
89
+ const builtinValidate = useMemo(() => buildClientValidator(manifest, allow), [manifest, allow])
90
+ const validator = validate ?? builtinValidate
91
+
92
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
93
+ const perPropErrors = useMemo(() => parsePerPropErrors(errorMessage), [errorMessage])
94
+
95
+ useEffect(() => {
96
+ if (!value) {
97
+ setErrorMessage(null)
98
+ return
99
+ }
100
+ const result = validator(value)
101
+ setErrorMessage(result === true ? null : result)
102
+ }, [value, validator])
103
+
104
+ function handleComponentChange(componentName: string) {
105
+ const spec = manifest.components.find((c) => c.name === componentName)
106
+ if (!spec) return
107
+ onChange({ component: spec.name, props: seedPropsForComponent(spec) })
108
+ }
109
+
110
+ function handlePropChange(propName: string, next: unknown) {
111
+ if (!value) return
112
+ onChange({
113
+ ...value,
114
+ props: { ...value.props, [propName]: next },
115
+ })
116
+ }
117
+
118
+ if (!currentSpec) {
119
+ return (
120
+ <div className="rounded-md border border-[var(--destructive)] bg-red-50 p-3 text-sm text-[var(--destructive)]">
121
+ Component block has no components to choose from.
122
+ </div>
123
+ )
124
+ }
125
+
126
+ return (
127
+ <div className="space-y-3">
128
+ {label ? <label className="block text-sm font-medium">{label}</label> : null}
129
+
130
+ {allowedComponents.length > 1 ? (
131
+ <label className="block text-sm">
132
+ <div className="mb-1 text-xs tracking-wide text-[var(--muted-foreground)] uppercase">
133
+ Component
134
+ </div>
135
+ <select
136
+ value={currentSpec.name}
137
+ onChange={(e) => handleComponentChange(e.target.value)}
138
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
139
+ >
140
+ {allowedComponents.map((spec) => (
141
+ <option key={spec.name} value={spec.name}>
142
+ {spec.displayName}
143
+ </option>
144
+ ))}
145
+ </select>
146
+ </label>
147
+ ) : null}
148
+
149
+ {errorMessage ? (
150
+ <div className="rounded-md border border-[var(--destructive)] bg-red-50 px-3 py-2 text-xs text-[var(--destructive)]">
151
+ {errorMessage}
152
+ </div>
153
+ ) : null}
154
+
155
+ <fieldset className="rounded-md border border-[var(--border)] bg-[var(--card)] p-4">
156
+ <legend className="px-1 text-xs tracking-wide text-[var(--muted-foreground)] uppercase">
157
+ {currentSpec.displayName}
158
+ </legend>
159
+ {currentSpec.description ? (
160
+ <p className="mb-3 text-xs text-[var(--muted-foreground)]">{currentSpec.description}</p>
161
+ ) : null}
162
+ <div className="space-y-4">
163
+ {currentSpec.props.map((prop) => (
164
+ <PropInput
165
+ key={prop.name}
166
+ prop={prop}
167
+ value={value?.props?.[prop.name]}
168
+ onChange={(next) => handlePropChange(prop.name, next)}
169
+ errors={perPropErrors}
170
+ path={prop.name}
171
+ />
172
+ ))}
173
+ </div>
174
+ </fieldset>
175
+
176
+ {helpText ? <p className="text-xs text-[var(--muted-foreground)]">{helpText}</p> : null}
177
+ </div>
178
+ )
179
+ }
@@ -13,6 +13,7 @@ import { BlockBuilderField } from './BlockBuilderField.js'
13
13
  import { GroupField } from './GroupField.js'
14
14
  import { NavBuilderField } from './NavBuilderField.js'
15
15
  import { NumberField } from './NumberField.js'
16
+ import { ComponentBlockField } from './ComponentBlockField.js'
16
17
 
17
18
  export interface FieldDefinition {
18
19
  name: string
@@ -28,6 +29,9 @@ export interface FieldDefinition {
28
29
  multi?: boolean
29
30
  fields?: FieldDefinition[]
30
31
  blocks?: any[]
32
+ manifest?: unknown
33
+ allow?: string[]
34
+ defaultComponent?: string
31
35
  }
32
36
 
33
37
  export interface FieldRendererProps {
@@ -50,6 +54,7 @@ const FIELD_MAP: Record<string, React.ComponentType<any>> = {
50
54
  group: GroupField,
51
55
  nav: NavBuilderField,
52
56
  number: NumberField,
57
+ componentBlock: ComponentBlockField,
53
58
  }
54
59
 
55
60
  export function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
@@ -79,6 +84,9 @@ export function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
79
84
  fields={field.fields}
80
85
  blocks={field.blocks}
81
86
  name={field.name}
87
+ manifest={field.manifest}
88
+ allow={field.allow}
89
+ defaultComponent={field.defaultComponent}
82
90
  />
83
91
  )
84
92
  }