@growth-labs/cms 0.5.18 → 0.5.20

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 (41) hide show
  1. package/README.md +46 -0
  2. package/dist/content/index.d.ts +4 -0
  3. package/dist/content/index.d.ts.map +1 -0
  4. package/dist/content/index.js +10 -0
  5. package/dist/content/index.js.map +1 -0
  6. package/dist/schema/insights-ingest.d.ts +18 -18
  7. package/dist/schema/portable-text.d.ts.map +1 -1
  8. package/dist/schema/portable-text.js +8 -0
  9. package/dist/schema/portable-text.js.map +1 -1
  10. package/dist/ui/editor/Rte.d.ts.map +1 -1
  11. package/dist/ui/editor/Rte.js +22 -0
  12. package/dist/ui/editor/Rte.js.map +1 -1
  13. package/dist/ui/editor/blocks.d.ts +17 -0
  14. package/dist/ui/editor/blocks.d.ts.map +1 -0
  15. package/dist/ui/editor/blocks.js +113 -0
  16. package/dist/ui/editor/blocks.js.map +1 -0
  17. package/dist/ui/editor/extensions.d.ts +1 -1
  18. package/dist/ui/editor/extensions.d.ts.map +1 -1
  19. package/dist/ui/editor/extensions.js +19 -0
  20. package/dist/ui/editor/extensions.js.map +1 -1
  21. package/dist/ui/editor/portable-text.d.ts.map +1 -1
  22. package/dist/ui/editor/portable-text.js +57 -1
  23. package/dist/ui/editor/portable-text.js.map +1 -1
  24. package/dist/ui/editor/serialize.d.ts.map +1 -1
  25. package/dist/ui/editor/serialize.js +96 -2
  26. package/dist/ui/editor/serialize.js.map +1 -1
  27. package/dist/ui/editor/slash-menu.d.ts +14 -0
  28. package/dist/ui/editor/slash-menu.d.ts.map +1 -0
  29. package/dist/ui/editor/slash-menu.js +200 -0
  30. package/dist/ui/editor/slash-menu.js.map +1 -0
  31. package/dist/ui/styles/broadsheet.css +20 -0
  32. package/package.json +11 -1
  33. package/src/content/index.ts +16 -0
  34. package/src/schema/portable-text.ts +8 -0
  35. package/src/ui/editor/Rte.tsx +25 -0
  36. package/src/ui/editor/blocks.ts +140 -0
  37. package/src/ui/editor/extensions.ts +20 -0
  38. package/src/ui/editor/portable-text.ts +60 -1
  39. package/src/ui/editor/serialize.ts +100 -2
  40. package/src/ui/editor/slash-menu.ts +218 -0
  41. package/src/ui/styles/broadsheet.css +20 -0
@@ -0,0 +1,218 @@
1
+ // src/ui/editor/slash-menu.ts
2
+ // `/` block-insert menu for the rich-text editor, built on @tiptap/suggestion.
3
+ // The item list + filter are pure and unit-tested; the dropdown itself is a
4
+ // vanilla-DOM renderer created only when the Suggestion plugin runs inside a
5
+ // live (browser) editor, so this module stays importable in Node.
6
+
7
+ import { type Editor, Extension, type Range } from '@tiptap/core'
8
+ import { PluginKey } from '@tiptap/pm/state'
9
+ import Suggestion, { type SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion'
10
+
11
+ export interface SlashMenuItem {
12
+ title: string
13
+ hint: string
14
+ /** Lowercase match terms beyond the title. */
15
+ keywords: string
16
+ run: (editor: Editor, range: Range) => void
17
+ }
18
+
19
+ export const SLASH_MENU_ITEMS: SlashMenuItem[] = [
20
+ {
21
+ title: 'Heading 2',
22
+ hint: 'Section heading',
23
+ keywords: 'h2 title section',
24
+ run: (editor, range) =>
25
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run(),
26
+ },
27
+ {
28
+ title: 'Heading 3',
29
+ hint: 'Sub-section heading',
30
+ keywords: 'h3 subtitle',
31
+ run: (editor, range) =>
32
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run(),
33
+ },
34
+ {
35
+ title: 'Bullet list',
36
+ hint: 'Unordered list',
37
+ keywords: 'ul bullets',
38
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleBulletList().run(),
39
+ },
40
+ {
41
+ title: 'Numbered list',
42
+ hint: 'Ordered list',
43
+ keywords: 'ol ordered numbers',
44
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
45
+ },
46
+ {
47
+ title: 'Quote',
48
+ hint: 'Blockquote',
49
+ keywords: 'blockquote',
50
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleBlockquote().run(),
51
+ },
52
+ {
53
+ title: 'Callout',
54
+ hint: 'Highlighted aside with a tone',
55
+ keywords: 'aside note warning tip alert',
56
+ run: (editor, range) =>
57
+ editor.chain().focus().deleteRange(range).toggleWrap('callout', { tone: 'note' }).run(),
58
+ },
59
+ {
60
+ title: 'Pull quote',
61
+ hint: 'Large excerpted quote',
62
+ keywords: 'pullquote excerpt',
63
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleWrap('pullQuote').run(),
64
+ },
65
+ {
66
+ title: 'Table',
67
+ hint: '3×3 table with a header row',
68
+ keywords: 'grid rows columns',
69
+ run: (editor, range) =>
70
+ editor
71
+ .chain()
72
+ .focus()
73
+ .deleteRange(range)
74
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
75
+ .run(),
76
+ },
77
+ {
78
+ title: 'Divider',
79
+ hint: 'Horizontal rule',
80
+ keywords: 'hr rule separator',
81
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
82
+ },
83
+ {
84
+ title: 'Tweet embed',
85
+ hint: 'Embed a post by URL',
86
+ keywords: 'twitter x embed',
87
+ run: (editor, range) => {
88
+ const url = typeof window !== 'undefined' ? window.prompt('Tweet URL') : null
89
+ if (!url) return
90
+ editor
91
+ .chain()
92
+ .focus()
93
+ .deleteRange(range)
94
+ .insertContent({ type: 'tweetEmbed', attrs: { url } })
95
+ .run()
96
+ },
97
+ },
98
+ ]
99
+
100
+ /** Case-insensitive filter over titles + keywords; empty query returns all. */
101
+ export function filterSlashMenuItems(query: string): SlashMenuItem[] {
102
+ const needle = query.trim().toLowerCase()
103
+ if (!needle) return SLASH_MENU_ITEMS
104
+ return SLASH_MENU_ITEMS.filter(
105
+ (item) =>
106
+ item.title.toLowerCase().includes(needle) || item.keywords.toLowerCase().includes(needle),
107
+ )
108
+ }
109
+
110
+ function createSlashMenuRenderer() {
111
+ let container: HTMLDivElement | null = null
112
+ let items: SlashMenuItem[] = []
113
+ let selected = 0
114
+ let executeSelected: (() => void) | null = null
115
+
116
+ function paint(props: SuggestionProps<SlashMenuItem, SlashMenuItem>) {
117
+ items = props.items
118
+ selected = Math.min(selected, Math.max(0, items.length - 1))
119
+ executeSelected = () => {
120
+ const item = items[selected]
121
+ if (item) props.command(item)
122
+ }
123
+ if (!container) return
124
+ container.innerHTML = ''
125
+ if (items.length === 0) {
126
+ container.style.display = 'none'
127
+ return
128
+ }
129
+ container.style.display = 'block'
130
+ items.forEach((item, index) => {
131
+ const button = document.createElement('button')
132
+ button.type = 'button'
133
+ button.className = `slash-menu-item${index === selected ? ' is-selected' : ''}`
134
+ const title = document.createElement('span')
135
+ title.className = 'slash-menu-title'
136
+ title.textContent = item.title
137
+ const hint = document.createElement('span')
138
+ hint.className = 'slash-menu-hint'
139
+ hint.textContent = item.hint
140
+ button.append(title, hint)
141
+ button.addEventListener('mousedown', (event) => {
142
+ event.preventDefault()
143
+ props.command(item)
144
+ })
145
+ container?.append(button)
146
+ })
147
+ const rect = props.clientRect?.()
148
+ if (rect) {
149
+ container.style.left = `${rect.left + window.scrollX}px`
150
+ container.style.top = `${rect.bottom + window.scrollY + 4}px`
151
+ }
152
+ }
153
+
154
+ return {
155
+ onStart(props: SuggestionProps<SlashMenuItem, SlashMenuItem>) {
156
+ selected = 0
157
+ container = document.createElement('div')
158
+ container.className = 'slash-menu'
159
+ document.body.append(container)
160
+ paint(props)
161
+ },
162
+ onUpdate(props: SuggestionProps<SlashMenuItem, SlashMenuItem>) {
163
+ paint(props)
164
+ },
165
+ onKeyDown({ event }: SuggestionKeyDownProps): boolean {
166
+ if (event.key === 'ArrowDown') {
167
+ selected = items.length ? (selected + 1) % items.length : 0
168
+ refreshSelection()
169
+ return true
170
+ }
171
+ if (event.key === 'ArrowUp') {
172
+ selected = items.length ? (selected - 1 + items.length) % items.length : 0
173
+ refreshSelection()
174
+ return true
175
+ }
176
+ if (event.key === 'Enter') {
177
+ executeSelected?.()
178
+ return true
179
+ }
180
+ if (event.key === 'Escape') {
181
+ container?.remove()
182
+ container = null
183
+ return true
184
+ }
185
+ return false
186
+ },
187
+ onExit() {
188
+ container?.remove()
189
+ container = null
190
+ },
191
+ }
192
+
193
+ function refreshSelection() {
194
+ if (!container) return
195
+ container.querySelectorAll('.slash-menu-item').forEach((el, index) => {
196
+ el.classList.toggle('is-selected', index === selected)
197
+ })
198
+ }
199
+ }
200
+
201
+ /** `/` at the start of a line opens the block-insert menu. */
202
+ export const SlashMenu = Extension.create({
203
+ name: 'slashMenu',
204
+
205
+ addProseMirrorPlugins() {
206
+ return [
207
+ Suggestion<SlashMenuItem, SlashMenuItem>({
208
+ editor: this.editor,
209
+ pluginKey: new PluginKey('slashMenu'),
210
+ char: '/',
211
+ startOfLine: true,
212
+ items: ({ query }) => filterSlashMenuItems(query),
213
+ command: ({ editor, range, props }) => props.run(editor, range),
214
+ render: createSlashMenuRenderer,
215
+ }),
216
+ ]
217
+ },
218
+ })
@@ -403,3 +403,23 @@ input, textarea, select { font-family: inherit; }
403
403
  .ProseMirror table .column-resize-handle { background: var(--accent); bottom: -2px; pointer-events: none; position: absolute; right: -2px; top: 0; width: 4px; }
404
404
  .ProseMirror .tableWrapper { margin: 1.5em 0; overflow-x: auto; }
405
405
  .ProseMirror.resize-cursor { cursor: col-resize; }
406
+
407
+ /* ── Typed editorial blocks (Phase 1) ──────────────────────────────────── */
408
+ .ProseMirror div[data-callout] { border-left: 3px solid var(--accent); background: var(--panel-2); border-radius: 6px; padding: 10px 14px; margin: 1em 0; }
409
+ .ProseMirror div[data-callout][data-tone="warning"], .ProseMirror div[data-callout][data-tone="caution"] { border-left-color: #b45309; }
410
+ .ProseMirror div[data-callout][data-tone="tip"] { border-left-color: #15803d; }
411
+ .ProseMirror div[data-callout][data-tone="important"] { border-left-color: #7c3aed; }
412
+ .ProseMirror figure[data-pull-quote] { border-left: 3px solid var(--ink-faint); margin: 1.5em 0; padding: 4px 18px; font-size: 1.15em; font-style: italic; color: var(--ink-soft); }
413
+ .ProseMirror figure[data-pull-quote][data-attribution]::after { content: "— " attr(data-attribution); display: block; font-size: 0.8em; font-style: normal; color: var(--ink-faint); margin-top: 6px; }
414
+ .ProseMirror div[data-generated-faq] { border: 1px dashed var(--border); border-radius: 6px; padding: 8px 12px; margin: 1em 0; color: var(--ink-faint); font-size: 0.85em; user-select: none; }
415
+ .ProseMirror a[data-citation] { text-decoration: underline dotted; color: var(--accent); }
416
+
417
+ /* ── Slash menu + drag handle ─────────────────────────────────────────── */
418
+ .slash-menu { position: absolute; z-index: 90; min-width: 240px; max-height: 320px; overflow-y: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.18); padding: 4px; }
419
+ .slash-menu-item { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%; padding: 6px 10px; border: 0; border-radius: 6px; background: transparent; cursor: pointer; text-align: left; }
420
+ .slash-menu-item.is-selected, .slash-menu-item:hover { background: var(--hover); }
421
+ .slash-menu-title { font-size: 13px; font-weight: 600; color: var(--ink); }
422
+ .slash-menu-hint { font-size: 11px; color: var(--ink-faint); }
423
+ .drag-handle { display: flex; align-items: center; justify-content: center; width: 18px; height: 22px; border-radius: 4px; color: var(--ink-faint); font-size: 11px; letter-spacing: -2px; cursor: grab; user-select: none; }
424
+ .drag-handle:hover { background: var(--hover); color: var(--ink-soft); }
425
+ .drag-handle:active { cursor: grabbing; }