feat: iadd drawer and editor navigation, and introduce letter management hooks

This commit is contained in:
ramvignesh-b
2026-04-13 00:56:58 +05:30
parent e328ce83f9
commit ad8a73bb47
13 changed files with 601 additions and 111 deletions
+32 -15
View File
@@ -10,6 +10,7 @@ export type CanvasTools = {
getData: () => { objects: CanvasJSON["objects"] }; // no-any hack :/
getJsonData: () => string;
getImages: () => { src: string; file: File }[];
loadData: (data: any) => Promise<void>;
};
export interface FabricImageWithFile extends fabric.FabricImage {
@@ -27,7 +28,6 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
let canvas: fabric.Canvas | null = null;
const init = async () => {
// lazy populate
await document.fonts.ready;
const waitForLayout = (): Promise<number> => {
return new Promise((resolve) => {
@@ -48,23 +48,21 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
600,
);
// init canvas
canvas = new fabric.Canvas(canvasRef.current, {
width: finalWidth,
height: initialHeight,
selection: false,
preserveObjectStacking: true,
allowTouchScrolling: true, // for mobile
allowTouchScrolling: true,
});
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...", {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
@@ -78,7 +76,7 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
editable: true,
hasControls: false,
hasBorders: false,
objectCaching: false, // for font crispness
objectCaching: false,
splitByGrapheme: false,
lockMovementX: true,
lockMovementY: true,
@@ -89,7 +87,6 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
textboxRef.current = textbox;
canvas.add(textbox);
// automatically adjust height
textbox.on("changed", () => {
if (!canvas || !wrapperRef.current) return;
const neededHeight = textbox.top + textbox.height + PAD;
@@ -100,15 +97,12 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
}
});
// 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"]',
);
@@ -151,8 +145,7 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
fabricRef.current?.add(img);
fabricRef.current?.setActiveObject(img);
fabricRef.current?.requestRenderAll();
URL.revokeObjectURL(url); // cleanup browser upload
URL.revokeObjectURL(url);
});
},
getData: () => {
@@ -167,12 +160,36 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
if (!fabricRef.current) return [];
const images = fabricRef.current.getObjects(
"Image",
) as FabricImageWithFile[];
) as fabric.FabricImage[];
return images.map((img) => ({
src: (img.getElement() as HTMLImageElement).currentSrc,
file: img._customRawFile,
src: img.getSrc(),
file: (img as any)._customRawFile,
}));
},
loadData: async (data: any) => {
if (!fabricRef.current) return;
await fabricRef.current.loadFromJSON(data);
// find the textbox and restore focus
const objects = fabricRef.current.getObjects("Textbox");
if (objects.length > 0) {
const textbox = objects[0] as fabric.Textbox;
textbox.lockMovementX = true;
textbox.lockMovementY = true;
textbox.hasControls = false;
textbox.hasBorders = false;
textboxRef.current = textbox;
fabricRef.current.setActiveObject(textbox);
if (textbox.text) {
// move cursor to end
textbox.selectionStart = textbox.text.length;
textbox.selectionEnd = textbox.text.length;
}
textbox.enterEditing();
}
fabricRef.current.renderAll();
},
}));
return (
+64
View File
@@ -0,0 +1,64 @@
interface DrawerSectionProps {
id: string;
title: string;
count: string;
isOpen: boolean;
onClick: () => void;
children: React.ReactNode;
}
export function DrawerSection({
id,
title,
count,
isOpen,
onClick,
children,
}: DrawerSectionProps) {
return (
<div
id={id}
className={`join-item group flex flex-col transition-colors ${isOpen ? "bg-base-300/30" : ""}`}
>
<div
className={`overflow-hidden transition-all duration-1000 ease-in-out bg-neutral/10 ${
isOpen
? "max-h-125 opacity-100 py-3 border-b border-base-content/5"
: "max-h-0 opacity-0"
}`}
>
{children}
</div>
<button
type="button"
onClick={onClick}
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-1000 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
>
<div className="flex-1">
<div
className={`font-sans text-xs tracking-[0.2em] uppercase transition-colors duration-300 ${
isOpen
? "text-base-content"
: "text-base-content/40 group-hover:text-base-content/80"
}`}
>
{title}
</div>
<div className="font-sans text-[0.6rem] text-base-content/20 mt-1">
{count}
</div>
</div>
<div
className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${
isOpen
? "bg-primary/80! opacity-80 scale-110"
: "group-hover:bg-primary"
}`}
>
<div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" />
</div>
</button>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useNavigate } from "react-router-dom";
import { ROUTES } from "../../config/routes";
export function LetterItem({
preview,
timestamp,
id,
status,
}: {
preview: string;
timestamp: string;
id: string;
status: "DRAFT" | "SEALED" | "BURNED";
}) {
const navigate = useNavigate();
function handleNavigate(): void {
if (status === "SEALED") {
navigate(ROUTES.READ(id));
} else {
navigate(ROUTES.WRITE(id));
}
}
return (
<button
type="button"
onClick={handleNavigate}
className="p-[16px_28px_16px_76px] border-b border-base-content/3 flex items-center gap-4 hover:bg-base-content/5 transition-colors group w-full text-left"
>
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60">
{preview}
</div>
<div className="font-sans text-[0.6rem] text-base-content/20">
{timestamp}
</div>
</button>
);
}