@prenta/admin 0.95.1 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/__tests__/styles/responsive-layer.test.js +2 -2
  3. package/dist/__tests__/styles/responsive-layer.test.js.map +1 -1
  4. package/dist/__tests__/views/canvas-surface-shared.test.d.ts +2 -0
  5. package/dist/__tests__/views/canvas-surface-shared.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/canvas-surface-shared.test.js +53 -0
  7. package/dist/__tests__/views/canvas-surface-shared.test.js.map +1 -0
  8. package/dist/__tests__/views/post-section-editor.test.js +41 -0
  9. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  10. package/dist/prenta-admin.css +1 -1
  11. package/dist/views/page-editor/CanvasSurface.d.ts +55 -0
  12. package/dist/views/page-editor/CanvasSurface.d.ts.map +1 -0
  13. package/dist/views/page-editor/CanvasSurface.js +94 -0
  14. package/dist/views/page-editor/CanvasSurface.js.map +1 -0
  15. package/dist/views/page-editor/EditorCanvas.d.ts +5 -28
  16. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  17. package/dist/views/page-editor/EditorCanvas.js +17 -79
  18. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  19. package/dist/views/post-editor/PostEditorCanvas.d.ts +16 -5
  20. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  21. package/dist/views/post-editor/PostEditorCanvas.js +16 -47
  22. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  23. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  24. package/dist/views/post-editor/PostSectionEditor.js +82 -22
  25. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  26. package/package.json +4 -4
  27. package/src/__tests__/styles/responsive-layer.test.ts +2 -2
  28. package/src/__tests__/views/canvas-surface-shared.test.ts +61 -0
  29. package/src/__tests__/views/post-section-editor.test.tsx +62 -0
  30. package/src/views/page-editor/CanvasSurface.tsx +171 -0
  31. package/src/views/page-editor/EditorCanvas.tsx +46 -144
  32. package/src/views/post-editor/PostEditorCanvas.tsx +54 -74
  33. package/src/views/post-editor/PostSectionEditor.tsx +180 -81
  34. package/dist/components/EditorSeoAside.d.ts +0 -23
  35. package/dist/components/EditorSeoAside.d.ts.map +0 -1
  36. package/dist/components/EditorSeoAside.js +0 -21
  37. package/dist/components/EditorSeoAside.js.map +0 -1
  38. package/src/components/EditorSeoAside.tsx +0 -68
@@ -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 { CanvasMetaLine } from './CanvasMetaLine.js'
7
- import { fitScale, getBreakpoint, type BreakpointId, type Zoom } from '../../lib/breakpoints.js'
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
- * Two measurement rules, both learned the hard way:
58
- *
59
- * 1. Available width comes from a **zero-height sentinel** rendered as the
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 bp = getBreakpoint(breakpoint)
83
- const sentinelRef = useRef<HTMLDivElement>(null)
84
- const surfaceRef = useRef<HTMLDivElement>(null)
85
- const sectionRefs = useRef<Map<string, HTMLElement>>(new Map())
86
-
87
- const [available, setAvailable] = useState(0)
88
- const [pageHeight, setPageHeight] = useState(0)
89
-
90
- const registerRef = useCallback((id: string, el: HTMLElement | null) => {
91
- if (el) sectionRefs.current.set(id, el)
92
- else sectionRefs.current.delete(id)
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
- <div className="prenta-canvas-scroll bg-muted h-full overflow-auto px-6 pt-4.5 pb-10 text-center">
130
- {/*
131
- * Zero-height sentinel. Its offsetWidth IS the usable width — it is
132
- * subject to exactly the same padding, scrollbar and host scaling as the
133
- * canvas, so there is no arithmetic to get wrong. Must stay the first
134
- * child and must stay zero-height.
135
- */}
136
- <div ref={sentinelRef} aria-hidden className="h-0" />
137
-
138
- {sections.length === 0 ? (
139
- <EmptyCanvas />
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
- <CanvasMetaLine breakpoint={bp} pageHeight={pageHeight} scale={scale} />
157
-
158
- {/*
159
- * Fit box: reserves the scaled footprint so the surrounding scroll
160
- * area sizes correctly against a transformed child. Deliberately has
161
- * NO width/height transition — a transition frozen mid-flight (a
162
- * preview that is not visible never finishes one) reports a bogus
163
- * height and corrupts the next measurement.
164
- */}
165
- <div
166
- className="prenta-canvas-fit mx-auto block"
167
- style={{
168
- width: Math.round(bp.width * scale),
169
- height: pageHeight ? Math.round(pageHeight * scale) : undefined,
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
 
@@ -1,12 +1,13 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
4
3
  import { FileText, Plus } from 'lucide-react'
5
4
  import { CanvasEditTip } from '../../components/CanvasEditTip.js'
5
+ import type { Zoom } from '../../lib/breakpoints.js'
6
6
  import type { PageSection, PostHeaderConfig } from '../../lib/post-editor-service.js'
7
+ import { CanvasScroll, CanvasSurface, useSectionRefs } from '../page-editor/CanvasSurface.js'
7
8
  import { SectionRenderer, type PostRenderContext } from '../page-editor/sections/index.js'
8
9
  import { StructureNotice } from '../page-editor/StructureNotice.js'
9
- import { getViewport, type ViewportId } from '../page-editor/viewports.js'
10
+ import { type ViewportId } from '../page-editor/viewports.js'
10
11
  import { PostHeader } from './PostHeader.js'
11
12
 
12
13
  interface PostEditorCanvasProps {
@@ -15,7 +16,10 @@ interface PostEditorCanvasProps {
15
16
  context: PostRenderContext
16
17
  selectedId: string | null
17
18
  viewport: ViewportId
19
+ zoom?: Zoom
18
20
  onSelect: (id: string) => void
21
+ onScaleChange?: (scale: number) => void
22
+ onCanvasElement?: (el: HTMLElement | null) => void
19
23
  /** Add-section CTA on the empty canvas. Omitted by design-owned editors. */
20
24
  onAddSection?: () => void
21
25
  /** Section types rendered as placeholders — shows the "Structure view" notice. */
@@ -25,10 +29,17 @@ interface PostEditorCanvasProps {
25
29
  }
26
30
 
27
31
  /**
28
- * Post preview canvas. Mirrors the Page editor canvas (scaled surface that
29
- * fits the selected viewport) but renders the post header above the section
30
- * list so the editor shows the full post layout header regions plus body
31
- * sections exactly as it will ship.
32
+ * Post preview canvas.
33
+ *
34
+ * Shares {@link CanvasSurface} with the page editor, so measurement, scaling,
35
+ * the fit box and the meta line are one implementation rather than two. What is
36
+ * post-specific is what goes inside: the post header renders above the body
37
+ * sections, so the editor shows the full layout — header regions plus sections
38
+ * — exactly as it will ship.
39
+ *
40
+ * Before this shared surface the post canvas derived its width from
41
+ * `container.clientWidth - 48`, which disagrees with the canvas's own metrics
42
+ * once a scrollbar appears, and it had no breakpoint/scale readout at all.
32
43
  */
33
44
  export function PostEditorCanvas({
34
45
  sections,
@@ -36,82 +47,51 @@ export function PostEditorCanvas({
36
47
  context,
37
48
  selectedId,
38
49
  viewport,
50
+ zoom = 'fit',
39
51
  onSelect,
52
+ onScaleChange,
53
+ onCanvasElement,
40
54
  onAddSection,
41
55
  structuralTypes = [],
42
56
  sitePreviewAvailable = false,
43
57
  }: PostEditorCanvasProps) {
44
- const vp = getViewport(viewport)
45
- const containerRef = useRef<HTMLDivElement>(null)
46
- const surfaceRef = useRef<HTMLDivElement>(null)
47
- const sectionRefs = useRef<Map<string, HTMLElement>>(new Map())
48
- const [scale, setScale] = useState(1)
49
- const [surfaceHeight, setSurfaceHeight] = useState<number | null>(null)
50
-
51
- const registerRef = useCallback((id: string, el: HTMLElement | null) => {
52
- if (el) sectionRefs.current.set(id, el)
53
- else sectionRefs.current.delete(id)
54
- }, [])
55
-
56
- useLayoutEffect(() => {
57
- const container = containerRef.current
58
- if (!container) return
59
- const recompute = () => {
60
- const available = container.clientWidth - 48
61
- const next = Math.min(1, available / vp.width)
62
- setScale(next > 0 ? next : 1)
63
- if (surfaceRef.current) {
64
- setSurfaceHeight(surfaceRef.current.offsetHeight * (next > 0 ? next : 1))
65
- }
66
- }
67
- recompute()
68
- const ro = new ResizeObserver(recompute)
69
- ro.observe(container)
70
- if (surfaceRef.current) ro.observe(surfaceRef.current)
71
- return () => ro.disconnect()
72
- }, [vp.width, sections, header, context])
73
-
74
- useEffect(() => {
75
- if (!selectedId) return
76
- const el = sectionRefs.current.get(selectedId)
77
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
78
- }, [selectedId])
58
+ const registerRef = useSectionRefs(selectedId)
79
59
 
80
60
  return (
81
- <div ref={containerRef} className="bg-muted h-full overflow-auto p-6">
82
- {sections.length > 0 && <CanvasEditTip />}
83
- <StructureNotice
84
- structuralTypes={structuralTypes}
85
- sitePreviewAvailable={sitePreviewAvailable}
86
- />
87
- <div
88
- className="mx-auto"
89
- style={{ width: vp.width * scale, height: surfaceHeight ?? undefined }}
61
+ <CanvasScroll>
62
+ <CanvasSurface
63
+ breakpoint={viewport}
64
+ zoom={zoom}
65
+ onScaleChange={onScaleChange}
66
+ onCanvasElement={onCanvasElement}
67
+ chrome={
68
+ <>
69
+ {sections.length > 0 && <CanvasEditTip />}
70
+ <StructureNotice
71
+ structuralTypes={structuralTypes}
72
+ sitePreviewAvailable={sitePreviewAvailable}
73
+ />
74
+ </>
75
+ }
90
76
  >
91
- <div
92
- ref={surfaceRef}
93
- className="prenta-canvas border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
94
- style={{ width: vp.width, transform: `scale(${scale})`, transformOrigin: 'top left' }}
95
- >
96
- <PostHeader config={header} context={context} />
97
- {sections.length === 0 ? (
98
- <EmptyBody onAddSection={onAddSection} />
99
- ) : (
100
- sections.map((section) => (
101
- <SectionRenderer
102
- key={section.id}
103
- section={section}
104
- context={context}
105
- editable
106
- selected={section.id === selectedId}
107
- onSelect={onSelect}
108
- registerRef={registerRef}
109
- />
110
- ))
111
- )}
112
- </div>
113
- </div>
114
- </div>
77
+ <PostHeader config={header} context={context} />
78
+ {sections.length === 0 ? (
79
+ <EmptyBody onAddSection={onAddSection} />
80
+ ) : (
81
+ sections.map((section) => (
82
+ <SectionRenderer
83
+ key={section.id}
84
+ section={section}
85
+ context={context}
86
+ editable
87
+ selected={section.id === selectedId}
88
+ onSelect={onSelect}
89
+ registerRef={registerRef}
90
+ />
91
+ ))
92
+ )}
93
+ </CanvasSurface>
94
+ </CanvasScroll>
115
95
  )
116
96
  }
117
97