Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 32 additions & 31 deletions frontend/src/components/delete-confirm-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,56 @@
import { useEffect, useId, useRef } from 'react'
import { useEffect, useId, useRef } from "react";
import { useFocusTrap } from "../hooks/use-focus-trap";

type DeleteConfirmDialogProps = {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
onConfirm: () => void
onClose: () => void
}
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onClose: () => void;
};

export default function DeleteConfirmDialog({
open,
title,
message,
confirmLabel = 'Move to trash',
cancelLabel = 'Cancel',
confirmLabel = "Move to trash",
cancelLabel = "Cancel",
onConfirm,
onClose,
}: DeleteConfirmDialogProps) {
const titleId = useId()
const descId = useId()
const panelRef = useRef<HTMLDivElement>(null)
const confirmRef = useRef<HTMLButtonElement>(null)
const titleId = useId();
const descId = useId();
const panelRef = useFocusTrap(open);
const confirmRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
if (!open) return
const t = window.setTimeout(() => confirmRef.current?.focus(), 0)
return () => window.clearTimeout(t)
}, [open])
if (!open) return;
const t = window.setTimeout(() => confirmRef.current?.focus(), 0);
return () => window.clearTimeout(t);
}, [open]);

useEffect(() => {
if (!open) return
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);

if (!open) return null
if (!open) return null;

return (
<div
className="fixed inset-0 z-[260] flex items-center justify-center p-4 sm:p-6"
role="presentation"
onMouseDown={e => {
if (e.target === e.currentTarget) onClose()
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="absolute inset-0 bg-black/35 backdrop-blur-[2px]" aria-hidden />
Expand Down Expand Up @@ -90,5 +91,5 @@ export default function DeleteConfirmDialog({
</div>
</div>
</div>
)
);
}
84 changes: 54 additions & 30 deletions frontend/src/components/editor-shortcuts-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,84 @@
import { Cancel01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useEffect } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { useFocusTrap } from "../hooks/use-focus-trap";

type ShortcutRow = { keys: string; action: string }
type ShortcutRow = { keys: string; action: string };

const ROWS: ShortcutRow[] = [
{ keys: 'Cmd/Ctrl + Z', action: 'Undo' },
{ keys: 'Cmd/Ctrl + Shift + Z', action: 'Redo' },
{ keys: 'Cmd/Ctrl + G', action: 'Group selection' },
{ keys: 'Cmd/Ctrl + Shift + G', action: 'Ungroup' },
{ keys: 'Cmd/Ctrl + D', action: 'Duplicate selection' },
{ keys: 'Cmd/Ctrl + C / V', action: 'Copy / paste (Avnac clipboard)' },
{ keys: 'Arrow keys', action: 'Nudge selection 1px' },
{ keys: 'Shift + Arrow keys', action: 'Nudge selection 10px' },
{ keys: 'Delete / Backspace', action: 'Delete selection' },
{ keys: 'Option/Alt + drag', action: 'Duplicate while dragging (canvas)' },
{ keys: "Cmd/Ctrl + Z", action: "Undo" },
{ keys: "Cmd/Ctrl + Shift + Z", action: "Redo" },
{ keys: "Cmd/Ctrl + G", action: "Group selection" },
{ keys: "Cmd/Ctrl + Shift + G", action: "Ungroup" },
{ keys: "Cmd/Ctrl + D", action: "Duplicate selection" },
{ keys: "Cmd/Ctrl + C / V", action: "Copy / paste (Avnac clipboard)" },
{ keys: "Arrow keys", action: "Nudge selection 1px" },
{ keys: "Shift + Arrow keys", action: "Nudge selection 10px" },
{ keys: "Delete / Backspace", action: "Delete selection" },
{ keys: "Option/Alt + drag", action: "Duplicate while dragging (canvas)" },
{
keys: 'Vector board — tools',
action: 'V = Move, P = Pen, Shift+P = Pencil, R = Rectangle, O = Ellipse',
keys: "Vector board — tools",
action: "V = Move, P = Pen, Shift+P = Pencil, R = Rectangle, O = Ellipse",
},
{
keys: 'Vector board — selection',
keys: "Vector board — selection",
action:
'Shift+click multi-selects; drag empty area marquees; Shift+drag marquee = additive; Delete/Backspace removes; Cmd/Ctrl+C/V copies/pastes; Alt+drag duplicates; Arrow nudges 1px (Shift = 10px)',
"Shift+click multi-selects; drag empty area marquees; Shift+drag marquee = additive; Delete/Backspace removes; Cmd/Ctrl+C/V copies/pastes; Alt+drag duplicates; Arrow nudges 1px (Shift = 10px)",
},
{
keys: 'Vector board — transform',
keys: "Vector board — transform",
action:
'Drag selection handles to resize (Shift = proportional, Alt = from center); double-click a pen shape to edit its anchors, then Alt+click to remove an anchor, Esc to exit',
"Drag selection handles to resize (Shift = proportional, Alt = from center); double-click a pen shape to edit its anchors, then Alt+click to remove an anchor, Esc to exit",
},
{
keys: 'Vector board — view / z-order',
keys: "Vector board — view / z-order",
action:
'Space+drag or middle-click drag to pan; Cmd/Ctrl+wheel zooms; Cmd/Ctrl+0 resets, Cmd/Ctrl+1 fits, Cmd/Ctrl+=/- zooms; Cmd/Ctrl+] / [ moves selection forward/backward (add Shift for front/back)',
"Space+drag or middle-click drag to pan; Cmd/Ctrl+wheel zooms; Cmd/Ctrl+0 resets, Cmd/Ctrl+1 fits, Cmd/Ctrl+=/- zooms; Cmd/Ctrl+] / [ moves selection forward/backward (add Shift for front/back)",
},
{ keys: '?', action: 'Show shortcuts' },
]
{ keys: "?", action: "Show shortcuts" },
];

type Props = {
open: boolean
onClose: () => void
}
open: boolean;
onClose: () => void;
};

export default function EditorShortcutsModal({ open, onClose }: Props) {
if (!open) return null
const panelRef = useFocusTrap(open);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);

useEffect(() => {
if (!open) return;
const t = window.setTimeout(() => {
panelRef.current?.querySelector<HTMLElement>("button")?.focus();
}, 0);
return () => window.clearTimeout(t);
}, [open]);

if (!open) return null;

return (
<div
className="pointer-events-auto fixed inset-0 z-[20000] flex items-center justify-center bg-black/35 p-4 backdrop-blur-[2px]"
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
onMouseDown={e => {
if (e.target === e.currentTarget) onClose()
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
data-avnac-chrome
className="max-h-[90vh] w-full max-w-md overflow-hidden rounded-2xl border border-black/[0.08] bg-white shadow-2xl"
>
Expand Down Expand Up @@ -85,5 +109,5 @@ export default function EditorShortcutsModal({ open, onClose }: Props) {
</div>
</div>
</div>
)
);
}
13 changes: 11 additions & 2 deletions frontend/src/components/image-crop-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HugeiconsIcon } from '@hugeicons/react'
import type { CSSProperties } from 'react'
import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useFocusTrap } from '../hooks/use-focus-trap'

const MIN_SIDE = 12
const HANDLE_PX = 9
Expand Down Expand Up @@ -35,7 +36,14 @@ function clampCrop(r: CropRect, nw: number, nh: number): CropRect {
return { x, y, w, h }
}

export default function ImageCropModal({ open, imageSrc, initialCrop, onCancel, onApply }: Props) {
export default function ImageCropModal({
open,
imageSrc,
initialCrop,
onCancel,
onApply,
}: Props) {
const dialogRef = useFocusTrap(open)
const wrapRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const initialCropRef = useRef(initialCrop)
Expand Down Expand Up @@ -230,7 +238,7 @@ export default function ImageCropModal({ open, imageSrc, initialCrop, onCancel,
if (e.target === e.currentTarget) onCancel()
}}
>
<div className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-black/10 bg-[var(--surface)] shadow-2xl">
<div ref={dialogRef} className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-black/10 bg-[var(--surface)] shadow-2xl">
<div className="flex items-center justify-between border-b border-black/10 px-4 py-3">
<h2 className="m-0 text-base font-semibold text-[var(--text)]">Crop image</h2>
<button
Expand Down Expand Up @@ -309,6 +317,7 @@ export default function ImageCropModal({ open, imageSrc, initialCrop, onCancel,
</button>
<button
type="button"
data-autofocus
disabled={nw <= 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-transparent bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
onClick={() =>
Expand Down
39 changes: 22 additions & 17 deletions frontend/src/components/new-canvas-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { StarIcon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useNavigate } from '@tanstack/react-router'
import { usePostHog } from 'posthog-js/react'
import { useEffect, useId, useRef, useState } from 'react'
import { ARTBOARD_PRESETS, type ArtboardPresetCategory } from '../data/artboard-presets'
import { useEditorUnsupportedOnThisDevice } from '../hooks/use-editor-device-support'
import { StarIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useId, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { usePostHog } from "posthog-js/react";
import { useEditorUnsupportedOnThisDevice } from "../hooks/use-editor-device-support";
import { ARTBOARD_PRESETS,
type ArtboardPresetCategory, } from "../data/artboard-presets";
import { useFocusTrap } from "../hooks/use-focus-trap";

const CANVAS_MIN = 100
const CANVAS_MAX = 16000
Expand Down Expand Up @@ -94,16 +96,19 @@ type NewCanvasDialogProps = {
onClose: () => void
}

export default function NewCanvasDialog({ open, onClose }: NewCanvasDialogProps) {
const navigate = useNavigate()
const posthog = usePostHog()
const editorUnsupported = useEditorUnsupportedOnThisDevice()
const titleId = useId()
const panelRef = useRef<HTMLDivElement>(null)
const [mode, setMode] = useState<'presets' | 'custom'>('presets')
const [customW, setCustomW] = useState('1920')
const [customH, setCustomH] = useState('1080')
const [customError, setCustomError] = useState<string | null>(null)
export default function NewCanvasDialog({
open,
onClose,
}: NewCanvasDialogProps) {
const navigate = useNavigate();
const posthog = usePostHog();
const editorUnsupported = useEditorUnsupportedOnThisDevice();
const titleId = useId();
const panelRef = useFocusTrap(open);
const [mode, setMode] = useState<"presets" | "custom">("presets");
const [customW, setCustomW] = useState("1920");
const [customH, setCustomH] = useState("1080");
const [customError, setCustomError] = useState<string | null>(null);

useEffect(() => {
if (!open) return
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/hooks/use-focus-trap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useRef } from "react";

const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';

export function useFocusTrap(open: boolean) {
const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<Element | null>(null);

useEffect(() => {
if (open) {
triggerRef.current = document.activeElement;
}
}, [open]);

useEffect(() => {
if (!open && triggerRef.current instanceof HTMLElement) {
triggerRef.current.focus();
triggerRef.current = null;
}
}, [open]);

// Tab trap
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const nodes = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE));
if (!nodes.length) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);

return panelRef;
}