@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.
- package/README.md +46 -0
- package/dist/content/index.d.ts +4 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +10 -0
- package/dist/content/index.js.map +1 -0
- package/dist/schema/insights-ingest.d.ts +18 -18
- package/dist/schema/portable-text.d.ts.map +1 -1
- package/dist/schema/portable-text.js +8 -0
- package/dist/schema/portable-text.js.map +1 -1
- package/dist/ui/editor/Rte.d.ts.map +1 -1
- package/dist/ui/editor/Rte.js +22 -0
- package/dist/ui/editor/Rte.js.map +1 -1
- package/dist/ui/editor/blocks.d.ts +17 -0
- package/dist/ui/editor/blocks.d.ts.map +1 -0
- package/dist/ui/editor/blocks.js +113 -0
- package/dist/ui/editor/blocks.js.map +1 -0
- package/dist/ui/editor/extensions.d.ts +1 -1
- package/dist/ui/editor/extensions.d.ts.map +1 -1
- package/dist/ui/editor/extensions.js +19 -0
- package/dist/ui/editor/extensions.js.map +1 -1
- package/dist/ui/editor/portable-text.d.ts.map +1 -1
- package/dist/ui/editor/portable-text.js +57 -1
- package/dist/ui/editor/portable-text.js.map +1 -1
- package/dist/ui/editor/serialize.d.ts.map +1 -1
- package/dist/ui/editor/serialize.js +96 -2
- package/dist/ui/editor/serialize.js.map +1 -1
- package/dist/ui/editor/slash-menu.d.ts +14 -0
- package/dist/ui/editor/slash-menu.d.ts.map +1 -0
- package/dist/ui/editor/slash-menu.js +200 -0
- package/dist/ui/editor/slash-menu.js.map +1 -0
- package/dist/ui/styles/broadsheet.css +20 -0
- package/package.json +11 -1
- package/src/content/index.ts +16 -0
- package/src/schema/portable-text.ts +8 -0
- package/src/ui/editor/Rte.tsx +25 -0
- package/src/ui/editor/blocks.ts +140 -0
- package/src/ui/editor/extensions.ts +20 -0
- package/src/ui/editor/portable-text.ts +60 -1
- package/src/ui/editor/serialize.ts +100 -2
- package/src/ui/editor/slash-menu.ts +218 -0
- 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; }
|