@rimelight/cms 0.0.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.
- package/README.md +19 -0
- package/package.json +69 -0
- package/src/astro/BlockRenderer.astro +111 -0
- package/src/astro/PageRenderer.astro +23 -0
- package/src/astro/blocks/CalloutBlock.astro +78 -0
- package/src/astro/blocks/CardBlock.astro +43 -0
- package/src/astro/blocks/CardsBlock.astro +24 -0
- package/src/astro/blocks/CodeBlock.astro +16 -0
- package/src/astro/blocks/DialogueBlock.astro +23 -0
- package/src/astro/blocks/FileTreeBlock.astro +45 -0
- package/src/astro/blocks/ImageBlock.astro +17 -0
- package/src/astro/blocks/OrderedListBlock.astro +55 -0
- package/src/astro/blocks/ParagraphBlock.astro +65 -0
- package/src/astro/blocks/SceneBlock.astro +65 -0
- package/src/astro/blocks/ScriptBlock.astro +59 -0
- package/src/astro/blocks/SectionBlock.astro +46 -0
- package/src/astro/blocks/StepItemBlock.astro +25 -0
- package/src/astro/blocks/StepsBlock.astro +14 -0
- package/src/astro/blocks/TabItemBlock.astro +19 -0
- package/src/astro/blocks/TableBlock.astro +49 -0
- package/src/astro/blocks/TabsBlock.astro +67 -0
- package/src/astro/blocks/UnorderedListBlock.astro +55 -0
- package/src/auth/editor-guards.ts +148 -0
- package/src/auth/filter-blocks.ts +44 -0
- package/src/auth/filter-templates.ts +81 -0
- package/src/auth/permissions.ts +40 -0
- package/src/cache/purge.ts +37 -0
- package/src/client/components/RimelightBlock.tsx +487 -0
- package/src/client/components/RimelightBlockPicker.tsx +305 -0
- package/src/client/components/RimelightEditor.tsx +131 -0
- package/src/client/components/RimelightEditorLayout.tsx +143 -0
- package/src/client/components/RimelightPreview.tsx +354 -0
- package/src/client/components/RimelightPropertiesPanel.tsx +408 -0
- package/src/client/components/RimelightTemplateEditor.tsx +245 -0
- package/src/client/components/editors/CalloutEditor.tsx +185 -0
- package/src/client/components/editors/CardEditor.tsx +60 -0
- package/src/client/components/editors/CardsEditor.tsx +132 -0
- package/src/client/components/editors/CodeEditor.tsx +101 -0
- package/src/client/components/editors/DialogueEditor.tsx +52 -0
- package/src/client/components/editors/FileTreeEditor.tsx +65 -0
- package/src/client/components/editors/ImageEditor.tsx +70 -0
- package/src/client/components/editors/ParagraphEditor.tsx +314 -0
- package/src/client/components/editors/SceneEditor.tsx +245 -0
- package/src/client/components/editors/ScriptEditor.tsx +245 -0
- package/src/client/components/editors/SectionEditor.tsx +139 -0
- package/src/client/components/editors/StepItemEditor.tsx +117 -0
- package/src/client/components/editors/StepsEditor.tsx +105 -0
- package/src/client/components/editors/TabItemEditor.tsx +113 -0
- package/src/client/components/editors/TableEditor.tsx +181 -0
- package/src/client/components/editors/TabsEditor.tsx +105 -0
- package/src/client/components/index.ts +7 -0
- package/src/client/loader.ts +19 -0
- package/src/client/state/autosave.ts +122 -0
- package/src/client/state/editor-store.ts +411 -0
- package/src/client/state/history.ts +251 -0
- package/src/client/state/lock-manager.ts +89 -0
- package/src/client/styles/cms-editor.css +653 -0
- package/src/client/toast.ts +12 -0
- package/src/client/utils/sortable.ts +184 -0
- package/src/client/utils/splitpane.ts +87 -0
- package/src/client/utils/tree-sanitizer.ts +33 -0
- package/src/core/page-definitions.ts +502 -0
- package/src/core/registry.ts +99 -0
- package/src/core/template-sync.ts +75 -0
- package/src/core/types/blocks.ts +367 -0
- package/src/core/types/inline.ts +23 -0
- package/src/core/types/pages.ts +36 -0
- package/src/core/types/templates.ts +32 -0
- package/src/core/types/versioning.ts +44 -0
- package/src/core/validator.ts +28 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +47 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/parser.ts +347 -0
- package/src/markdown/serializer.ts +219 -0
- package/src/migration.ts +155 -0
- package/src/schema/index.ts +8 -0
- package/src/schema/page_draft_locks.ts +13 -0
- package/src/schema/page_drafts.ts +18 -0
- package/src/schema/page_templates.ts +27 -0
- package/src/schema/page_version_approvals.ts +12 -0
- package/src/schema/page_version_comments.ts +14 -0
- package/src/schema/page_versions.ts +34 -0
- package/src/schema/pages.ts +42 -0
- package/src/schema/search.ts +23 -0
- package/src/search/query.ts +10 -0
- package/src/search/sync.ts +32 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { createSignal, onMount, onCleanup, For, type Component } from "solid-js"
|
|
2
|
+
import RLSInput from "@rimelight/ui/components/input/RLSInput.tsx"
|
|
3
|
+
import RLSIcon from "@rimelight/ui/components/icon/RLSIcon.tsx"
|
|
4
|
+
import RLSButton from "@rimelight/ui/components/button/RLSButton.tsx"
|
|
5
|
+
|
|
6
|
+
const BLOCK_DEFS = [
|
|
7
|
+
{
|
|
8
|
+
group: "Text",
|
|
9
|
+
type: "ParagraphBlock",
|
|
10
|
+
label: "Paragraph",
|
|
11
|
+
desc: "Rich text paragraph",
|
|
12
|
+
icon: "paragraph"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
group: "Text",
|
|
16
|
+
type: "SectionBlock",
|
|
17
|
+
label: "Section",
|
|
18
|
+
desc: "Collapsible section with heading",
|
|
19
|
+
icon: "heading"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
group: "Media",
|
|
23
|
+
type: "ImageBlock",
|
|
24
|
+
label: "Image",
|
|
25
|
+
desc: "Embed an image with caption",
|
|
26
|
+
icon: "image"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
group: "Media",
|
|
30
|
+
type: "CodeBlock",
|
|
31
|
+
label: "Code",
|
|
32
|
+
desc: "Code block with syntax highlighting",
|
|
33
|
+
icon: "code"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
group: "Callout",
|
|
37
|
+
type: "CalloutBlock",
|
|
38
|
+
label: "Callout",
|
|
39
|
+
desc: "Highlighted info/warning/note box",
|
|
40
|
+
icon: "info"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
group: "Documentation",
|
|
44
|
+
type: "TableBlock",
|
|
45
|
+
label: "Table",
|
|
46
|
+
desc: "Structured data table with columns and rows",
|
|
47
|
+
icon: "table"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
group: "Documentation",
|
|
51
|
+
type: "TabsBlock",
|
|
52
|
+
label: "Tabs",
|
|
53
|
+
desc: "Tabbed content container",
|
|
54
|
+
icon: "layers"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
group: "Documentation",
|
|
58
|
+
type: "TabItemBlock",
|
|
59
|
+
label: "Tab Item",
|
|
60
|
+
desc: "Individual tab page",
|
|
61
|
+
icon: "fileText"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
group: "Documentation",
|
|
65
|
+
type: "StepsBlock",
|
|
66
|
+
label: "Steps",
|
|
67
|
+
desc: "Step-by-step walkthrough sequence",
|
|
68
|
+
icon: "listOrdered"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
group: "Documentation",
|
|
72
|
+
type: "StepItemBlock",
|
|
73
|
+
label: "Step Item",
|
|
74
|
+
desc: "Individual step",
|
|
75
|
+
icon: "check"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
group: "Documentation",
|
|
79
|
+
type: "CardsBlock",
|
|
80
|
+
label: "Cards Grid",
|
|
81
|
+
desc: "Grid container for link cards",
|
|
82
|
+
icon: "grid"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
group: "Documentation",
|
|
86
|
+
type: "CardBlock",
|
|
87
|
+
label: "Card",
|
|
88
|
+
desc: "Interactive doc/link card",
|
|
89
|
+
icon: "file"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
group: "Documentation",
|
|
93
|
+
type: "FileTreeBlock",
|
|
94
|
+
label: "File Tree",
|
|
95
|
+
desc: "Directory and file structure visualizer",
|
|
96
|
+
icon: "folder"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
group: "Screenplay",
|
|
100
|
+
type: "ScriptBlock",
|
|
101
|
+
label: "Script",
|
|
102
|
+
desc: "Script container with plot tracking",
|
|
103
|
+
icon: "fileText"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
group: "Screenplay",
|
|
107
|
+
type: "SceneBlock",
|
|
108
|
+
label: "Scene",
|
|
109
|
+
desc: "Scene with location and time-of-day",
|
|
110
|
+
icon: "video"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
group: "Screenplay",
|
|
114
|
+
type: "DialogueBlock",
|
|
115
|
+
label: "Dialogue",
|
|
116
|
+
desc: "Character dialogue line",
|
|
117
|
+
icon: "messageSquare"
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
import { ALLOWED_CHILDREN_MAP, MAX_SECTION_DEPTH } from "../../core/types/blocks"
|
|
122
|
+
import { toast } from "../toast"
|
|
123
|
+
export { ALLOWED_CHILDREN_MAP }
|
|
124
|
+
|
|
125
|
+
export interface RimelightBlockPickerProps {
|
|
126
|
+
open: boolean
|
|
127
|
+
sectionDepth?: number | undefined
|
|
128
|
+
containerType?: string | null | undefined
|
|
129
|
+
onSelect: (type: string) => void
|
|
130
|
+
onClose: () => void
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const RimelightBlockPicker: Component<RimelightBlockPickerProps> = (props) => {
|
|
134
|
+
let searchInput: HTMLInputElement | undefined = undefined
|
|
135
|
+
const [query, setQuery] = createSignal("")
|
|
136
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
|
137
|
+
|
|
138
|
+
const allowedTypes = (): string[] => {
|
|
139
|
+
const parent = props.containerType || "Root"
|
|
140
|
+
return ALLOWED_CHILDREN_MAP[parent] || ALLOWED_CHILDREN_MAP.Root || []
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const filteredBlocks = () => {
|
|
144
|
+
const q = query().toLowerCase()
|
|
145
|
+
const depth = props.sectionDepth || 0
|
|
146
|
+
const allowed = allowedTypes()
|
|
147
|
+
|
|
148
|
+
return BLOCK_DEFS.filter((b) => {
|
|
149
|
+
if (!allowed.includes(b.type)) return false
|
|
150
|
+
if (b.type === "SectionBlock" && depth > MAX_SECTION_DEPTH) return false
|
|
151
|
+
return (
|
|
152
|
+
b.label.toLowerCase().includes(q) ||
|
|
153
|
+
b.type.toLowerCase().includes(q) ||
|
|
154
|
+
b.desc.toLowerCase().includes(q) ||
|
|
155
|
+
b.group.toLowerCase().includes(q)
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const handleSelectBlock = (type: string) => {
|
|
161
|
+
const depth = props.sectionDepth || 0
|
|
162
|
+
if (type === "SectionBlock" && depth > MAX_SECTION_DEPTH) {
|
|
163
|
+
toast.error(
|
|
164
|
+
"Invalid Section Nesting",
|
|
165
|
+
"Sections cannot be nested inside an H6 section. H6 is the maximum heading level."
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
props.onSelect(type)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
onMount(() => {
|
|
173
|
+
const handleGlobalKeydown = (e: KeyboardEvent) => {
|
|
174
|
+
if (props.open && e.key === "Escape") {
|
|
175
|
+
props.onClose()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
document.addEventListener("keydown", handleGlobalKeydown)
|
|
179
|
+
onCleanup(() => document.removeEventListener("keydown", handleGlobalKeydown))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
183
|
+
const list = filteredBlocks()
|
|
184
|
+
if (e.key === "ArrowDown") {
|
|
185
|
+
e.preventDefault()
|
|
186
|
+
setSelectedIndex((prev) => Math.min(prev + 1, Math.max(0, list.length - 1)))
|
|
187
|
+
} else if (e.key === "ArrowUp") {
|
|
188
|
+
e.preventDefault()
|
|
189
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
|
190
|
+
} else if (e.key === "Enter") {
|
|
191
|
+
e.preventDefault()
|
|
192
|
+
const selected = list[selectedIndex()]
|
|
193
|
+
if (selected) {
|
|
194
|
+
handleSelectBlock(selected.type)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div classList={{ hidden: !props.open }}>
|
|
201
|
+
<div
|
|
202
|
+
role="presentation"
|
|
203
|
+
onClick={(e) => {
|
|
204
|
+
if (e.target === e.currentTarget) props.onClose()
|
|
205
|
+
}}
|
|
206
|
+
class="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
|
207
|
+
>
|
|
208
|
+
<div class="bg-elevated border border-default rounded-2xl w-full max-w-md shadow-2xl flex flex-col overflow-hidden">
|
|
209
|
+
<div class="flex items-center justify-between px-4 py-3 border-b border-default">
|
|
210
|
+
<div class="text-sm font-semibold text-foreground">
|
|
211
|
+
Add Block{" "}
|
|
212
|
+
{props.containerType ? `to ${props.containerType.replace("Block", "")}` : ""}
|
|
213
|
+
</div>
|
|
214
|
+
<RLSButton
|
|
215
|
+
variant="ghost"
|
|
216
|
+
color="neutral"
|
|
217
|
+
size="xs"
|
|
218
|
+
square={true}
|
|
219
|
+
onClick={() => props.onClose()}
|
|
220
|
+
aria-label="Close"
|
|
221
|
+
leading={<RLSIcon name="close" size="xs" />}
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div class="px-4 py-3 border-b border-default/50">
|
|
226
|
+
<RLSInput
|
|
227
|
+
ref={(el: HTMLInputElement) => {
|
|
228
|
+
searchInput = el
|
|
229
|
+
if (props.open) setTimeout(() => searchInput?.focus(), 10)
|
|
230
|
+
}}
|
|
231
|
+
value={query()}
|
|
232
|
+
onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) => {
|
|
233
|
+
setQuery(e.currentTarget.value)
|
|
234
|
+
setSelectedIndex(0)
|
|
235
|
+
}}
|
|
236
|
+
onKeyDown={handleKeydown}
|
|
237
|
+
placeholder="Search blocks..."
|
|
238
|
+
leadingIcon="search"
|
|
239
|
+
size="sm"
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div class="overflow-y-auto max-h-[50vh] pb-2">
|
|
244
|
+
{filteredBlocks().length === 0 ? (
|
|
245
|
+
<div class="text-center py-4 text-muted-foreground text-sm">
|
|
246
|
+
No compatible blocks found
|
|
247
|
+
</div>
|
|
248
|
+
) : (
|
|
249
|
+
(() => {
|
|
250
|
+
let currentGroup = ""
|
|
251
|
+
return (
|
|
252
|
+
<For each={filteredBlocks()}>
|
|
253
|
+
{(block, index) => {
|
|
254
|
+
const showHeader = block.group !== currentGroup
|
|
255
|
+
if (showHeader) currentGroup = block.group
|
|
256
|
+
const isSelected = index() === selectedIndex()
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<>
|
|
260
|
+
{showHeader && (
|
|
261
|
+
<div class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-4 py-1.5 mt-2">
|
|
262
|
+
{block.group}
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
onClick={(e) => {
|
|
268
|
+
e.preventDefault()
|
|
269
|
+
e.stopPropagation()
|
|
270
|
+
handleSelectBlock(block.type)
|
|
271
|
+
}}
|
|
272
|
+
onMouseEnter={() => setSelectedIndex(index())}
|
|
273
|
+
class="cms-picker-item flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-800/80 w-full text-left bg-transparent border-none"
|
|
274
|
+
classList={{ "bg-neutral-100 dark:bg-neutral-800/80": isSelected }}
|
|
275
|
+
>
|
|
276
|
+
<div class="text-base w-8 h-8 flex items-center justify-center shrink-0 rounded-lg bg-neutral-100 dark:bg-neutral-800 text-foreground border border-default">
|
|
277
|
+
<RLSIcon name={block.icon} size="sm" />
|
|
278
|
+
</div>
|
|
279
|
+
<div class="flex flex-col flex-1 min-w-0">
|
|
280
|
+
<div class="flex items-center justify-between">
|
|
281
|
+
<span class="text-sm font-medium text-foreground">
|
|
282
|
+
{block.label}
|
|
283
|
+
</span>
|
|
284
|
+
<span class="text-[10px] font-mono text-muted-foreground bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded border border-default/50">
|
|
285
|
+
{block.type}
|
|
286
|
+
</span>
|
|
287
|
+
</div>
|
|
288
|
+
<span class="text-xs text-muted-foreground mt-0.5 truncate">
|
|
289
|
+
{block.desc}
|
|
290
|
+
</span>
|
|
291
|
+
</div>
|
|
292
|
+
</button>
|
|
293
|
+
</>
|
|
294
|
+
)
|
|
295
|
+
}}
|
|
296
|
+
</For>
|
|
297
|
+
)
|
|
298
|
+
})()
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { onMount, onCleanup, For, type Component } from "solid-js"
|
|
2
|
+
import type { EditorState } from "../state/editor-store"
|
|
3
|
+
import { RimelightBlock } from "./RimelightBlock"
|
|
4
|
+
import { RimelightBlockPicker } from "./RimelightBlockPicker"
|
|
5
|
+
import { initBlockSortable, cleanAllSortableClasses } from "../utils/sortable"
|
|
6
|
+
import RLSButton from "@rimelight/ui/components/button/RLSButton.tsx"
|
|
7
|
+
|
|
8
|
+
export interface RimelightEditorProps {
|
|
9
|
+
state: EditorState
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const RimelightEditor: Component<RimelightEditorProps> = (props) => {
|
|
13
|
+
let canvasContainer: HTMLDivElement | undefined = undefined
|
|
14
|
+
let sortableInstance: any = null
|
|
15
|
+
|
|
16
|
+
onMount(() => {
|
|
17
|
+
if (canvasContainer) {
|
|
18
|
+
sortableInstance = initBlockSortable(canvasContainer, {
|
|
19
|
+
handle: ".cms-grip-btn",
|
|
20
|
+
filter: ".cms-empty-state, .locked",
|
|
21
|
+
onReorder: (oldIdx: number, newIdx: number) => {
|
|
22
|
+
cleanAllSortableClasses()
|
|
23
|
+
const list = [...props.state.blocks]
|
|
24
|
+
const item = list.splice(oldIdx, 1)[0]
|
|
25
|
+
if (item) {
|
|
26
|
+
list.splice(newIdx, 0, item)
|
|
27
|
+
props.state.setBlocks(list)
|
|
28
|
+
}
|
|
29
|
+
requestAnimationFrame(() => cleanAllSortableClasses())
|
|
30
|
+
setTimeout(cleanAllSortableClasses, 50)
|
|
31
|
+
setTimeout(cleanAllSortableClasses, 200)
|
|
32
|
+
},
|
|
33
|
+
onAdd: (blockId: string, newIdx: number) => {
|
|
34
|
+
cleanAllSortableClasses()
|
|
35
|
+
props.state.relocateBlock(blockId, newIdx, null)
|
|
36
|
+
requestAnimationFrame(() => cleanAllSortableClasses())
|
|
37
|
+
setTimeout(cleanAllSortableClasses, 50)
|
|
38
|
+
setTimeout(cleanAllSortableClasses, 200)
|
|
39
|
+
},
|
|
40
|
+
onRemove: (_blockId: string, _oldIdx: number) => {
|
|
41
|
+
cleanAllSortableClasses()
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
47
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
|
|
48
|
+
e.preventDefault()
|
|
49
|
+
props.state.undo()
|
|
50
|
+
} else if ((e.ctrlKey || e.metaKey) && (e.key === "y" || (e.key === "z" && e.shiftKey))) {
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
props.state.redo()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
window.addEventListener("keydown", handleKeydown)
|
|
56
|
+
|
|
57
|
+
onCleanup(() => {
|
|
58
|
+
window.removeEventListener("keydown", handleKeydown)
|
|
59
|
+
if (sortableInstance) sortableInstance.destroy()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div class="flex flex-col gap-6 w-full bg-transparent">
|
|
65
|
+
<div ref={canvasContainer} class="flex flex-col gap-3 w-full min-h-[200px] pl-6">
|
|
66
|
+
{props.state.blocks.length === 0 ? (
|
|
67
|
+
<div class="flex flex-col items-center justify-center p-8 sm:p-12 rounded-xl border border-dashed border-default text-center my-4">
|
|
68
|
+
<div class="text-3xl text-muted-foreground mb-2">📄</div>
|
|
69
|
+
<h4 class="text-base font-semibold text-foreground">No blocks yet</h4>
|
|
70
|
+
<p class="text-xs text-muted-foreground mt-1 max-w-xs">
|
|
71
|
+
Start building your page content by adding your first block below.
|
|
72
|
+
</p>
|
|
73
|
+
<div class="mt-4">
|
|
74
|
+
<RLSButton
|
|
75
|
+
variant="outline"
|
|
76
|
+
color="neutral"
|
|
77
|
+
size="sm"
|
|
78
|
+
onClick={() => props.state.openPicker(null, "after", 0)}
|
|
79
|
+
leadingIcon="plus"
|
|
80
|
+
label="Add Block"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
) : (
|
|
85
|
+
<For each={props.state.blocks}>
|
|
86
|
+
{(block) => (
|
|
87
|
+
<RimelightBlock
|
|
88
|
+
block={block}
|
|
89
|
+
sectionDepth={0}
|
|
90
|
+
onPropsChange={props.state.updateBlockProps}
|
|
91
|
+
onMove={props.state.moveBlock}
|
|
92
|
+
onDelete={props.state.deleteBlock}
|
|
93
|
+
onDuplicate={props.state.duplicateBlock}
|
|
94
|
+
onAddAdjacent={(targetId, pos) => props.state.openPicker(targetId, pos, 0)}
|
|
95
|
+
onRelocate={props.state.relocateBlock}
|
|
96
|
+
onOpenPicker={(targetId, pos, depth, parentId, containerType) =>
|
|
97
|
+
props.state.openPicker(targetId, pos, depth, parentId, containerType)
|
|
98
|
+
}
|
|
99
|
+
/>
|
|
100
|
+
)}
|
|
101
|
+
</For>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{props.state.blocks.length > 0 && (
|
|
106
|
+
<div class="flex flex-col items-center justify-center gap-4 p-4 mt-2">
|
|
107
|
+
<RLSButton
|
|
108
|
+
variant="outline"
|
|
109
|
+
color="neutral"
|
|
110
|
+
size="sm"
|
|
111
|
+
onClick={() => props.state.openPicker(null, "after", 0, null, "Root")}
|
|
112
|
+
leadingIcon="plus"
|
|
113
|
+
label="Add Block"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
<RimelightBlockPicker
|
|
119
|
+
open={props.state.pickerTarget().open}
|
|
120
|
+
sectionDepth={props.state.pickerTarget().sectionDepth}
|
|
121
|
+
containerType={props.state.pickerTarget().containerType}
|
|
122
|
+
onSelect={(type) => {
|
|
123
|
+
const { targetId, position, parentId } = props.state.pickerTarget()
|
|
124
|
+
props.state.insertBlock(type, targetId, position, parentId)
|
|
125
|
+
props.state.closePicker()
|
|
126
|
+
}}
|
|
127
|
+
onClose={props.state.closePicker}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { onMount, createEffect, createMemo, Show, type Component } from "solid-js"
|
|
2
|
+
import type { BaseBlock } from "../../core/types/blocks"
|
|
3
|
+
import { createEditorState } from "../state/editor-store"
|
|
4
|
+
import { RimelightEditor } from "./RimelightEditor"
|
|
5
|
+
import { RimelightPreview } from "./RimelightPreview"
|
|
6
|
+
import { initSplitPane } from "../utils/splitpane"
|
|
7
|
+
|
|
8
|
+
export interface RimelightEditorLayoutProps {
|
|
9
|
+
pageId?: string
|
|
10
|
+
initialBlocks?: BaseBlock[] | string
|
|
11
|
+
showInternalPreview?: boolean
|
|
12
|
+
ref?: (el: any) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const RimelightEditorLayout: Component<RimelightEditorLayoutProps> = (componentProps) => {
|
|
16
|
+
let containerRef: HTMLDivElement | undefined = undefined
|
|
17
|
+
let editorPaneRef: HTMLDivElement | undefined = undefined
|
|
18
|
+
let previewPaneRef: HTMLDivElement | undefined = undefined
|
|
19
|
+
let handleRef: HTMLDivElement | undefined = undefined
|
|
20
|
+
|
|
21
|
+
let parsedInitialBlocks: BaseBlock[] = []
|
|
22
|
+
if (typeof componentProps.initialBlocks === "string") {
|
|
23
|
+
try {
|
|
24
|
+
parsedInitialBlocks = JSON.parse(componentProps.initialBlocks)
|
|
25
|
+
} catch {
|
|
26
|
+
parsedInitialBlocks = []
|
|
27
|
+
}
|
|
28
|
+
} else if (Array.isArray(componentProps.initialBlocks)) {
|
|
29
|
+
parsedInitialBlocks = componentProps.initialBlocks
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const state = createEditorState(parsedInitialBlocks, componentProps.pageId || "default-page")
|
|
33
|
+
|
|
34
|
+
// Snapshot of blocks as plain objects for the preview.
|
|
35
|
+
const previewBlocks = createMemo<BaseBlock[]>(() => {
|
|
36
|
+
const len = state.blocks.length
|
|
37
|
+
const result: BaseBlock[] = []
|
|
38
|
+
for (let i = 0; i < len; i++) {
|
|
39
|
+
const blockObj = JSON.parse(JSON.stringify(state.blocks[i]))
|
|
40
|
+
if (blockObj && typeof blockObj === "object") {
|
|
41
|
+
result.push(blockObj)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
createEffect(() => {
|
|
48
|
+
const currentStatus = state.status()
|
|
49
|
+
if (typeof document !== "undefined") {
|
|
50
|
+
document.dispatchEvent(
|
|
51
|
+
new CustomEvent("status-change", { detail: { status: currentStatus }, bubbles: true })
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
createEffect(() => {
|
|
57
|
+
const rawBlocks = previewBlocks()
|
|
58
|
+
if (typeof document !== "undefined") {
|
|
59
|
+
document.dispatchEvent(
|
|
60
|
+
new CustomEvent("blocks-change", { detail: { blocks: rawBlocks }, bubbles: true })
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
onMount(() => {
|
|
66
|
+
const publicMethods = {
|
|
67
|
+
undo: () => state.undo(),
|
|
68
|
+
redo: () => state.redo(),
|
|
69
|
+
addBlock: (_type?: string) => state.openPicker(null, "after", 0),
|
|
70
|
+
saveDraft: () => state.saveDraft(),
|
|
71
|
+
togglePreview: () => {
|
|
72
|
+
const next = !state.previewVisible()
|
|
73
|
+
state.setPreviewVisible(next)
|
|
74
|
+
if (typeof document !== "undefined") {
|
|
75
|
+
document.dispatchEvent(
|
|
76
|
+
new CustomEvent("preview-toggle", { detail: { visible: next }, bubbles: true })
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
return next
|
|
80
|
+
},
|
|
81
|
+
getBlocks: () => JSON.parse(JSON.stringify(state.blocks)),
|
|
82
|
+
setBlocks: (newBlocks: BaseBlock[]) => state.setBlocks(newBlocks),
|
|
83
|
+
getAttribute: (attr: string) => (attr === "page-id" ? componentProps.pageId : null)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (componentProps.ref) {
|
|
87
|
+
componentProps.ref(publicMethods)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof window !== "undefined") {
|
|
91
|
+
const win = window as Window & { cmsEditorLayout?: Record<string, any> }
|
|
92
|
+
win.cmsEditorLayout = publicMethods
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (containerRef) {
|
|
96
|
+
Object.assign(containerRef, publicMethods)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
createEffect(() => {
|
|
101
|
+
if (componentProps.showInternalPreview && state.previewVisible()) {
|
|
102
|
+
queueMicrotask(() => {
|
|
103
|
+
if (containerRef && editorPaneRef && previewPaneRef && handleRef) {
|
|
104
|
+
initSplitPane(containerRef, editorPaneRef, previewPaneRef, handleRef)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div class="rimelight-cms-editor-container flex-1 w-full h-full flex">
|
|
112
|
+
<div
|
|
113
|
+
ref={containerRef}
|
|
114
|
+
class="cms-split-layout w-full h-full flex flex-row overflow-hidden relative"
|
|
115
|
+
id="cms-layout"
|
|
116
|
+
>
|
|
117
|
+
<div
|
|
118
|
+
ref={editorPaneRef}
|
|
119
|
+
style={{
|
|
120
|
+
flex:
|
|
121
|
+
componentProps.showInternalPreview && state.previewVisible() ? "0 0 50%" : "1 1 100%"
|
|
122
|
+
}}
|
|
123
|
+
class="flex flex-col h-full overflow-hidden w-full"
|
|
124
|
+
>
|
|
125
|
+
<RimelightEditor state={state} />
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<Show when={componentProps.showInternalPreview && state.previewVisible()}>
|
|
129
|
+
<div ref={handleRef} class="cms-resize-handle" id="cms-resize-handle">
|
|
130
|
+
<div class="cms-resize-pill" />
|
|
131
|
+
</div>
|
|
132
|
+
<div
|
|
133
|
+
ref={previewPaneRef}
|
|
134
|
+
style={{ flex: "0 0 50%" }}
|
|
135
|
+
class="flex flex-col h-full overflow-hidden border-l border-gray-800"
|
|
136
|
+
>
|
|
137
|
+
<RimelightPreview blocks={previewBlocks} />
|
|
138
|
+
</div>
|
|
139
|
+
</Show>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|