diff --git a/frontend/src/components/ui/ComposeCanvas.tsx b/frontend/src/components/ui/ComposeCanvas.tsx new file mode 100644 index 0000000..b192ada --- /dev/null +++ b/frontend/src/components/ui/ComposeCanvas.tsx @@ -0,0 +1,141 @@ +import * as fabric from "fabric"; +import { useEffect, useRef } from "react"; + +const PAD = 36; + +export default function ComposeCanvas() { + const wrapperRef = useRef(null); + const canvasRef = useRef(null); + const fabricRef = useRef(null); + const textboxRef = useRef(null); + + useEffect(() => { + let isMounted = true; + let canvas: fabric.Canvas | null = null; + + const init = async () => { + // lazy populate + await document.fonts.ready; + const waitForLayout = (): Promise => { + return new Promise((resolve) => { + const check = () => { + const wrapperWidth = wrapperRef.current?.clientWidth || 0; + if (wrapperWidth > 0) resolve(wrapperWidth); + else requestAnimationFrame(check); + }; + check(); + }); + }; + + const finalWidth = await waitForLayout(); + if (!isMounted || !canvasRef.current || !wrapperRef.current) return; + + const initialHeight = Math.max( + wrapperRef.current.clientHeight || 900, + 600, + ); + + // init canvas + canvas = new fabric.Canvas(canvasRef.current, { + width: finalWidth, + height: initialHeight, + selection: false, + preserveObjectStacking: true, + allowTouchScrolling: true, // for mobile + }); + + fabricRef.current = canvas; + + // transparent background + const wrapperEl = canvas.getElement().parentElement; + if (wrapperEl) wrapperEl.style.background = "transparent"; + + // the core textbox + const textbox = new fabric.Textbox("Take a deep breath...", { + originX: "left", + originY: "top", + left: PAD, + top: PAD, + width: finalWidth - PAD * 2, + fontSize: 16, + fontWeight: 500, + fontFamily: "Playfair Display Variable", + fill: "#000", + lineHeight: 1.5, + editable: true, + hasControls: false, + hasBorders: false, + objectCaching: false, // for font crispness + splitByGrapheme: false, + lockMovementX: true, + lockMovementY: true, + lockScalingX: true, + lockScalingY: true, + }); + + textboxRef.current = textbox; + canvas.add(textbox); + + // automatically adjust height + textbox.on("changed", () => { + if (!canvas || !wrapperRef.current) return; + const neededHeight = textbox.top + textbox.height + PAD; + if (neededHeight > canvas.height) { + const newH = neededHeight + PAD; + canvas.setDimensions({ height: newH }); + wrapperRef.current.style.height = `${newH}px`; + } + }); + + // auto focus + setTimeout(() => { + if (!isMounted) return; + canvas?.setActiveObject(textbox); + textbox.enterEditing(); + canvas?.renderAll(); + + // Accessibility fix for Fabric.js hidden textarea + // searching globally in case it is appended to body + const hiddenTextareas = document.querySelectorAll( + 'textarea[data-fabric="textarea"]', + ); + hiddenTextareas.forEach((ta) => { + if (!ta.getAttribute("aria-label")) { + ta.setAttribute("aria-label", "Canvas text input"); + } + }); + }, 100); + + canvas.on("mouse:down", (opt) => { + if (!opt.target || opt.target === textbox) { + canvas?.setActiveObject(textbox); + textbox.enterEditing(); + canvas?.renderAll(); + } + }); + }; + + init(); + + return () => { + isMounted = false; + canvas?.dispose(); + fabricRef.current = null; + textboxRef.current = null; + }; + }, []); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx new file mode 100644 index 0000000..61b84a7 --- /dev/null +++ b/frontend/src/pages/Editor.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import ComposeCanvas from "../components/ui/ComposeCanvas"; +import DateDisplay from "../components/ui/DateDisplay"; + +export default function Editor() { + const [recipient, setRecipient] = useState(""); + + return ( +
+
+
+
+ + setRecipient(e.target.value)} + className="bg-transparent border-none outline-none text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full" + /> +
+ +
+ +
+ Toolbar Placeholder +
+ +
+
+ ); +}