From 3afe8b61fd288bbb2a8578f7ce23806db17eb1ac Mon Sep 17 00:00:00 2001 From: alfaarghya Date: Thu, 3 Sep 2026 21:25:05 +0530 Subject: [PATCH] fix(editor): stop iOS reloading when the template picker opens - render the side panels only above lg, so the picker is not mounted twice - allow the LAN dev origin so phones can load the dev server --- src/components/editor/editor.tsx | 113 ++++++++++++++++++++++++------ src/components/editor/preview.tsx | 22 +++--- src/lib/renderer/stage.ts | 14 ++-- 3 files changed, 115 insertions(+), 34 deletions(-) diff --git a/src/components/editor/editor.tsx b/src/components/editor/editor.tsx index 54543c1..99c8cfd 100644 --- a/src/components/editor/editor.tsx +++ b/src/components/editor/editor.tsx @@ -49,6 +49,8 @@ type EditorProps = { }; const ratios: AspectRatio[] = ['1:1', '4:5', '16:9', '9:16']; +/** Viewport at which the side panels replace the mobile sheets, matching `lg`. */ +const panelQuery = '(min-width: 1024px)'; /** * Renders the responsive editor and initializes it from route parameters. @@ -57,6 +59,7 @@ const ratios: AspectRatio[] = ['1:1', '4:5', '16:9', '9:16']; */ function Editor({ repo, templateId }: EditorProps) { const state = useEditorStore(); + const sidePanels = useSidePanels(); const initialized = useRef(false); const [repoInput, setRepoInput] = useState(repo ?? ''); const [fontsLoaded, setFontsLoaded] = useState(false); @@ -298,17 +301,15 @@ function Editor({ repo, templateId }: EditorProps) { } as CSSProperties } > - +
@@ -351,17 +352,17 @@ function Editor({ repo, templateId }: EditorProps) { />
- +
@@ -376,6 +377,78 @@ function Editor({ repo, templateId }: EditorProps) { ); } +/** + * Renders a resizable side panel, or nothing while the sheets carry it instead. + * + * @param props - Visibility, edge, resizer state, and the panel to host. + */ +function SidePanel({ + show, + side, + width, + label, + onResize, + children, +}: { + show: boolean; + side: 'left' | 'right'; + width: number; + label: string; + onResize(width: number): void; + children: ReactNode; +}) { + if (!show) { + return null; + } + + const resizer = ( + + ); + + return ( + + ); +} + +/** + * Tracks whether the viewport is wide enough for the side panels. + * + * @returns Whether the panels render beside the canvas instead of in sheets. + */ +function useSidePanels() { + const [sidePanels, setSidePanels] = useState( + () => window.matchMedia(panelQuery).matches, + ); + + useEffect(() => { + const media = window.matchMedia(panelQuery); + const update = () => setSidePanels(media.matches); + + media.addEventListener('change', update); + update(); + return () => media.removeEventListener('change', update); + }, []); + + return sidePanels; +} + /** @returns Saved panel widths clamped to the resizer bounds, or the defaults. */ function restorePanelWidths(): { left: number; right: number } { const saved = readPanelWidths(); diff --git a/src/components/editor/preview.tsx b/src/components/editor/preview.tsx index 2973b96..a52b09a 100644 --- a/src/components/editor/preview.tsx +++ b/src/components/editor/preview.tsx @@ -31,12 +31,22 @@ function Preview({ scene, label, compact = false }: PreviewProps) { let stage: Awaited> | undefined; let observer: ResizeObserver | undefined; + // A hidden host measures zero, so the scale it reports is zero too and the + // stage it carries holds no canvas until the host is shown. + function scaleFor(hostElement: HTMLDivElement) { + return Math.min( + hostElement.clientWidth / scene.width, + hostElement.clientHeight / scene.height, + 1, + ); + } + async function mountStage( hostElement: HTMLDivElement, stageContainer: HTMLDivElement, ) { const renderContainer = document.createElement('div'); - stage = await renderScene(scene, renderContainer); + stage = await renderScene(scene, renderContainer, scaleFor(hostElement)); if (disposed) { stage.destroy(); stage = undefined; @@ -50,17 +60,11 @@ function Preview({ scene, label, compact = false }: PreviewProps) { return; } - // A host that is hidden or on its way out of the tree measures zero, - // and sizing the stage to nothing fails the draw that follows. - if (hostElement.clientWidth === 0 || hostElement.clientHeight === 0) { + const scale = scaleFor(hostElement); + if (scale === 0) { return; } - const scale = Math.min( - hostElement.clientWidth / scene.width, - hostElement.clientHeight / scene.height, - 1, - ); stage.scale({ x: scale, y: scale }); stage.width(scene.width * scale); stage.height(scene.height * scale); diff --git a/src/lib/renderer/stage.ts b/src/lib/renderer/stage.ts index 32eb2ad..3f82251 100644 --- a/src/lib/renderer/stage.ts +++ b/src/lib/renderer/stage.ts @@ -18,19 +18,23 @@ Konva.autoDrawEnabled = false; * * @param scene - Renderer-independent scene to draw. * @param container - Element that hosts the Konva stage. + * @param scale - Fraction of the scene size the stage is drawn at. * @returns The rendered stage, which the caller must destroy when finished. */ -async function renderScene(scene: Scene, container: HTMLDivElement) { +async function renderScene(scene: Scene, container: HTMLDivElement, scale = 1) { await fontsReady; const stage = new Konva.Stage({ container, - width: scene.width, - height: scene.height, + width: scene.width * scale, + height: scene.height * scale, + scaleX: scale, + scaleY: scale, }); const layer = new Konva.Layer(); - // A preview is drawn below the scene size, so the layer keeps at least a - // doubled backing store to stop images softening on low-density screens. + // The layer is allocated at the size it is displayed at, so its backing + // store follows the screen density and keeps at least a doubled store to + // stop images softening on low-density screens. layer.getCanvas().setPixelRatio(Math.max(2, window.devicePixelRatio || 1)); layer.add( new Konva.Rect({