From b753ce4e2daf486fb4aeeda2f166f13e3a5c29f1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:33:03 +0400 Subject: [PATCH 01/40] Update VSCode extension recommendations --- .vscode/extensions.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 9f54332817..48fecc0661 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,6 @@ { - "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] + "recommendations": [ + "biomejs.biome", + "rust-lang.rust-analyzer" + ] } From dd51fcca85eb83c04493b89976890b87f4ec99fa Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:33:18 +0400 Subject: [PATCH 02/40] Support different minimum crop sizes for screenshot mode --- apps/desktop/src/routes/target-select-overlay.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index eee4d02baa..ab314ab339 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -67,6 +67,7 @@ import { } from "./(window-chrome)/OptionsContext"; const MIN_SIZE = { width: 150, height: 150 }; +const MIN_SCREENSHOT_SIZE = { width: 1, height: 1 }; const capitalize = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); @@ -431,9 +432,13 @@ function Inner() { return bounds.width <= 1 && bounds.height <= 1 && !isInteracting(); }); + const minSize = () => + options.mode === "screenshot" ? MIN_SCREENSHOT_SIZE : MIN_SIZE; + const isValid = createMemo(() => { const b = crop(); - return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; + const min = minSize(); + return b.width >= min.width && b.height >= min.height; }); const [targetState, setTargetState] = createSignal<{ @@ -781,7 +786,9 @@ function Inner() {
-

Minimum size is 150 x 150

+

+ Minimum size is {minSize().width} x {minSize().height} +

{crop().width} x {crop().height} From 01879989634e30ccce453c86214a2a2848ddd91d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:41:30 +0400 Subject: [PATCH 03/40] Format VSCode extensions.json file --- .vscode/extensions.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 48fecc0661..d6bb1b45df 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,3 @@ { - "recommendations": [ - "biomejs.biome", - "rust-lang.rust-analyzer" - ] + "recommendations": ["biomejs.biome", "rust-lang.rust-analyzer"] } From acdd6cc00232ae0e24dfc9a0a0c775ef372ad4b1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:41:37 +0400 Subject: [PATCH 04/40] Add bottom margin to ViewAllButton in TargetMenuGrid --- .../src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx index 9d45814b74..d5d7936754 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx @@ -90,7 +90,7 @@ function ViewAllButton(props: { onClick: () => void; label: string }) { - -
+ + +
+ +
-
+ +
- -
- - Intensity {Math.round(maskLevel())} - - update("maskLevel", v[0])} - minValue={4} - maxValue={50} - step={1} - class="w-full" - /> -
-
+ + + update("maskLevel", v[0])} + minValue={4} + maxValue={50} + step={1} + class="w-24" + /> + + - -
- - Size {ann().height}px - - update("height", v[0])} - minValue={12} - maxValue={100} - step={1} - class="w-full" - /> -
-
+ + + update("height", v[0])} + minValue={12} + maxValue={100} + step={1} + class="w-20" + /> + + -
+
- -
+
- + ); }} ); } +function ConfigItem(props: { + label: string; + value?: string; + children: JSX.Element; +}) { + return ( +
+ + {props.label} + {props.value && ( + {props.value} + )} + + {props.children} +
+ ); +} + function ColorPickerButton(props: { value: string; onChange: (value: string) => void; allowTransparent?: boolean; }) { - // Helper to handle RGB <-> Hex const rgbValue = createMemo(() => { if (props.value === "transparent") return [0, 0, 0] as [number, number, number]; @@ -194,24 +196,24 @@ function ColorPickerButton(props: { const isTransparent = createMemo(() => props.value === "transparent"); return ( - + -
+
- -
+ +
{ @@ -219,17 +221,17 @@ function ColorPickerButton(props: { }} /> -
+
)} diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx index 41927cc458..c4eeacacf2 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx @@ -7,52 +7,43 @@ import IconLucideEyeOff from "~icons/lucide/eye-off"; import IconLucideMousePointer2 from "~icons/lucide/mouse-pointer-2"; import IconLucideSquare from "~icons/lucide/square"; import IconLucideType from "~icons/lucide/type"; -import { AnnotationConfig } from "./AnnotationConfig"; import { type AnnotationType, useScreenshotEditorContext } from "./context"; export function AnnotationTools() { return ( - <> -
- - - - - - -
- - +
+ + + + + + +
); } diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx index cfa497ab97..e1e26f7137 100644 --- a/apps/desktop/src/routes/screenshot-editor/Header.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -15,7 +15,6 @@ import IconLucideMoreHorizontal from "~icons/lucide/more-horizontal"; import IconLucideSave from "~icons/lucide/save"; import { AnnotationTools } from "./AnnotationTools"; import { useScreenshotEditorContext } from "./context"; -import PresetsSubMenu from "./PresetsDropdown"; import { AspectRatioSelect } from "./popovers/AspectRatioSelect"; import { BackgroundSettingsPopover } from "./popovers/BackgroundSettingsPopover"; import { BorderPopover } from "./popovers/BorderPopover"; @@ -32,8 +31,9 @@ import { import { useScreenshotExport } from "./useScreenshotExport"; export function Header() { - const { path, setDialog, project, latestFrame } = - useScreenshotEditorContext(); + const ctx = useScreenshotEditorContext(); + const { setDialog, project, latestFrame } = ctx; + const path = () => ctx.editorInstance()?.path ?? ""; const { exportImage, isExporting } = useScreenshotExport(); @@ -149,7 +149,7 @@ export function Header() { > { - revealItemInDir(path); + revealItemInDir(path()); }} > @@ -162,7 +162,7 @@ export function Header() { "Are you sure you want to delete this screenshot?", ) ) { - await remove(path); + await remove(path()); await getCurrentWindow().close(); } }} @@ -171,15 +171,6 @@ export function Header() { Delete - - - - - as={DropdownMenu.Group} - class="p-1" - > - - diff --git a/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx b/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx deleted file mode 100644 index 7f212b772c..0000000000 --- a/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { DropdownMenu as KDropdownMenu } from "@kobalte/core/dropdown-menu"; -import { cx } from "cva"; -import { Suspense } from "solid-js"; -import IconCapCirclePlus from "~icons/cap/circle-plus"; -import IconCapPresets from "~icons/cap/presets"; -import IconLucideChevronRight from "~icons/lucide/chevron-right"; -import { - DropdownItem, - MenuItemList, - PopperContent, - topSlideAnimateClasses, -} from "./ui"; - -export function PresetsSubMenu() { - return ( - - -
- - Presets -
- -
- - - - as={KDropdownMenu.SubContent} - class={cx("w-72 max-h-56", topSlideAnimateClasses)} - > - - as={KDropdownMenu.Group} - class="overflow-y-auto flex-1 scrollbar-none" - > -
- No Presets -
- - - as={KDropdownMenu.Group} - class="border-t shrink-0" - > - - Create new preset - - - - -
-
-
- ); -} - -export default PresetsSubMenu; From 92221566d947606b117fea8762198bb79cf91426 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:13:05 +0400 Subject: [PATCH 06/40] try add clippy for windows --- .github/workflows/ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77afebc97f..f3e5bec0bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,9 +103,8 @@ jobs: settings: - target: aarch64-apple-darwin runner: macos-latest - # Windows can't take the disk usage lol - # - target: x86_64-pc-windows-msvc - # runner: windows-latest + - target: x86_64-pc-windows-msvc + runner: windows-latest runs-on: ${{ matrix.settings.runner }} permissions: contents: read From 2b0195cf1942cd4c20682e0b6ecd3c49ba25f0cf Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:39:12 +0400 Subject: [PATCH 07/40] Add LayersPanel and improve crop dialog logic --- .../src/routes/screenshot-editor/Editor.tsx | 74 +++-- .../routes/screenshot-editor/LayersPanel.tsx | 295 ++++++++++++++++++ .../src/routes/screenshot-editor/context.tsx | 19 +- 3 files changed, 357 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx diff --git a/apps/desktop/src/routes/screenshot-editor/Editor.tsx b/apps/desktop/src/routes/screenshot-editor/Editor.tsx index 2785ae3d12..d5ec4e1cf6 100644 --- a/apps/desktop/src/routes/screenshot-editor/Editor.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Editor.tsx @@ -26,8 +26,10 @@ import { composeEventHandlers } from "~/utils/composeEventHandlers"; import IconCapCircleX from "~icons/cap/circle-x"; import IconLucideMaximize from "~icons/lucide/maximize"; import IconLucideRatio from "~icons/lucide/ratio"; +import { AnnotationConfigBar } from "./AnnotationConfig"; import { useScreenshotEditorContext } from "./context"; import { Header } from "./Header"; +import { LayersPanel } from "./LayersPanel"; import { Preview } from "./Preview"; import { Dialog, EditorButton } from "./ui"; @@ -39,6 +41,8 @@ export function Editor() { setProject, project, setSelectedAnnotationId, + layersPanelOpen, + setLayersPanelOpen, } = useScreenshotEditorContext(); createEffect(() => { @@ -103,13 +107,14 @@ export function Editor() { setSelectedAnnotationId(null); break; case "p": { - // Toggle Padding - // We need to push history here too if we want undo for padding projectHistory.push(); const currentPadding = project.background.padding; setProject("background", "padding", currentPadding === 0 ? 20 : 0); break; } + case "l": + setLayersPanelOpen(!layersPanelOpen()); + break; } } }; @@ -120,11 +125,17 @@ export function Editor() { return ( <> -
+
+
+ +
+ + +
@@ -192,12 +203,21 @@ function Dialogs() { ); }); - const initialBounds = { - x: cropDialog().position.x, - y: cropDialog().position.y, - width: cropDialog().size.x, - height: cropDialog().size.y, - }; + const originalSize = cropDialog().originalSize; + const existingCrop = cropDialog().currentCrop; + const initialBounds = existingCrop + ? { + x: existingCrop.position.x, + y: existingCrop.position.y, + width: existingCrop.size.x, + height: existingCrop.size.y, + } + : { + x: 0, + y: 0, + width: originalSize.x, + height: originalSize.y, + }; const [snapToRatio, setSnapToRatioEnabled] = makePersisted( createSignal(true), @@ -260,17 +280,11 @@ function Dialogs() {
Size
- +
×
- +
@@ -321,8 +335,8 @@ function Dialogs() { leftIcon={} onClick={() => cropperRef?.fill()} disabled={ - crop().width === cropDialog().size.x && - crop().height === cropDialog().size.y + crop().width === originalSize.x && + crop().height === originalSize.y } > Full @@ -334,10 +348,10 @@ function Dialogs() { setAspect(null); }} disabled={ - crop().x === cropDialog().position.x && - crop().y === cropDialog().position.y && - crop().width === cropDialog().size.x && - crop().height === cropDialog().size.y + crop().x === initialBounds.x && + crop().y === initialBounds.y && + crop().width === initialBounds.width && + crop().height === initialBounds.height } > Reset @@ -350,8 +364,8 @@ function Dialogs() { class="rounded overflow-hidden relative select-none" style={{ width: (() => { - const srcW = cropDialog().size.x; - const srcH = cropDialog().size.y; + const srcW = originalSize.x; + const srcH = originalSize.y; const maxW = Math.min( windowSize().width * 0.8, 768, @@ -361,8 +375,8 @@ function Dialogs() { return `${srcW * ratio}px`; })(), height: (() => { - const srcW = cropDialog().size.x; - const srcH = cropDialog().size.y; + const srcW = originalSize.x; + const srcH = originalSize.y; const maxW = Math.min( windowSize().width * 0.8, 768, @@ -378,8 +392,8 @@ function Dialogs() { onCropChange={setCrop} aspectRatio={aspect() ?? undefined} targetSize={{ - x: cropDialog().size.x, - y: cropDialog().size.y, + x: originalSize.x, + y: originalSize.y, }} initialCrop={initialBounds} snapToRatioEnabled={snapToRatio()} diff --git a/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx b/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx new file mode 100644 index 0000000000..3587031168 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx @@ -0,0 +1,295 @@ +import { cx } from "cva"; +import { createEffect, createSignal, For, on, Show } from "solid-js"; +import IconLucideArrowUpRight from "~icons/lucide/arrow-up-right"; +import IconLucideCircle from "~icons/lucide/circle"; +import IconLucideEyeOff from "~icons/lucide/eye-off"; +import IconLucideGripVertical from "~icons/lucide/grip-vertical"; +import IconLucideLayers from "~icons/lucide/layers"; +import IconLucideSquare from "~icons/lucide/square"; +import IconLucideType from "~icons/lucide/type"; +import IconLucideX from "~icons/lucide/x"; +import { type Annotation, useScreenshotEditorContext } from "./context"; + +const ANNOTATION_TYPE_ICONS = { + arrow: IconLucideArrowUpRight, + rectangle: IconLucideSquare, + circle: IconLucideCircle, + mask: IconLucideEyeOff, + text: IconLucideType, +}; + +const ANNOTATION_TYPE_LABELS = { + arrow: "Arrow", + rectangle: "Rectangle", + circle: "Circle", + mask: "Mask", + text: "Text", +}; + +export function LayersPanel() { + const { + annotations, + setAnnotations, + selectedAnnotationId, + setSelectedAnnotationId, + setLayersPanelOpen, + projectHistory, + setActiveTool, + setFocusAnnotationId, + } = useScreenshotEditorContext(); + + const [dragState, setDragState] = createSignal<{ + draggedId: string; + startY: number; + currentY: number; + } | null>(null); + + const [dropTargetIndex, setDropTargetIndex] = createSignal( + null, + ); + + const getTypeLabel = (ann: Annotation) => { + if (ann.type === "text" && ann.text) { + const truncated = + ann.text.length > 12 ? `${ann.text.slice(0, 12)}...` : ann.text; + return truncated; + } + return ANNOTATION_TYPE_LABELS[ann.type]; + }; + + const reversedAnnotations = () => [...annotations].reverse(); + + const getActualIndex = (reversedIdx: number) => + annotations.length - 1 - reversedIdx; + + const handleMouseDown = (ann: Annotation, e: MouseEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + + const gripHandle = (e.target as HTMLElement).closest("[data-grip-handle]"); + if (!gripHandle) return; + + e.preventDefault(); + e.stopPropagation(); + + setDragState({ + draggedId: ann.id, + startY: e.clientY, + currentY: e.clientY, + }); + + const handleMouseMove = (moveEvent: MouseEvent) => { + setDragState((prev) => + prev ? { ...prev, currentY: moveEvent.clientY } : null, + ); + + const listEl = document.querySelector("[data-layers-list]"); + if (!listEl) return; + + const items = listEl.querySelectorAll("[data-layer-item]"); + let targetIdx: number | null = null; + + for (let i = 0; i < items.length; i++) { + const item = items[i] as HTMLElement; + const rect = item.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + + if (moveEvent.clientY < midY) { + targetIdx = i; + break; + } + targetIdx = i + 1; + } + + setDropTargetIndex(targetIdx); + }; + + const handleMouseUp = () => { + const state = dragState(); + const targetIdx = dropTargetIndex(); + + if (state && targetIdx !== null) { + const draggedReversedIdx = reversedAnnotations().findIndex( + (a) => a.id === state.draggedId, + ); + + if (draggedReversedIdx !== -1 && draggedReversedIdx !== targetIdx) { + const fromActual = getActualIndex(draggedReversedIdx); + let toActual: number; + + if (targetIdx > draggedReversedIdx) { + toActual = getActualIndex(targetIdx - 1); + } else { + toActual = getActualIndex(targetIdx); + } + + if (fromActual !== toActual) { + projectHistory.push(); + const newAnnotations = [...annotations]; + const [removed] = newAnnotations.splice(fromActual, 1); + newAnnotations.splice(toActual, 0, removed); + setAnnotations(newAnnotations); + } + } + } + + setDragState(null); + setDropTargetIndex(null); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + }; + + const handleLayerClick = (ann: Annotation, e: MouseEvent) => { + if ((e.target as HTMLElement).closest("[data-grip-handle]")) return; + setSelectedAnnotationId(ann.id); + setActiveTool("select"); + setFocusAnnotationId(ann.id); + }; + + const handleDelete = (id: string, e: MouseEvent) => { + e.stopPropagation(); + projectHistory.push(); + setAnnotations((prev) => prev.filter((a) => a.id !== id)); + if (selectedAnnotationId() === id) { + setSelectedAnnotationId(null); + } + }; + + createEffect( + on( + () => annotations.length, + () => { + setDragState(null); + setDropTargetIndex(null); + }, + ), + ); + + return ( +
+
+
+ + Layers +
+ +
+ +
+ 0} + fallback={ +
+ +

No layers yet

+

+ Use the tools above to add annotations +

+
+ } + > + + {(ann, reversedIdx) => { + const Icon = ANNOTATION_TYPE_ICONS[ann.type]; + const isSelected = () => selectedAnnotationId() === ann.id; + const isDragging = () => dragState()?.draggedId === ann.id; + const isDropTarget = () => { + const target = dropTargetIndex(); + return target !== null && target === reversedIdx(); + }; + const showDropIndicatorAfter = () => { + const target = dropTargetIndex(); + const state = dragState(); + if (!state || target === null) return false; + const draggedIdx = reversedAnnotations().findIndex( + (a) => a.id === state.draggedId, + ); + return ( + target === reversedIdx() + 1 && target !== draggedIdx + 1 + ); + }; + + return ( + <> + +
+ +
handleMouseDown(ann, e)} + onClick={(e) => handleLayerClick(ann, e)} + class={cx( + "flex items-center gap-2 px-2 py-1.5 mx-1 rounded-md cursor-pointer transition-all group", + isSelected() + ? "bg-blue-3 dark:bg-blue-4" + : "hover:bg-gray-3", + isDragging() && "opacity-50 bg-gray-3", + )} + > +
+ +
+ +
+ +
+ + + {getTypeLabel(ann)} + + + +
+ +
+ + + ); + }} + + +
+ + +
+ +
+ Drag to reorder • Top = front +
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/context.tsx b/apps/desktop/src/routes/screenshot-editor/context.tsx index b3e5d3cd5b..67220a16bf 100644 --- a/apps/desktop/src/routes/screenshot-editor/context.tsx +++ b/apps/desktop/src/routes/screenshot-editor/context.tsx @@ -1,6 +1,7 @@ import { createContextProvider } from "@solid-primitives/context"; import { trackStore } from "@solid-primitives/deep"; import { debounce } from "@solid-primitives/scheduled"; +import { makePersisted } from "@solid-primitives/storage"; import { convertFileSrc } from "@tauri-apps/api/core"; import { createEffect, createResource, createSignal, on } from "solid-js"; import { createStore, reconcile, unwrap } from "solid-js/store"; @@ -24,7 +25,11 @@ export type CurrentDialog = | { type: "createPreset" } | { type: "renamePreset"; presetIndex: number } | { type: "deletePreset"; presetIndex: number } - | { type: "crop"; position: XY; size: XY }; + | { + type: "crop"; + originalSize: XY; + currentCrop: { position: XY; size: XY } | null; + }; export type DialogState = { open: false } | ({ open: boolean } & CurrentDialog); @@ -105,6 +110,14 @@ function createScreenshotEditorContext() { "select", ); + const [layersPanelOpen, setLayersPanelOpen] = makePersisted( + createSignal(false), + { name: "screenshotEditorLayersPanelOpen" }, + ); + const [focusAnnotationId, setFocusAnnotationId] = createSignal( + null, + ); + const [dialog, setDialog] = createSignal({ open: false, }); @@ -304,6 +317,10 @@ function createScreenshotEditorContext() { setSelectedAnnotationId, activeTool, setActiveTool, + layersPanelOpen, + setLayersPanelOpen, + focusAnnotationId, + setFocusAnnotationId, projectHistory, dialog, setDialog, From 1c349eaf92f1572f09a46a64da2f657d12d89d25 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:39:25 +0400 Subject: [PATCH 08/40] Refactor crop dialog state in Header component --- .../desktop/src/routes/screenshot-editor/Header.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx index e1e26f7137..eaa4ee2d2b 100644 --- a/apps/desktop/src/routes/screenshot-editor/Header.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -66,18 +66,13 @@ export function Header() { const cropDialogHandler = () => { const frame = latestFrame(); + const frameWidth = frame?.data?.width ?? 0; + const frameHeight = frame?.data?.height ?? 0; setDialog({ open: true, type: "crop", - position: { - ...(project.background.crop?.position ?? { x: 0, y: 0 }), - }, - size: { - ...(project.background.crop?.size ?? { - x: frame?.data?.width ?? 0, - y: frame?.data?.height ?? 0, - }), - }, + originalSize: { x: frameWidth, y: frameHeight }, + currentCrop: project.background.crop, }); }; From c306d9e360de2e0484a5397a8bc7426f0c01cb42 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:39:33 +0400 Subject: [PATCH 09/40] Add keyboard shortcuts and focus pan to Preview --- .../src/routes/screenshot-editor/Preview.tsx | 92 +++++++++++++++++-- 1 file changed, 85 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/routes/screenshot-editor/Preview.tsx b/apps/desktop/src/routes/screenshot-editor/Preview.tsx index a0835baef8..60fff182fd 100644 --- a/apps/desktop/src/routes/screenshot-editor/Preview.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Preview.tsx @@ -1,5 +1,12 @@ import { createElementBounds } from "@solid-primitives/bounds"; -import { createEffect, createMemo, createSignal, Show } from "solid-js"; +import { + createEffect, + createMemo, + createSignal, + on, + onCleanup, + Show, +} from "solid-js"; import IconCapZoomIn from "~icons/cap/zoom-in"; import IconCapZoomOut from "~icons/cap/zoom-out"; import { ASPECT_RATIOS } from "../editor/projectConfig"; @@ -17,7 +24,13 @@ const gridStyle = { }; export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { - const { project, latestFrame, annotations } = useScreenshotEditorContext(); + const { + project, + latestFrame, + annotations, + focusAnnotationId, + setFocusAnnotationId, + } = useScreenshotEditorContext(); let canvasRef: HTMLCanvasElement | undefined; const [canvasContainerRef, setCanvasContainerRef] = @@ -26,16 +39,50 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { const [pan, setPan] = createSignal({ x: 0, y: 0 }); + const zoomIn = () => { + props.setZoom(Math.min(3, props.zoom + 0.1)); + setPan({ x: 0, y: 0 }); + }; + + const zoomOut = () => { + props.setZoom(Math.max(0.1, props.zoom - 0.1)); + setPan({ x: 0, y: 0 }); + }; + + createEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + if (!e.metaKey && !e.ctrlKey) return; + + if (e.key === "-") { + e.preventDefault(); + zoomOut(); + } else if (e.key === "=" || e.key === "+") { + e.preventDefault(); + zoomIn(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); + }); + const handleWheel = (e: WheelEvent) => { e.preventDefault(); if (e.ctrlKey) { - // Zoom const delta = -e.deltaY; const zoomStep = 0.005; const newZoom = Math.max(0.1, Math.min(3, props.zoom + delta * zoomStep)); props.setZoom(newZoom); } else { - // Pan setPan((p) => ({ x: p.x - e.deltaX, y: p.y - e.deltaY, @@ -92,7 +139,8 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) {
props.setZoom(Math.max(0.1, props.zoom - 0.1))} + kbd={["meta", "-"]} + onClick={zoomOut} > @@ -107,7 +155,8 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { /> props.setZoom(Math.min(3, props.zoom + 0.1))} + kbd={["meta", "+"]} + onClick={zoomIn} > @@ -266,6 +315,35 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { const canvasLeft = () => -bounds().x * cssScale(); const canvasTop = () => -bounds().y * cssScale(); + createEffect( + on(focusAnnotationId, (annId) => { + if (!annId) return; + + const ann = annotations.find((a) => a.id === annId); + if (!ann) { + setFocusAnnotationId(null); + return; + } + + const annCenterX = ann.x + ann.width / 2; + const annCenterY = ann.y + ann.height / 2; + + const boundsData = bounds(); + const sizeData = size(); + const scale = fitScale() * props.zoom; + + const annScreenX = + (annCenterX - boundsData.x) * scale - + (sizeData.width * props.zoom) / 2; + const annScreenY = + (annCenterY - boundsData.y) * scale - + (sizeData.height * props.zoom) / 2; + + setPan({ x: -annScreenX, y: -annScreenY }); + setFocusAnnotationId(null); + }), + ); + let maskCanvasRef: HTMLCanvasElement | undefined; const blurRegion = ( @@ -432,7 +510,7 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { transform: `translate(${pan().x}px, ${pan().y}px)`, "will-change": "transform", }} - class="shadow-lg block" + class="block" > Date: Thu, 4 Dec 2025 20:39:41 +0400 Subject: [PATCH 10/40] Add layers panel toggle and improve aspect ratio constraints --- .../screenshot-editor/AnnotationConfig.tsx | 8 +++- .../screenshot-editor/AnnotationLayer.tsx | 42 +++++++++++-------- .../screenshot-editor/AnnotationTools.tsx | 18 ++++++++ 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx index 129104c0ab..8faefea296 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx @@ -13,6 +13,7 @@ export function AnnotationConfigBar() { setAnnotations, projectHistory, setSelectedAnnotationId, + layersPanelOpen, } = useScreenshotEditorContext(); const selected = createMemo(() => @@ -35,7 +36,12 @@ export function AnnotationConfigBar() { const maskType = () => ann().maskType ?? "blur"; const maskLevel = () => ann().maskLevel ?? 16; return ( -
+
diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx index a6cfb321d2..1fcc612fd8 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx @@ -243,12 +243,12 @@ export function AnnotationLayer(props: { let width = currentX - temp.x; let height = currentY - temp.y; - if (e.shiftKey) { - if ( - temp.type === "rectangle" || - temp.type === "circle" || - temp.type === "mask" - ) { + if (temp.type === "circle" && !e.shiftKey) { + const size = Math.max(Math.abs(width), Math.abs(height)); + width = width < 0 ? -size : size; + height = height < 0 ? -size : size; + } else if (e.shiftKey) { + if (temp.type === "rectangle" || temp.type === "mask") { const size = Math.max(Math.abs(width), Math.abs(height)); width = width < 0 ? -size : size; height = height < 0 ? -size : size; @@ -333,19 +333,25 @@ export function AnnotationLayer(props: { newH = original.height - dy; } - // Shift constraint during resize - if ( - e.shiftKey && - (original.type === "rectangle" || original.type === "circle") - ) { - // This is complex for corner resizing, simplifying: - // Just force aspect ratio based on original - const _ratio = original.width / original.height; - if (state.handle.includes("e") || state.handle.includes("w")) { - // Width driven, adjust height - // This is tricky with 8 handles. Skipping proper aspect resize for now to save time/complexity - // Or simple implementation: + const shouldConstrainCircle = + original.type === "circle" && !e.shiftKey; + const shouldConstrainRectangle = + original.type === "rectangle" && e.shiftKey; + + if (shouldConstrainCircle || shouldConstrainRectangle) { + const size = Math.max(Math.abs(newW), Math.abs(newH)); + const signW = newW < 0 ? -1 : 1; + const signH = newH < 0 ? -1 : 1; + + if (state.handle.includes("w")) { + newX = original.x + original.width - signW * size; } + if (state.handle.includes("n")) { + newY = original.y + original.height - signH * size; + } + + newW = signW * size; + newH = signH * size; } } diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx index c4eeacacf2..37c0ed861b 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx @@ -4,14 +4,32 @@ import Tooltip from "~/components/Tooltip"; import IconLucideArrowUpRight from "~icons/lucide/arrow-up-right"; import IconLucideCircle from "~icons/lucide/circle"; import IconLucideEyeOff from "~icons/lucide/eye-off"; +import IconLucideLayers from "~icons/lucide/layers"; import IconLucideMousePointer2 from "~icons/lucide/mouse-pointer-2"; import IconLucideSquare from "~icons/lucide/square"; import IconLucideType from "~icons/lucide/type"; import { type AnnotationType, useScreenshotEditorContext } from "./context"; export function AnnotationTools() { + const { layersPanelOpen, setLayersPanelOpen } = useScreenshotEditorContext(); + return (
+ + + +
Date: Thu, 4 Dec 2025 21:13:43 +0400 Subject: [PATCH 11/40] Add prettyName to screenshot editor --- apps/desktop/src-tauri/src/screenshot_editor.rs | 4 ++++ .../src/routes/screenshot-editor/context.tsx | 9 +++++++++ .../routes/screenshot-editor/useScreenshotExport.ts | 13 ++++++++++--- apps/desktop/src/utils/tauri.ts | 2 +- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 591b17a34c..ad23e0ebd5 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -30,6 +30,7 @@ pub struct ScreenshotEditorInstance { pub ws_shutdown_token: CancellationToken, pub config_tx: watch::Sender, pub path: PathBuf, + pub pretty_name: String, } impl ScreenshotEditorInstance { @@ -264,6 +265,7 @@ impl ScreenshotEditorInstances { ws_shutdown_token, config_tx, path: path.clone(), + pretty_name: recording_meta.pretty_name.clone(), }); // Spawn render loop @@ -375,6 +377,7 @@ pub struct SerializedScreenshotEditorInstance { pub frames_socket_url: String, pub path: PathBuf, pub config: Option, + pub pretty_name: String, } #[tauri::command] @@ -404,6 +407,7 @@ pub async fn create_screenshot_editor_instance( frames_socket_url: format!("ws://localhost:{}", instance.ws_port), path: instance.path.clone(), config: Some(config), + pretty_name: instance.pretty_name.clone(), }) } diff --git a/apps/desktop/src/routes/screenshot-editor/context.tsx b/apps/desktop/src/routes/screenshot-editor/context.tsx index 67220a16bf..8763218896 100644 --- a/apps/desktop/src/routes/screenshot-editor/context.tsx +++ b/apps/desktop/src/routes/screenshot-editor/context.tsx @@ -118,6 +118,10 @@ function createScreenshotEditorContext() { null, ); + const [activePopover, setActivePopover] = createSignal< + "background" | "padding" | "rounding" | "shadow" | "border" | null + >(null); + const [dialog, setDialog] = createSignal({ open: false, }); @@ -309,6 +313,9 @@ function createScreenshotEditorContext() { get path() { return editorInstance()?.path ?? ""; }, + get prettyName() { + return editorInstance()?.prettyName ?? "Screenshot"; + }, project, setProject, annotations, @@ -321,6 +328,8 @@ function createScreenshotEditorContext() { setLayersPanelOpen, focusAnnotationId, setFocusAnnotationId, + activePopover, + setActivePopover, projectHistory, dialog, setDialog, diff --git a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts index 0e52522f47..9abc271d22 100644 --- a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts +++ b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts @@ -8,8 +8,15 @@ import { getArrowHeadPoints } from "./arrow"; import { type Annotation, useScreenshotEditorContext } from "./context"; export function useScreenshotExport() { - const { path, latestFrame, annotations, dialog, setDialog, project } = - useScreenshotEditorContext(); + const { + path, + prettyName, + latestFrame, + annotations, + dialog, + setDialog, + project, + } = useScreenshotEditorContext(); const [isExporting, setIsExporting] = createSignal(false); const drawAnnotations = ( @@ -284,7 +291,7 @@ export function useScreenshotExport() { if (destination === "file") { const savePath = await save({ filters: [{ name: "PNG Image", extensions: ["png"] }], - defaultPath: "screenshot.png", + defaultPath: `${prettyName}.png`, }); if (savePath) { await writeFile(savePath, uint8Array); diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index b7b805ec97..94d9c77564 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -472,7 +472,7 @@ export type SceneSegment = { start: number; end: number; mode?: SceneMode } export type ScreenCaptureTarget = { variant: "window"; id: WindowId } | { variant: "display"; id: DisplayId } | { variant: "area"; screen: DisplayId; bounds: LogicalBounds } export type SegmentRecordings = { display: Video; camera: Video | null; mic: Audio | null; system_audio: Audio | null } export type SerializedEditorInstance = { framesSocketUrl: string; recordingDuration: number; savedProjectConfig: ProjectConfiguration; recordings: ProjectRecordingsMeta; path: string } -export type SerializedScreenshotEditorInstance = { framesSocketUrl: string; path: string; config: ProjectConfiguration | null } +export type SerializedScreenshotEditorInstance = { framesSocketUrl: string; path: string; config: ProjectConfiguration | null; prettyName: string } export type SetCaptureAreaPending = boolean export type ShadowConfiguration = { size: number; opacity: number; blur: number } export type SharingMeta = { id: string; link: string } From 96aac803d82d48f9820b6ef9cea555cde127da2b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:14:10 +0400 Subject: [PATCH 12/40] Refactor popover state management in screenshot editor --- .../screenshot-editor/AnnotationConfig.tsx | 30 +++++++++++-------- .../src/routes/screenshot-editor/Editor.tsx | 22 +++++++++----- .../popovers/BackgroundSettingsPopover.tsx | 15 ++++++++-- .../popovers/BorderPopover.tsx | 10 +++++-- .../popovers/PaddingPopover.tsx | 9 ++++-- .../popovers/RoundingPopover.tsx | 9 ++++-- .../popovers/ShadowPopover.tsx | 10 +++++-- 7 files changed, 76 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx index 8faefea296..2dee4442a6 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationConfig.tsx @@ -31,8 +31,8 @@ export function AnnotationConfigBar() { return ( {(ann) => { - const type = ann().type; - const isMask = type === "mask"; + const type = () => ann().type; + const isMask = () => type() === "mask"; const maskType = () => ann().maskType ?? "blur"; const maskLevel = () => ann().maskLevel ?? 16; return ( @@ -43,8 +43,8 @@ export function AnnotationConfigBar() { )} >
- - + + update("strokeColor", c)} @@ -52,8 +52,11 @@ export function AnnotationConfigBar() { - - + + update("strokeWidth", v[0])} @@ -65,7 +68,7 @@ export function AnnotationConfigBar() { - + - + - +