@prenta/admin 0.95.0 → 0.96.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/CHANGELOG.md +36 -0
- package/dist/__tests__/styles/responsive-layer.test.js +2 -2
- package/dist/__tests__/styles/responsive-layer.test.js.map +1 -1
- package/dist/__tests__/views/canvas-surface-shared.test.d.ts +2 -0
- package/dist/__tests__/views/canvas-surface-shared.test.d.ts.map +1 -0
- package/dist/__tests__/views/canvas-surface-shared.test.js +53 -0
- package/dist/__tests__/views/canvas-surface-shared.test.js.map +1 -0
- package/dist/__tests__/views/page-section-editor.test.js +39 -1
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +56 -1
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/CanvasSurface.d.ts +55 -0
- package/dist/views/page-editor/CanvasSurface.d.ts.map +1 -0
- package/dist/views/page-editor/CanvasSurface.js +94 -0
- package/dist/views/page-editor/CanvasSurface.js.map +1 -0
- package/dist/views/page-editor/EditorCanvas.d.ts +5 -28
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +17 -79
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +14 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.d.ts +16 -5
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +16 -47
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +86 -23
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/styles/responsive-layer.test.ts +2 -2
- package/src/__tests__/views/canvas-surface-shared.test.ts +61 -0
- package/src/__tests__/views/page-section-editor.test.tsx +58 -1
- package/src/__tests__/views/post-section-editor.test.tsx +91 -1
- package/src/views/page-editor/CanvasSurface.tsx +171 -0
- package/src/views/page-editor/EditorCanvas.tsx +46 -144
- package/src/views/page-editor/PageSectionEditor.tsx +13 -1
- package/src/views/post-editor/PostEditorCanvas.tsx +54 -74
- package/src/views/post-editor/PostSectionEditor.tsx +184 -82
- package/dist/components/EditorSeoAside.d.ts +0 -23
- package/dist/components/EditorSeoAside.d.ts.map +0 -1
- package/dist/components/EditorSeoAside.js +0 -21
- package/dist/components/EditorSeoAside.js.map +0 -1
- package/src/components/EditorSeoAside.tsx +0 -68
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
const fetchPostForEditor = vi.fn()
|
|
6
6
|
const fetchTemplateForEditor = vi.fn()
|
|
@@ -171,3 +171,93 @@ describe('PostSectionEditor unmanaged sections', () => {
|
|
|
171
171
|
expect(screen.queryByText(/managed outside the editor/)).toBeNull()
|
|
172
172
|
})
|
|
173
173
|
})
|
|
174
|
+
|
|
175
|
+
/** Same disclosure contract as the page editor — see the note on its copy. */
|
|
176
|
+
describe('PostSectionEditor section disclosure', () => {
|
|
177
|
+
it('opens a section, and closes it when picked again', async () => {
|
|
178
|
+
fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
|
|
179
|
+
fetchPostForEditor.mockResolvedValue(
|
|
180
|
+
loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }]),
|
|
181
|
+
)
|
|
182
|
+
render(
|
|
183
|
+
<PostSectionEditor
|
|
184
|
+
postType="blog"
|
|
185
|
+
documentId="p1"
|
|
186
|
+
config={validConfig}
|
|
187
|
+
onNavigate={() => {}}
|
|
188
|
+
session={{}}
|
|
189
|
+
/>,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const open = await screen.findByRole('button', { name: 'Edit Quote' })
|
|
193
|
+
expect(screen.queryByRole('button', { name: 'Close Quote' })).toBeNull()
|
|
194
|
+
|
|
195
|
+
fireEvent.click(open)
|
|
196
|
+
await screen.findByRole('button', { name: 'Close Quote' })
|
|
197
|
+
|
|
198
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close Quote' }))
|
|
199
|
+
await waitFor(() => expect(screen.queryByRole('button', { name: 'Close Quote' })).toBeNull())
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The post editor was the last screen still on the pre-handoff panel: a fixed
|
|
205
|
+
* 288px aside with no resize, no collapse and no tabs, with SEO in a
|
|
206
|
+
* right-hand aside beside it. Every one of those is user-visible, and the
|
|
207
|
+
* width/resize/tabs trio is exactly what got reported.
|
|
208
|
+
*/
|
|
209
|
+
describe('PostSectionEditor rail', () => {
|
|
210
|
+
async function renderLoaded() {
|
|
211
|
+
fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
|
|
212
|
+
fetchPostForEditor.mockResolvedValue(
|
|
213
|
+
loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }]),
|
|
214
|
+
)
|
|
215
|
+
render(
|
|
216
|
+
<PostSectionEditor
|
|
217
|
+
postType="blog"
|
|
218
|
+
documentId="p1"
|
|
219
|
+
config={validConfig}
|
|
220
|
+
onNavigate={() => {}}
|
|
221
|
+
session={{}}
|
|
222
|
+
/>,
|
|
223
|
+
)
|
|
224
|
+
await screen.findByRole('tablist', { name: 'Editor panels' })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
it('renders the resizable rail rather than the fixed panel', async () => {
|
|
228
|
+
await renderLoaded()
|
|
229
|
+
|
|
230
|
+
// The separator is what makes the rail resizable — and operable without a
|
|
231
|
+
// pointer, which the old aside never was.
|
|
232
|
+
const separator = screen.getByRole('separator', { name: 'Resize panel' })
|
|
233
|
+
expect(separator.getAttribute('aria-valuemin')).toBe('300')
|
|
234
|
+
expect(separator.getAttribute('aria-valuemax')).toBe('620')
|
|
235
|
+
expect(separator.getAttribute('tabindex')).toBe('0')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('carries the tab strip', async () => {
|
|
239
|
+
await renderLoaded()
|
|
240
|
+
|
|
241
|
+
expect(screen.getByRole('tab', { name: /Sections/ })).toBeTruthy()
|
|
242
|
+
expect(screen.getByRole('tab', { name: /SEO/ })).toBeTruthy()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('opens SEO inside the rail, not as a second panel beside it', async () => {
|
|
246
|
+
await renderLoaded()
|
|
247
|
+
|
|
248
|
+
fireEvent.click(screen.getByRole('tab', { name: /SEO/ }))
|
|
249
|
+
|
|
250
|
+
// The old surface was a `dialog`-role aside. One settings surface now.
|
|
251
|
+
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'SEO settings' })).toBeNull())
|
|
252
|
+
expect(screen.getByRole('tab', { name: /SEO/ }).getAttribute('aria-selected')).toBe('true')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('can be collapsed to a strip', async () => {
|
|
256
|
+
await renderLoaded()
|
|
257
|
+
|
|
258
|
+
fireEvent.click(screen.getByRole('button', { name: /Collapse .* panel/ }))
|
|
259
|
+
|
|
260
|
+
await screen.findByRole('button', { name: /Expand .* panel/ })
|
|
261
|
+
expect(screen.queryByRole('tablist', { name: 'Editor panels' })).toBeNull()
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
4
|
+
import { CanvasMetaLine } from './CanvasMetaLine.js'
|
|
5
|
+
import { fitScale, getBreakpoint, type BreakpointId, type Zoom } from '../../lib/breakpoints.js'
|
|
6
|
+
|
|
7
|
+
export interface CanvasSurfaceProps {
|
|
8
|
+
breakpoint: BreakpointId
|
|
9
|
+
zoom?: Zoom
|
|
10
|
+
/**
|
|
11
|
+
* Reports the measured scale so the header's zoom chip can show a real
|
|
12
|
+
* percentage. Only the canvas knows the available width, so the number has
|
|
13
|
+
* to travel up rather than being recomputed by the header.
|
|
14
|
+
*/
|
|
15
|
+
onScaleChange?: (scale: number) => void
|
|
16
|
+
/**
|
|
17
|
+
* Reports the canvas surface element, so the reflow guard can clone it. The
|
|
18
|
+
* guard needs the real element rather than a `document.querySelector`, which
|
|
19
|
+
* would also match the site-preview frame's canvas when that is mounted.
|
|
20
|
+
*/
|
|
21
|
+
onCanvasElement?: (el: HTMLElement | null) => void
|
|
22
|
+
/** Tips, notices, or the review bar — rendered above the meta line. */
|
|
23
|
+
chrome?: React.ReactNode
|
|
24
|
+
/** The page itself. Rendered inside the scaled, container-queried surface. */
|
|
25
|
+
children: React.ReactNode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The scaled preview surface, shared by the page and post editors.
|
|
30
|
+
*
|
|
31
|
+
* The canvas renders at the breakpoint's **real CSS width** and is scaled down
|
|
32
|
+
* with a transform. Combined with `container-type: inline-size`, that makes the
|
|
33
|
+
* page reflow against its own width — the same container queries that style the
|
|
34
|
+
* live page decide the layout. A max-width clamp cannot do this: it yields a
|
|
35
|
+
* narrow desktop layout rather than the mobile one.
|
|
36
|
+
*
|
|
37
|
+
* Extracted because the arithmetic was being maintained in two places and got
|
|
38
|
+
* it wrong in one of them (see the `clientWidth - 48` entry in
|
|
39
|
+
* docs/page-builder-handoff-followups.md). The editors differ only in what
|
|
40
|
+
* they put INSIDE the surface — the post editor renders a header above its
|
|
41
|
+
* sections — and not at all in how it is measured, sized or scaled.
|
|
42
|
+
*
|
|
43
|
+
* Two measurement rules, both learned the hard way:
|
|
44
|
+
*
|
|
45
|
+
* 1. Available width comes from a **zero-height sentinel** rendered as the
|
|
46
|
+
* panel's first child, not from `panel.clientWidth - padding`. The two
|
|
47
|
+
* disagree once a scrollbar appears or the host scales the preview, and
|
|
48
|
+
* the padding arithmetic silently rots whenever the gutter changes.
|
|
49
|
+
* 2. Both measurements are re-taken on **every render**, not only from the
|
|
50
|
+
* ResizeObserver. Page height changes with every text edit, image swap and
|
|
51
|
+
* show/hide — none of which resize the observed elements. The observer
|
|
52
|
+
* exists for later window resizes; its first callback is not a substitute.
|
|
53
|
+
*/
|
|
54
|
+
export function CanvasSurface({
|
|
55
|
+
breakpoint,
|
|
56
|
+
zoom = 'fit',
|
|
57
|
+
onScaleChange,
|
|
58
|
+
onCanvasElement,
|
|
59
|
+
chrome,
|
|
60
|
+
children,
|
|
61
|
+
}: CanvasSurfaceProps) {
|
|
62
|
+
const bp = getBreakpoint(breakpoint)
|
|
63
|
+
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
64
|
+
const surfaceRef = useRef<HTMLDivElement>(null)
|
|
65
|
+
|
|
66
|
+
const [available, setAvailable] = useState(0)
|
|
67
|
+
const [pageHeight, setPageHeight] = useState(0)
|
|
68
|
+
|
|
69
|
+
// No dependency array: page height changes on edits that resize nothing the
|
|
70
|
+
// observer watches. Both setters bail when the value is unchanged, so this
|
|
71
|
+
// settles after one extra pass instead of looping.
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
const measure = () => {
|
|
74
|
+
const sentinelWidth = sentinelRef.current?.offsetWidth ?? 0
|
|
75
|
+
if (sentinelWidth > 0) setAvailable((prev) => (prev === sentinelWidth ? prev : sentinelWidth))
|
|
76
|
+
|
|
77
|
+
const height = surfaceRef.current?.offsetHeight ?? 0
|
|
78
|
+
if (height > 0) setPageHeight((prev) => (prev === height ? prev : height))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
measure()
|
|
82
|
+
|
|
83
|
+
const ro = new ResizeObserver(measure)
|
|
84
|
+
if (sentinelRef.current) ro.observe(sentinelRef.current)
|
|
85
|
+
if (surfaceRef.current) ro.observe(surfaceRef.current)
|
|
86
|
+
return () => ro.disconnect()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const scale = fitScale(available, bp.width, zoom)
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
onScaleChange?.(scale)
|
|
93
|
+
}, [scale, onScaleChange])
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
{/*
|
|
98
|
+
* Zero-height sentinel. Its offsetWidth IS the usable width — it is
|
|
99
|
+
* subject to exactly the same padding, scrollbar and host scaling as the
|
|
100
|
+
* canvas, so there is no arithmetic to get wrong. Must stay the first
|
|
101
|
+
* child and must stay zero-height.
|
|
102
|
+
*/}
|
|
103
|
+
<div ref={sentinelRef} aria-hidden className="h-0" />
|
|
104
|
+
|
|
105
|
+
{chrome}
|
|
106
|
+
<CanvasMetaLine breakpoint={bp} pageHeight={pageHeight} scale={scale} />
|
|
107
|
+
|
|
108
|
+
{/*
|
|
109
|
+
* Fit box: reserves the scaled footprint so the surrounding scroll area
|
|
110
|
+
* sizes correctly against a transformed child. Deliberately has NO
|
|
111
|
+
* width/height transition — a transition frozen mid-flight (a preview
|
|
112
|
+
* that is not visible never finishes one) reports a bogus height and
|
|
113
|
+
* corrupts the next measurement.
|
|
114
|
+
*/}
|
|
115
|
+
<div
|
|
116
|
+
className="prenta-canvas-fit mx-auto block"
|
|
117
|
+
style={{
|
|
118
|
+
width: Math.round(bp.width * scale),
|
|
119
|
+
height: pageHeight ? Math.round(pageHeight * scale) : undefined,
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{/* `prenta-canvas` is the public hook for consumer brand CSS: site
|
|
123
|
+
styles that should render in this preview scope to
|
|
124
|
+
`.prenta-admin .prenta-canvas` instead of the admin root. It also
|
|
125
|
+
carries `container-type: inline-size` (theme.css) — that is what
|
|
126
|
+
makes the `@pv-*` steps in the section renderers resolve against
|
|
127
|
+
the simulated width instead of the viewport. */}
|
|
128
|
+
<div
|
|
129
|
+
ref={(el) => {
|
|
130
|
+
surfaceRef.current = el
|
|
131
|
+
onCanvasElement?.(el)
|
|
132
|
+
}}
|
|
133
|
+
className="prenta-canvas border-border bg-card origin-top-left overflow-hidden rounded-lg text-left shadow-[0_2px_20px_rgba(0,0,0,0.12)] motion-safe:transition-[width,transform] motion-safe:duration-(--mo-slow) motion-safe:ease-(--ease)"
|
|
134
|
+
style={{
|
|
135
|
+
width: bp.width,
|
|
136
|
+
transform: `scale(${scale})`,
|
|
137
|
+
transformOrigin: 'top left',
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{children}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The scroll container both editors wrap their surface in. */
|
|
148
|
+
export function CanvasScroll({ children }: { children: React.ReactNode }) {
|
|
149
|
+
return (
|
|
150
|
+
<div className="prenta-canvas-scroll bg-muted h-full overflow-auto px-6 pt-4.5 pb-10 text-center">
|
|
151
|
+
{children}
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Registers section DOM nodes so the canvas can scroll one into view. */
|
|
157
|
+
export function useSectionRefs(selectedId: string | null) {
|
|
158
|
+
const sectionRefs = useRef<Map<string, HTMLElement>>(new Map())
|
|
159
|
+
|
|
160
|
+
const registerRef = useCallback((id: string, el: HTMLElement | null) => {
|
|
161
|
+
if (el) sectionRefs.current.set(id, el)
|
|
162
|
+
else sectionRefs.current.delete(id)
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!selectedId) return
|
|
167
|
+
sectionRefs.current.get(selectedId)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
168
|
+
}, [selectedId])
|
|
169
|
+
|
|
170
|
+
return registerRef
|
|
171
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
4
3
|
import { LayoutTemplate } from 'lucide-react'
|
|
5
4
|
import { CanvasEditTip } from '../../components/CanvasEditTip.js'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { CanvasScroll, CanvasSurface, useSectionRefs } from './CanvasSurface.js'
|
|
6
|
+
import type { BreakpointId, Zoom } from '../../lib/breakpoints.js'
|
|
8
7
|
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
9
8
|
import { SectionRenderer } from './sections/index.js'
|
|
10
9
|
import { StructureNotice } from './StructureNotice.js'
|
|
@@ -15,17 +14,7 @@ interface EditorCanvasProps {
|
|
|
15
14
|
breakpoint: BreakpointId
|
|
16
15
|
zoom?: Zoom
|
|
17
16
|
onSelect: (id: string) => void
|
|
18
|
-
/**
|
|
19
|
-
* Reports the measured scale so the header's zoom chip can show a real
|
|
20
|
-
* percentage. Only the canvas knows the available width, so the number has
|
|
21
|
-
* to travel up rather than being recomputed by the header.
|
|
22
|
-
*/
|
|
23
17
|
onScaleChange?: (scale: number) => void
|
|
24
|
-
/**
|
|
25
|
-
* Reports the canvas surface element, so the reflow guard can clone it. The
|
|
26
|
-
* guard needs the real element rather than a `document.querySelector`, which
|
|
27
|
-
* would also match the site-preview frame's canvas when that is mounted.
|
|
28
|
-
*/
|
|
29
18
|
onCanvasElement?: (el: HTMLElement | null) => void
|
|
30
19
|
/** Section types rendered as placeholders — shows the "Structure view" notice. */
|
|
31
20
|
structuralTypes?: string[]
|
|
@@ -46,24 +35,11 @@ interface EditorCanvasProps {
|
|
|
46
35
|
}
|
|
47
36
|
|
|
48
37
|
/**
|
|
49
|
-
* The preview canvas.
|
|
50
|
-
*
|
|
51
|
-
* The canvas renders at the breakpoint's **real CSS width** and is scaled down
|
|
52
|
-
* with a transform. Combined with `container-type: inline-size`, that makes the
|
|
53
|
-
* page reflow against its own width — the same container queries that style the
|
|
54
|
-
* live page decide the layout. A max-width clamp cannot do this: it yields a
|
|
55
|
-
* narrow desktop layout rather than the mobile one.
|
|
38
|
+
* The page editor's preview canvas.
|
|
56
39
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* panel's first child, not from `panel.clientWidth - padding`. The two
|
|
61
|
-
* disagree once a scrollbar appears or the host scales the preview, and
|
|
62
|
-
* the padding arithmetic silently rots whenever the gutter changes.
|
|
63
|
-
* 2. Both measurements are re-taken on **every render**, not only from the
|
|
64
|
-
* ResizeObserver. Page height changes with every text edit, image swap and
|
|
65
|
-
* show/hide — none of which resize the observed elements. The observer
|
|
66
|
-
* exists for later window resizes; its first callback is not a substitute.
|
|
40
|
+
* Measurement, scaling and the fit box live in {@link CanvasSurface}, shared
|
|
41
|
+
* with the post editor. What is left here is what is genuinely page-specific:
|
|
42
|
+
* which chrome sits above the canvas, and what goes inside it.
|
|
67
43
|
*/
|
|
68
44
|
export function EditorCanvas({
|
|
69
45
|
sections,
|
|
@@ -79,70 +55,31 @@ export function EditorCanvas({
|
|
|
79
55
|
changedSectionIds,
|
|
80
56
|
reviewBar,
|
|
81
57
|
}: EditorCanvasProps) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// No dependency array: page height changes on edits that resize nothing the
|
|
96
|
-
// observer watches. Both setters bail when the value is unchanged, so this
|
|
97
|
-
// settles after one extra pass instead of looping.
|
|
98
|
-
useLayoutEffect(() => {
|
|
99
|
-
const measure = () => {
|
|
100
|
-
const sentinelWidth = sentinelRef.current?.offsetWidth ?? 0
|
|
101
|
-
if (sentinelWidth > 0) setAvailable((prev) => (prev === sentinelWidth ? prev : sentinelWidth))
|
|
102
|
-
|
|
103
|
-
const height = surfaceRef.current?.offsetHeight ?? 0
|
|
104
|
-
if (height > 0) setPageHeight((prev) => (prev === height ? prev : height))
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
measure()
|
|
108
|
-
|
|
109
|
-
const ro = new ResizeObserver(measure)
|
|
110
|
-
if (sentinelRef.current) ro.observe(sentinelRef.current)
|
|
111
|
-
if (surfaceRef.current) ro.observe(surfaceRef.current)
|
|
112
|
-
return () => ro.disconnect()
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
// Scroll the selected section into view within the canvas.
|
|
116
|
-
useEffect(() => {
|
|
117
|
-
if (!selectedId) return
|
|
118
|
-
const el = sectionRefs.current.get(selectedId)
|
|
119
|
-
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
120
|
-
}, [selectedId])
|
|
121
|
-
|
|
122
|
-
const scale = fitScale(available, bp.width, zoom)
|
|
123
|
-
|
|
124
|
-
useEffect(() => {
|
|
125
|
-
onScaleChange?.(scale)
|
|
126
|
-
}, [scale, onScaleChange])
|
|
58
|
+
const registerRef = useSectionRefs(selectedId)
|
|
59
|
+
|
|
60
|
+
// A design-owned page always ships the template's sections, so an empty
|
|
61
|
+
// canvas means the template resolved to nothing — a configuration problem,
|
|
62
|
+
// not a page to scale and measure.
|
|
63
|
+
if (sections.length === 0) {
|
|
64
|
+
return (
|
|
65
|
+
<CanvasScroll>
|
|
66
|
+
<EmptyCanvas />
|
|
67
|
+
</CanvasScroll>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
127
70
|
|
|
128
71
|
return (
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
) : (
|
|
141
|
-
<>
|
|
142
|
-
{/* The review bar REPLACES the editing chrome rather than joining it:
|
|
143
|
-
a tip about clicking sections to edit them is actively wrong while
|
|
144
|
-
the canvas is showing a version you cannot edit. */}
|
|
145
|
-
{reviewing ? (
|
|
72
|
+
<CanvasScroll>
|
|
73
|
+
<CanvasSurface
|
|
74
|
+
breakpoint={breakpoint}
|
|
75
|
+
zoom={zoom}
|
|
76
|
+
onScaleChange={onScaleChange}
|
|
77
|
+
onCanvasElement={onCanvasElement}
|
|
78
|
+
chrome={
|
|
79
|
+
// The review bar REPLACES the editing chrome rather than joining it:
|
|
80
|
+
// a tip about clicking sections to edit them is actively wrong while
|
|
81
|
+
// the canvas is showing a version you cannot edit.
|
|
82
|
+
reviewing ? (
|
|
146
83
|
reviewBar
|
|
147
84
|
) : (
|
|
148
85
|
<>
|
|
@@ -152,58 +89,23 @@ export function EditorCanvas({
|
|
|
152
89
|
sitePreviewAvailable={sitePreviewAvailable}
|
|
153
90
|
/>
|
|
154
91
|
</>
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
{/* `prenta-canvas` is the public hook for consumer brand CSS:
|
|
173
|
-
site styles that should render in this preview scope to
|
|
174
|
-
`.prenta-admin .prenta-canvas` instead of the admin root. */}
|
|
175
|
-
<div
|
|
176
|
-
ref={(el) => {
|
|
177
|
-
surfaceRef.current = el
|
|
178
|
-
onCanvasElement?.(el)
|
|
179
|
-
}}
|
|
180
|
-
// `prenta-canvas` carries `container-type: inline-size` (theme.css)
|
|
181
|
-
// — that is what makes the `@pv-*` steps in the section renderers
|
|
182
|
-
// resolve against the simulated width instead of the viewport.
|
|
183
|
-
className="prenta-canvas border-border bg-card origin-top-left overflow-hidden rounded-lg text-left shadow-[0_2px_20px_rgba(0,0,0,0.12)] motion-safe:transition-[width,transform] motion-safe:duration-(--mo-slow) motion-safe:ease-(--ease)"
|
|
184
|
-
style={{
|
|
185
|
-
width: bp.width,
|
|
186
|
-
transform: `scale(${scale})`,
|
|
187
|
-
transformOrigin: 'top left',
|
|
188
|
-
}}
|
|
189
|
-
>
|
|
190
|
-
{sections.map((section) => (
|
|
191
|
-
<SectionRenderer
|
|
192
|
-
key={section.id}
|
|
193
|
-
section={section}
|
|
194
|
-
editable
|
|
195
|
-
reviewing={reviewing}
|
|
196
|
-
changed={changedSectionIds?.has(section.id) ?? false}
|
|
197
|
-
selected={section.id === selectedId}
|
|
198
|
-
onSelect={onSelect}
|
|
199
|
-
registerRef={registerRef}
|
|
200
|
-
/>
|
|
201
|
-
))}
|
|
202
|
-
</div>
|
|
203
|
-
</div>
|
|
204
|
-
</>
|
|
205
|
-
)}
|
|
206
|
-
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
{sections.map((section) => (
|
|
96
|
+
<SectionRenderer
|
|
97
|
+
key={section.id}
|
|
98
|
+
section={section}
|
|
99
|
+
editable
|
|
100
|
+
reviewing={reviewing}
|
|
101
|
+
changed={changedSectionIds?.has(section.id) ?? false}
|
|
102
|
+
selected={section.id === selectedId}
|
|
103
|
+
onSelect={onSelect}
|
|
104
|
+
registerRef={registerRef}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
</CanvasSurface>
|
|
108
|
+
</CanvasScroll>
|
|
207
109
|
)
|
|
208
110
|
}
|
|
209
111
|
|
|
@@ -743,8 +743,20 @@ export function PageSectionEditor({
|
|
|
743
743
|
// Selecting on the canvas is a request to edit that section — show the
|
|
744
744
|
// outline it belongs to, whichever tab was open.
|
|
745
745
|
setRailTab('sections')
|
|
746
|
-
setSelectedId(id)
|
|
747
746
|
setPendingFocusPath(fieldPath ?? null)
|
|
747
|
+
setSelectedId((current) => {
|
|
748
|
+
// Selection IS disclosure now that the editor opens inside the row, so
|
|
749
|
+
// re-picking the open section has to close it. Without the toggle the
|
|
750
|
+
// rail has no close affordance at all — the chevron and the row both
|
|
751
|
+
// re-select what is already selected and nothing happens.
|
|
752
|
+
//
|
|
753
|
+
// Except when a fieldPath came with it: that is the site preview saying
|
|
754
|
+
// "the editor clicked THIS field", which must open and focus it. Making
|
|
755
|
+
// that toggle would close the form on the second click of a field the
|
|
756
|
+
// user is trying to edit.
|
|
757
|
+
if (fieldPath) return id
|
|
758
|
+
return current === id ? null : id
|
|
759
|
+
})
|
|
748
760
|
}, [])
|
|
749
761
|
|
|
750
762
|
// `effectiveCanEdit`, not `canEdit`: the site preview is a second editing
|