mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 15:56:56 +00:00
feat: iadd drawer and editor navigation, and introduce letter management hooks
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user