From 497a3d0884cd34d1a330ecde75481ac2c26df3d3 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 17 Oct 2025 16:16:14 +0800 Subject: [PATCH 1/2] allow splitting zoom segments --- .../src/routes/editor/ConfigSidebar.tsx | 4891 ++++++++--------- .../src/routes/editor/Timeline/ClipTrack.tsx | 11 - .../src/routes/editor/Timeline/ZoomTrack.tsx | 1186 ++-- apps/desktop/src/routes/editor/context.ts | 20 +- 4 files changed, 3062 insertions(+), 3046 deletions(-) diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 3663d04e78..fcaa33aa51 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -1,11 +1,11 @@ import { NumberField } from "@kobalte/core"; import { - Collapsible, - Collapsible as KCollapsible, + Collapsible, + Collapsible as KCollapsible, } from "@kobalte/core/collapsible"; import { - RadioGroup as KRadioGroup, - RadioGroup, + RadioGroup as KRadioGroup, + RadioGroup, } from "@kobalte/core/radio-group"; import { Select as KSelect } from "@kobalte/core/select"; import { Tabs as KTabs } from "@kobalte/core/tabs"; @@ -18,19 +18,19 @@ import { BaseDirectory, writeFile } from "@tauri-apps/plugin-fs"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { - batch, - createEffect, - createMemo, - createResource, - createRoot, - createSignal, - For, - Index, - on, - onMount, - Show, - Suspense, - type ValidComponent, + batch, + createEffect, + createMemo, + createResource, + createRoot, + createSignal, + For, + Index, + on, + onMount, + Show, + Suspense, + type ValidComponent, } from "solid-js"; import { createStore, produce } from "solid-js/store"; import { Dynamic } from "solid-js/web"; @@ -42,485 +42,485 @@ import transparentBg from "~/assets/illustrations/transparent.webp"; import { Toggle } from "~/components/Toggle"; import { generalSettingsStore } from "~/store"; import { - type BackgroundSource, - type CameraShape, - type ClipOffsets, - commands, - type SceneSegment, - type StereoMode, - type TimelineSegment, - type ZoomSegment, + type BackgroundSource, + type CameraShape, + type ClipOffsets, + commands, + type SceneSegment, + type StereoMode, + type TimelineSegment, + type ZoomSegment, } from "~/utils/tauri"; import IconLucideMonitor from "~icons/lucide/monitor"; import IconLucideSparkles from "~icons/lucide/sparkles"; import { CaptionsTab } from "./CaptionsTab"; import { useEditorContext } from "./context"; import { - DEFAULT_GRADIENT_FROM, - DEFAULT_GRADIENT_TO, - type RGBColor, + DEFAULT_GRADIENT_FROM, + DEFAULT_GRADIENT_TO, + type RGBColor, } from "./projectConfig"; import ShadowSettings from "./ShadowSettings"; import { TextInput } from "./TextInput"; import { - ComingSoonTooltip, - EditorButton, - Field, - MenuItem, - MenuItemList, - PopperContent, - Slider, - Subfield, - topSlideAnimateClasses, + ComingSoonTooltip, + EditorButton, + Field, + MenuItem, + MenuItemList, + PopperContent, + Slider, + Subfield, + topSlideAnimateClasses, } from "./ui"; const BACKGROUND_SOURCES = { - wallpaper: "Wallpaper", - image: "Image", - color: "Color", - gradient: "Gradient", + wallpaper: "Wallpaper", + image: "Image", + color: "Color", + gradient: "Gradient", } satisfies Record; const BACKGROUND_ICONS = { - wallpaper: imageBg, - image: transparentBg, - color: colorBg, - gradient: gradientBg, + wallpaper: imageBg, + image: transparentBg, + color: colorBg, + gradient: gradientBg, } satisfies Record; const BACKGROUND_SOURCES_LIST = [ - "wallpaper", - "image", - "color", - "gradient", + "wallpaper", + "image", + "color", + "gradient", ] satisfies Array; const BACKGROUND_COLORS = [ - "#FF0000", // Red - "#FF4500", // Orange-Red - "#FF8C00", // Orange - "#FFD700", // Gold - "#FFFF00", // Yellow - "#ADFF2F", // Green-Yellow - "#32CD32", // Lime Green - "#008000", // Green - "#00CED1", // Dark Turquoise - "#4785FF", // Dodger Blue - "#0000FF", // Blue - "#4B0082", // Indigo - "#800080", // Purple - "#A9A9A9", // Dark Gray - "#FFFFFF", // White - "#000000", // Black + "#FF0000", // Red + "#FF4500", // Orange-Red + "#FF8C00", // Orange + "#FFD700", // Gold + "#FFFF00", // Yellow + "#ADFF2F", // Green-Yellow + "#32CD32", // Lime Green + "#008000", // Green + "#00CED1", // Dark Turquoise + "#4785FF", // Dodger Blue + "#0000FF", // Blue + "#4B0082", // Indigo + "#800080", // Purple + "#A9A9A9", // Dark Gray + "#FFFFFF", // White + "#000000", // Black ]; const BACKGROUND_GRADIENTS = [ - { from: [15, 52, 67], to: [52, 232, 158] }, // Dark Blue to Teal - { from: [34, 193, 195], to: [253, 187, 45] }, // Turquoise to Golden Yellow - { from: [29, 253, 251], to: [195, 29, 253] }, // Cyan to Purple - { from: [69, 104, 220], to: [176, 106, 179] }, // Blue to Violet - { from: [106, 130, 251], to: [252, 92, 125] }, // Soft Blue to Pinkish Red - { from: [131, 58, 180], to: [253, 29, 29] }, // Purple to Red - { from: [249, 212, 35], to: [255, 78, 80] }, // Yellow to Coral Red - { from: [255, 94, 0], to: [255, 42, 104] }, // Orange to Reddish Pink - { from: [255, 0, 150], to: [0, 204, 255] }, // Pink to Sky Blue - { from: [0, 242, 96], to: [5, 117, 230] }, // Green to Blue - { from: [238, 205, 163], to: [239, 98, 159] }, // Peach to Soft Pink - { from: [44, 62, 80], to: [52, 152, 219] }, // Dark Gray Blue to Light Blue - { from: [168, 239, 255], to: [238, 205, 163] }, // Light Blue to Peach - { from: [74, 0, 224], to: [143, 0, 255] }, // Deep Blue to Bright Purple - { from: [252, 74, 26], to: [247, 183, 51] }, // Deep Orange to Soft Yellow - { from: [0, 255, 255], to: [255, 20, 147] }, // Cyan to Deep Pink - { from: [255, 127, 0], to: [255, 255, 0] }, // Orange to Yellow - { from: [255, 0, 255], to: [0, 255, 0] }, // Magenta to Green + { from: [15, 52, 67], to: [52, 232, 158] }, // Dark Blue to Teal + { from: [34, 193, 195], to: [253, 187, 45] }, // Turquoise to Golden Yellow + { from: [29, 253, 251], to: [195, 29, 253] }, // Cyan to Purple + { from: [69, 104, 220], to: [176, 106, 179] }, // Blue to Violet + { from: [106, 130, 251], to: [252, 92, 125] }, // Soft Blue to Pinkish Red + { from: [131, 58, 180], to: [253, 29, 29] }, // Purple to Red + { from: [249, 212, 35], to: [255, 78, 80] }, // Yellow to Coral Red + { from: [255, 94, 0], to: [255, 42, 104] }, // Orange to Reddish Pink + { from: [255, 0, 150], to: [0, 204, 255] }, // Pink to Sky Blue + { from: [0, 242, 96], to: [5, 117, 230] }, // Green to Blue + { from: [238, 205, 163], to: [239, 98, 159] }, // Peach to Soft Pink + { from: [44, 62, 80], to: [52, 152, 219] }, // Dark Gray Blue to Light Blue + { from: [168, 239, 255], to: [238, 205, 163] }, // Light Blue to Peach + { from: [74, 0, 224], to: [143, 0, 255] }, // Deep Blue to Bright Purple + { from: [252, 74, 26], to: [247, 183, 51] }, // Deep Orange to Soft Yellow + { from: [0, 255, 255], to: [255, 20, 147] }, // Cyan to Deep Pink + { from: [255, 127, 0], to: [255, 255, 0] }, // Orange to Yellow + { from: [255, 0, 255], to: [0, 255, 0] }, // Magenta to Green ] satisfies Array<{ from: RGBColor; to: RGBColor }>; const WALLPAPER_NAMES = [ - // macOS wallpapers - "macOS/sequoia-dark", - "macOS/sequoia-light", - "macOS/sonoma-clouds", - "macOS/sonoma-dark", - "macOS/sonoma-evening", - "macOS/sonoma-fromabove", - "macOS/sonoma-horizon", - "macOS/sonoma-light", - "macOS/sonoma-river", - "macOS/ventura-dark", - "macOS/ventura-semi-dark", - "macOS/ventura", - // Blue wallpapers - "blue/1", - "blue/2", - "blue/3", - "blue/4", - "blue/5", - "blue/6", - // Purple wallpapers - "purple/1", - "purple/2", - "purple/3", - "purple/4", - "purple/5", - "purple/6", - // Dark wallpapers - "dark/1", - "dark/2", - "dark/3", - "dark/4", - "dark/5", - "dark/6", - // Orange wallpapers - "orange/1", - "orange/2", - "orange/3", - "orange/4", - "orange/5", - "orange/6", - "orange/7", - "orange/8", - "orange/9", + // macOS wallpapers + "macOS/sequoia-dark", + "macOS/sequoia-light", + "macOS/sonoma-clouds", + "macOS/sonoma-dark", + "macOS/sonoma-evening", + "macOS/sonoma-fromabove", + "macOS/sonoma-horizon", + "macOS/sonoma-light", + "macOS/sonoma-river", + "macOS/ventura-dark", + "macOS/ventura-semi-dark", + "macOS/ventura", + // Blue wallpapers + "blue/1", + "blue/2", + "blue/3", + "blue/4", + "blue/5", + "blue/6", + // Purple wallpapers + "purple/1", + "purple/2", + "purple/3", + "purple/4", + "purple/5", + "purple/6", + // Dark wallpapers + "dark/1", + "dark/2", + "dark/3", + "dark/4", + "dark/5", + "dark/6", + // Orange wallpapers + "orange/1", + "orange/2", + "orange/3", + "orange/4", + "orange/5", + "orange/6", + "orange/7", + "orange/8", + "orange/9", ] as const; const STEREO_MODES = [ - { name: "Stereo", value: "stereo" }, - { name: "Mono L", value: "monoL" }, - { name: "Mono R", value: "monoR" }, + { name: "Stereo", value: "stereo" }, + { name: "Mono L", value: "monoL" }, + { name: "Mono R", value: "monoR" }, ] satisfies Array<{ name: string; value: StereoMode }>; const CAMERA_SHAPES = [ - { - name: "Square", - value: "square", - }, - { - name: "Source", - value: "source", - }, + { + name: "Square", + value: "square", + }, + { + name: "Source", + value: "source", + }, ] satisfies Array<{ name: string; value: CameraShape }>; const BACKGROUND_THEMES = { - macOS: "macOS", - dark: "Dark", - blue: "Blue", - purple: "Purple", - orange: "Orange", + macOS: "macOS", + dark: "Dark", + blue: "Blue", + purple: "Purple", + orange: "Orange", }; const TAB_IDS = { - background: "background", - camera: "camera", - transcript: "transcript", - audio: "audio", - cursor: "cursor", - hotkeys: "hotkeys", + background: "background", + camera: "camera", + transcript: "transcript", + audio: "audio", + cursor: "cursor", + hotkeys: "hotkeys", } as const; export function ConfigSidebar() { - const { - project, - setProject, - setEditorState, - projectActions, - editorInstance, - editorState, - meta, - } = useEditorContext(); - - const [state, setState] = createStore({ - selectedTab: "background" as - | "background" - | "camera" - | "transcript" - | "audio" - | "cursor" - | "hotkeys" - | "captions", - }); - - let scrollRef!: HTMLDivElement; - - return ( - - - s.camera === null, - ), - }, - { id: TAB_IDS.audio, icon: IconCapAudioOn }, - { - id: TAB_IDS.cursor, - icon: IconCapCursor, - disabled: !( - meta().type === "multiple" && (meta() as any).segments[0].cursor - ), - }, - window.FLAGS.captions && { - id: "captions" as const, - icon: IconCapMessageBubble, - }, - // { id: "hotkeys" as const, icon: IconCapHotkeys }, - ].filter(Boolean)} - > - {(item) => ( - { - // Clear any active selection first - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - } - setState("selectedTab", item.id); - scrollRef.scrollTo({ - top: 0, - }); - }} - disabled={item.disabled} - > -
- -
-
- )} -
- - {/** Center the indicator with the icon */} - - -
- - - -
- - - - } - > - - setProject("audio", "mute", v)} - /> - - {editorInstance.recordings.segments[0].mic?.channels === 2 && ( - - - options={STEREO_MODES} - optionValue="value" - optionTextValue="name" - value={STEREO_MODES.find( - (v) => v.value === project.audio.micStereoMode, - )} - onChange={(v) => { - if (v) setProject("audio", "micStereoMode", v.value); - }} - disallowEmptySelection - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.name} - - - )} - > - - class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> - {(state) => {state.selectedOption().name}} - - - as={(props) => ( - - )} - /> - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="overflow-y-auto max-h-32" - as={KSelect.Listbox} - /> - - - - - )} - - {/* + const { + project, + setProject, + setEditorState, + projectActions, + editorInstance, + editorState, + meta, + } = useEditorContext(); + + const [state, setState] = createStore({ + selectedTab: "background" as + | "background" + | "camera" + | "transcript" + | "audio" + | "cursor" + | "hotkeys" + | "captions", + }); + + let scrollRef!: HTMLDivElement; + + return ( + + + s.camera === null + ), + }, + { id: TAB_IDS.audio, icon: IconCapAudioOn }, + { + id: TAB_IDS.cursor, + icon: IconCapCursor, + disabled: !( + meta().type === "multiple" && (meta() as any).segments[0].cursor + ), + }, + window.FLAGS.captions && { + id: "captions" as const, + icon: IconCapMessageBubble, + }, + // { id: "hotkeys" as const, icon: IconCapHotkeys }, + ].filter(Boolean)} + > + {(item) => ( + { + // Clear any active selection first + if (editorState.timeline.selection) { + setEditorState("timeline", "selection", null); + } + setState("selectedTab", item.id); + scrollRef.scrollTo({ + top: 0, + }); + }} + disabled={item.disabled} + > +
+ +
+
+ )} +
+ + {/** Center the indicator with the icon */} + + +
+ + + +
+ + + + } + > + + setProject("audio", "mute", v)} + /> + + {editorInstance.recordings.segments[0].mic?.channels === 2 && ( + + + options={STEREO_MODES} + optionValue="value" + optionTextValue="name" + value={STEREO_MODES.find( + (v) => v.value === project.audio.micStereoMode + )} + onChange={(v) => { + if (v) setProject("audio", "micStereoMode", v.value); + }} + disallowEmptySelection + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.name} + + + )} + > + + class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> + {(state) => {state.selectedOption().name}} + + + as={(props) => ( + + )} + /> + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-32" + as={KSelect.Listbox} + /> + + + + + )} + + {/* setProject("audio", "mute", v)} /> */} - {/* + {/* */} - - {meta().hasMicrophone && ( - } - > - setProject("audio", "micVolumeDb", v[0])} - minValue={-30} - maxValue={10} - step={0.1} - formatTooltip={(v) => - v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` - } - /> - - )} - {meta().hasSystemAudio && ( - } - > - setProject("audio", "systemVolumeDb", v[0])} - minValue={-30} - maxValue={10} - step={0.1} - formatTooltip={(v) => - v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` - } - /> - - )} - - - } - value={ - { - setProject("cursor", "hide", !v); - }} - /> - } - /> - - }> - setProject("cursor", "size", v[0])} - minValue={20} - maxValue={300} - step={1} - /> - - - } - value={ - { - setProject("cursor", "raw", !value); - }} - /> - } - /> - - {/* if Content has padding or margin the animation doesn't look as good */} -
- - setProject("cursor", "tension", v[0])} - minValue={1} - maxValue={500} - step={1} - /> - - - setProject("cursor", "friction", v[0])} - minValue={0} - maxValue={50} - step={0.1} - /> - - - setProject("cursor", "mass", v[0])} - minValue={0.1} - maxValue={10} - step={0.01} - /> - -
-
-
- } - value={ - { - setProject("cursor", "useSvg" as any, value); - }} - /> - } - /> -
- - {/* + + {meta().hasMicrophone && ( + } + > + setProject("audio", "micVolumeDb", v[0])} + minValue={-30} + maxValue={10} + step={0.1} + formatTooltip={(v) => + v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` + } + /> + + )} + {meta().hasSystemAudio && ( + } + > + setProject("audio", "systemVolumeDb", v[0])} + minValue={-30} + maxValue={10} + step={0.1} + formatTooltip={(v) => + v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` + } + /> + + )} +
+ + } + value={ + { + setProject("cursor", "hide", !v); + }} + /> + } + /> + + }> + setProject("cursor", "size", v[0])} + minValue={20} + maxValue={300} + step={1} + /> + + + } + value={ + { + setProject("cursor", "raw", !value); + }} + /> + } + /> + + {/* if Content has padding or margin the animation doesn't look as good */} +
+ + setProject("cursor", "tension", v[0])} + minValue={1} + maxValue={500} + step={1} + /> + + + setProject("cursor", "friction", v[0])} + minValue={0} + maxValue={50} + step={0.1} + /> + + + setProject("cursor", "mass", v[0])} + minValue={0.1} + maxValue={10} + step={0.01} + /> + +
+
+
+ } + value={ + { + setProject("cursor", "useSvg" as any, value); + }} + /> + } + /> +
+ + {/* setProject("cursor", "motionBlur", v[0])} @@ -529,7 +529,7 @@ export function ConfigSidebar() { step={0.001} /> */} - {/* }> + {/* }> */} -
- - }> - - - - - - - - - - -
- - {(selection) => ( -
- - { - const zoomSelection = selection(); - if (zoomSelection.type !== "zoom") return; - - const segments = zoomSelection.indices - .map((index) => ({ - index, - segment: project.timeline?.zoomSegments?.[index], - })) - .filter( - (item): item is { index: number; segment: ZoomSegment } => - item.segment !== undefined, - ); - - if (segments.length === 0) { - setEditorState("timeline", "selection", null); - return; - } - return { selection: zoomSelection, segments }; - })()} - > - {(value) => ( -
-
-
- - setEditorState("timeline", "selection", null) - } - leftIcon={} - > - Done - - - {value().segments.length} zoom{" "} - {value().segments.length === 1 - ? "segment" - : "segments"}{" "} - selected - -
- { - projectActions.deleteZoomSegments( - value().segments.map((s) => s.index), - ); - }} - leftIcon={} - > - Delete - -
- - - {(item, index) => ( -
- -
- )} -
-
- } - > - - {(item) => ( -
- -
- )} -
-
-
- )} -
- { - const sceneSelection = selection(); - if (sceneSelection.type !== "scene") return; - - const segment = - project.timeline?.sceneSegments?.[sceneSelection.index]; - if (!segment) return; - - return { selection: sceneSelection, segment }; - })()} - > - {(value) => ( - - )} - - { - const clipSegment = selection(); - if (clipSegment.type !== "clip") return; - - const segment = - project.timeline?.segments?.[clipSegment.index]; - if (!segment) return; - - return { selection: clipSegment, segment }; - })()} - > - {(value) => ( - - )} - - -
- )} -
-
- ); +
+ + }> + + + + + + + + + + +
+ + {(selection) => ( +
+ + { + const zoomSelection = selection(); + if (zoomSelection.type !== "zoom") return; + + const segments = zoomSelection.indices + .map((index) => ({ + index, + segment: project.timeline?.zoomSegments?.[index], + })) + .filter( + (item): item is { index: number; segment: ZoomSegment } => + item.segment !== undefined + ); + + if (segments.length === 0) { + setEditorState("timeline", "selection", null); + return; + } + return { selection: zoomSelection, segments }; + })()} + > + {(value) => ( +
+
+
+ + setEditorState("timeline", "selection", null) + } + leftIcon={} + > + Done + + + {value().segments.length} zoom{" "} + {value().segments.length === 1 + ? "segment" + : "segments"}{" "} + selected + +
+ { + projectActions.deleteZoomSegments( + value().segments.map((s) => s.index) + ); + }} + leftIcon={} + > + Delete + +
+ + + {(item, index) => ( +
+ +
+ )} +
+
+ } + > + + {(item) => ( +
+ +
+ )} +
+
+
+ )} +
+ { + const sceneSelection = selection(); + if (sceneSelection.type !== "scene") return; + + const segment = + project.timeline?.sceneSegments?.[sceneSelection.index]; + if (!segment) return; + + return { selection: sceneSelection, segment }; + })()} + > + {(value) => ( + + )} + + { + const clipSegment = selection(); + if (clipSegment.type !== "clip") return; + + const segment = + project.timeline?.segments?.[clipSegment.index]; + if (!segment) return; + + return { selection: clipSegment, segment }; + })()} + > + {(value) => ( + + )} + + +
+ )} +
+
+ ); } function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { - const { project, setProject, projectHistory } = useEditorContext(); - - // Background tabs - const [backgroundTab, setBackgroundTab] = - createSignal("macOS"); - - const [wallpapers] = createResource(async () => { - // Only load visible wallpapers initially - const visibleWallpaperPaths = WALLPAPER_NAMES.map(async (id) => { - try { - const path = await resolveResource(`assets/backgrounds/${id}.jpg`); - return { id, path }; - } catch (err) { - return { id, path: null }; - } - }); - - // Load initial batch - const initialPaths = await Promise.all(visibleWallpaperPaths); - - return initialPaths - .filter((p) => p.path !== null) - .map(({ id, path }) => ({ - id, - url: convertFileSrc(path!), - rawPath: path!, - })); - }); - - // set padding if background is selected - const ensurePaddingForBackground = () => { - if (project.background.padding === 0) - setProject("background", "padding", 10); - }; - - // Validate background source path on mount - onMount(async () => { - if ( - project.background.source.type === "wallpaper" || - project.background.source.type === "image" - ) { - const path = project.background.source.path; - - if (path) { - if (project.background.source.type === "wallpaper") { - // If the path is just the wallpaper ID (e.g. "sequoia-dark"), get the full path - if ( - WALLPAPER_NAMES.includes(path as (typeof WALLPAPER_NAMES)[number]) - ) { - // Wait for wallpapers to load - const loadedWallpapers = wallpapers(); - if (!loadedWallpapers) return; - - // Find the wallpaper with matching ID - const wallpaper = loadedWallpapers.find((w) => w.id === path); - if (!wallpaper?.url) return; - - // Directly trigger the radio group's onChange handler - const radioGroupOnChange = async (photoUrl: string) => { - try { - const wallpaper = wallpapers()?.find((w) => w.url === photoUrl); - if (!wallpaper) return; - - // Get the raw path without any URL prefixes - const rawPath = decodeURIComponent( - photoUrl.replace("file://", ""), - ); - - debouncedSetProject(rawPath); - } catch (err) { - toast.error("Failed to set wallpaper"); - } - }; - - await radioGroupOnChange(wallpaper.url); - } - } else if (project.background.source.type === "image") { - (async () => { - try { - const convertedPath = convertFileSrc(path); - await fetch(convertedPath, { method: "HEAD" }); - } catch (err) { - setProject("background", "source", { - type: "image", - path: null, - }); - } - })(); - } - } - } - }); - - const filteredWallpapers = createMemo(() => { - const currentTab = backgroundTab(); - return wallpapers()?.filter((wp) => wp.id.startsWith(currentTab)) || []; - }); - - const [scrollX, setScrollX] = createSignal(0); - const [reachedEndOfScroll, setReachedEndOfScroll] = createSignal(false); - - const [backgroundRef, setBackgroundRef] = createSignal(); - - createEventListenerMap( - () => backgroundRef() ?? [], - { - /** Handle background tabs overflowing to show fade */ - scroll: () => { - const el = backgroundRef(); - if (el) { - setScrollX(el.scrollLeft); - const reachedEnd = el.scrollWidth - el.clientWidth - el.scrollLeft; - setReachedEndOfScroll(reachedEnd === 0); - } - }, - //Mouse wheel and touchpad support - wheel: (e: WheelEvent) => { - const el = backgroundRef(); - if (el) { - e.preventDefault(); - el.scrollLeft += - Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; - } - }, - }, - { passive: false }, - ); - - let fileInput!: HTMLInputElement; - - // Optimize the debounced set project function - const debouncedSetProject = (wallpaperPath: string) => { - const resumeHistory = projectHistory.pause(); - queueMicrotask(() => { - batch(() => { - setProject("background", "source", { - type: "wallpaper", - path: wallpaperPath, - } as const); - resumeHistory(); - }); - }); - }; - - const backgrounds: { - [K in BackgroundSource["type"]]: Extract; - } = { - wallpaper: { - type: "wallpaper", - path: null, - }, - image: { - type: "image", - path: null, - }, - color: { - type: "color", - value: DEFAULT_GRADIENT_FROM, - }, - gradient: { - type: "gradient", - from: DEFAULT_GRADIENT_FROM, - to: DEFAULT_GRADIENT_TO, - }, - }; - - const generalSettings = generalSettingsStore.createQuery(); - const hapticsEnabled = () => - generalSettings.data?.hapticsEnabled && ostype() === "macos"; - - return ( - - } name="Background Image"> - { - const tab = v as BackgroundSource["type"]; - ensurePaddingForBackground(); - switch (tab) { - case "image": { - setProject("background", "source", { - type: "image", - path: - project.background.source.type === "image" - ? project.background.source.path - : null, - }); - break; - } - case "color": { - setProject("background", "source", { - type: "color", - value: - project.background.source.type === "color" - ? project.background.source.value - : DEFAULT_GRADIENT_FROM, - }); - break; - } - case "gradient": { - setProject("background", "source", { - type: "gradient", - from: - project.background.source.type === "gradient" - ? project.background.source.from - : DEFAULT_GRADIENT_FROM, - to: - project.background.source.type === "gradient" - ? project.background.source.to - : DEFAULT_GRADIENT_TO, - angle: - project.background.source.type === "gradient" - ? project.background.source.angle - : 90, - }); - break; - } - case "wallpaper": { - setProject("background", "source", { - type: "wallpaper", - path: - project.background.source.type === "wallpaper" - ? project.background.source.path - : null, - }); - break; - } - } - }} - > - - - {(item) => { - const el = (props?: object) => ( - -
- {(() => { - const getGradientBackground = () => { - const angle = - project.background.source.type === "gradient" - ? project.background.source.angle - : 90; - const fromColor = - project.background.source.type === "gradient" - ? project.background.source.from - : DEFAULT_GRADIENT_FROM; - const toColor = - project.background.source.type === "gradient" - ? project.background.source.to - : DEFAULT_GRADIENT_TO; - - return ( -
- ); - }; - - const getColorBackground = () => { - const backgroundColor = - project.background.source.type === "color" - ? project.background.source.value - : hexToRgb(BACKGROUND_COLORS[9]); - - return ( -
- ); - }; - - const getImageBackground = () => { - // Always start with the default icon - let imageSrc: string = BACKGROUND_ICONS[item]; - - // Only override for "image" if a valid path exists - if ( - item === "image" && - project.background.source.type === "image" && - project.background.source.path - ) { - const convertedPath = convertFileSrc( - project.background.source.path, - ); - // Only use converted path if it's valid - if (convertedPath) { - imageSrc = convertedPath; - } - } - // Only override for "wallpaper" if a valid wallpaper is found - else if ( - item === "wallpaper" && - project.background.source.type === "wallpaper" && - project.background.source.path - ) { - const selectedWallpaper = wallpapers()?.find((w) => - ( - project.background.source as { path?: string } - ).path?.includes(w.id), - ); - // Only use wallpaper URL if it exists - if (selectedWallpaper?.url) { - imageSrc = selectedWallpaper.url; - } - } - - return ( - {BACKGROUND_SOURCES[item]} - ); - }; - - switch (item) { - case "gradient": - return getGradientBackground(); - case "color": - return getColorBackground(); - case "image": - case "wallpaper": - return getImageBackground(); - default: - return null; - } - })()} - {BACKGROUND_SOURCES[item]} -
- - ); - - return el({}); - }} - - - {/** Dashed divider */} -
- - {/** Background Tabs */} - - 0 ? "24px" : "0" - }, black calc(100% - ${ - reachedEndOfScroll() ? "0px" : "24px" - }), transparent)`, - - "mask-image": `linear-gradient(to right, transparent, black ${ - scrollX() > 0 ? "24px" : "0" - }, black calc(100% - ${ - reachedEndOfScroll() ? "0px" : "24px" - }), transparent);`, - }} - > - - {([key, value]) => ( - <> - - setBackgroundTab( - key as keyof typeof BACKGROUND_THEMES, - ) - } - value={key} - class="flex relative z-10 flex-1 justify-center items-center px-4 py-2 bg-transparent rounded-lg border transition-colors duration-200 text-gray-11 ui-not-selected:hover:border-gray-7 ui-selected:bg-gray-3 ui-selected:border-gray-3 group ui-selected:text-gray-12 disabled:opacity-50 focus:outline-none" - > - {value} - - - )} - - - - {/** End of Background Tabs */} - - ( - project.background.source as { path?: string } - ).path?.includes(w.id), - )?.url ?? undefined) - : undefined - } - onChange={(photoUrl) => { - try { - const wallpaper = wallpapers()?.find( - (w) => w.url === photoUrl, - ); - if (!wallpaper) return; - - // Get the raw path without any URL prefixes - - debouncedSetProject(wallpaper.rawPath); - - ensurePaddingForBackground(); - } catch (err) { - toast.error("Failed to set wallpaper"); - } - }} - class="grid grid-cols-7 gap-2 h-auto" - > - -
-
- Loading wallpapers... -
-
- } - > - - {(photo) => ( - - - - Wallpaper option - - - )} - - - -
- - {(photo) => ( - - - - Wallpaper option - - - )} - -
-
-
-
-
-
- - fileInput.click()} - class="p-6 bg-gray-2 text-[13px] w-full rounded-[0.5rem] border border-gray-5 border-dashed flex flex-col items-center justify-center gap-[0.5rem] hover:bg-gray-3 transition-colors duration-100" - > - - - Click to select or drag and drop image - - - } - > - {(source) => ( -
- Selected background -
- -
-
- )} -
- { - const file = e.currentTarget.files?.[0]; - if (!file) return; - - /* + const { project, setProject, projectHistory } = useEditorContext(); + + // Background tabs + const [backgroundTab, setBackgroundTab] = + createSignal("macOS"); + + const [wallpapers] = createResource(async () => { + // Only load visible wallpapers initially + const visibleWallpaperPaths = WALLPAPER_NAMES.map(async (id) => { + try { + const path = await resolveResource(`assets/backgrounds/${id}.jpg`); + return { id, path }; + } catch (err) { + return { id, path: null }; + } + }); + + // Load initial batch + const initialPaths = await Promise.all(visibleWallpaperPaths); + + return initialPaths + .filter((p) => p.path !== null) + .map(({ id, path }) => ({ + id, + url: convertFileSrc(path!), + rawPath: path!, + })); + }); + + // set padding if background is selected + const ensurePaddingForBackground = () => { + if (project.background.padding === 0) + setProject("background", "padding", 10); + }; + + // Validate background source path on mount + onMount(async () => { + if ( + project.background.source.type === "wallpaper" || + project.background.source.type === "image" + ) { + const path = project.background.source.path; + + if (path) { + if (project.background.source.type === "wallpaper") { + // If the path is just the wallpaper ID (e.g. "sequoia-dark"), get the full path + if ( + WALLPAPER_NAMES.includes(path as (typeof WALLPAPER_NAMES)[number]) + ) { + // Wait for wallpapers to load + const loadedWallpapers = wallpapers(); + if (!loadedWallpapers) return; + + // Find the wallpaper with matching ID + const wallpaper = loadedWallpapers.find((w) => w.id === path); + if (!wallpaper?.url) return; + + // Directly trigger the radio group's onChange handler + const radioGroupOnChange = async (photoUrl: string) => { + try { + const wallpaper = wallpapers()?.find((w) => w.url === photoUrl); + if (!wallpaper) return; + + // Get the raw path without any URL prefixes + const rawPath = decodeURIComponent( + photoUrl.replace("file://", "") + ); + + debouncedSetProject(rawPath); + } catch (err) { + toast.error("Failed to set wallpaper"); + } + }; + + await radioGroupOnChange(wallpaper.url); + } + } else if (project.background.source.type === "image") { + (async () => { + try { + const convertedPath = convertFileSrc(path); + await fetch(convertedPath, { method: "HEAD" }); + } catch (err) { + setProject("background", "source", { + type: "image", + path: null, + }); + } + })(); + } + } + } + }); + + const filteredWallpapers = createMemo(() => { + const currentTab = backgroundTab(); + return wallpapers()?.filter((wp) => wp.id.startsWith(currentTab)) || []; + }); + + const [scrollX, setScrollX] = createSignal(0); + const [reachedEndOfScroll, setReachedEndOfScroll] = createSignal(false); + + const [backgroundRef, setBackgroundRef] = createSignal(); + + createEventListenerMap( + () => backgroundRef() ?? [], + { + /** Handle background tabs overflowing to show fade */ + scroll: () => { + const el = backgroundRef(); + if (el) { + setScrollX(el.scrollLeft); + const reachedEnd = el.scrollWidth - el.clientWidth - el.scrollLeft; + setReachedEndOfScroll(reachedEnd === 0); + } + }, + //Mouse wheel and touchpad support + wheel: (e: WheelEvent) => { + const el = backgroundRef(); + if (el) { + e.preventDefault(); + el.scrollLeft += + Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + } + }, + }, + { passive: false } + ); + + let fileInput!: HTMLInputElement; + + // Optimize the debounced set project function + const debouncedSetProject = (wallpaperPath: string) => { + const resumeHistory = projectHistory.pause(); + queueMicrotask(() => { + batch(() => { + setProject("background", "source", { + type: "wallpaper", + path: wallpaperPath, + } as const); + resumeHistory(); + }); + }); + }; + + const backgrounds: { + [K in BackgroundSource["type"]]: Extract; + } = { + wallpaper: { + type: "wallpaper", + path: null, + }, + image: { + type: "image", + path: null, + }, + color: { + type: "color", + value: DEFAULT_GRADIENT_FROM, + }, + gradient: { + type: "gradient", + from: DEFAULT_GRADIENT_FROM, + to: DEFAULT_GRADIENT_TO, + }, + }; + + const generalSettings = generalSettingsStore.createQuery(); + const hapticsEnabled = () => + generalSettings.data?.hapticsEnabled && ostype() === "macos"; + + return ( + + } name="Background Image"> + { + const tab = v as BackgroundSource["type"]; + ensurePaddingForBackground(); + switch (tab) { + case "image": { + setProject("background", "source", { + type: "image", + path: + project.background.source.type === "image" + ? project.background.source.path + : null, + }); + break; + } + case "color": { + setProject("background", "source", { + type: "color", + value: + project.background.source.type === "color" + ? project.background.source.value + : DEFAULT_GRADIENT_FROM, + }); + break; + } + case "gradient": { + setProject("background", "source", { + type: "gradient", + from: + project.background.source.type === "gradient" + ? project.background.source.from + : DEFAULT_GRADIENT_FROM, + to: + project.background.source.type === "gradient" + ? project.background.source.to + : DEFAULT_GRADIENT_TO, + angle: + project.background.source.type === "gradient" + ? project.background.source.angle + : 90, + }); + break; + } + case "wallpaper": { + setProject("background", "source", { + type: "wallpaper", + path: + project.background.source.type === "wallpaper" + ? project.background.source.path + : null, + }); + break; + } + } + }} + > + + + {(item) => { + const el = (props?: object) => ( + +
+ {(() => { + const getGradientBackground = () => { + const angle = + project.background.source.type === "gradient" + ? project.background.source.angle + : 90; + const fromColor = + project.background.source.type === "gradient" + ? project.background.source.from + : DEFAULT_GRADIENT_FROM; + const toColor = + project.background.source.type === "gradient" + ? project.background.source.to + : DEFAULT_GRADIENT_TO; + + return ( +
+ ); + }; + + const getColorBackground = () => { + const backgroundColor = + project.background.source.type === "color" + ? project.background.source.value + : hexToRgb(BACKGROUND_COLORS[9]); + + return ( +
+ ); + }; + + const getImageBackground = () => { + // Always start with the default icon + let imageSrc: string = BACKGROUND_ICONS[item]; + + // Only override for "image" if a valid path exists + if ( + item === "image" && + project.background.source.type === "image" && + project.background.source.path + ) { + const convertedPath = convertFileSrc( + project.background.source.path + ); + // Only use converted path if it's valid + if (convertedPath) { + imageSrc = convertedPath; + } + } + // Only override for "wallpaper" if a valid wallpaper is found + else if ( + item === "wallpaper" && + project.background.source.type === "wallpaper" && + project.background.source.path + ) { + const selectedWallpaper = wallpapers()?.find((w) => + ( + project.background.source as { path?: string } + ).path?.includes(w.id) + ); + // Only use wallpaper URL if it exists + if (selectedWallpaper?.url) { + imageSrc = selectedWallpaper.url; + } + } + + return ( + {BACKGROUND_SOURCES[item]} + ); + }; + + switch (item) { + case "gradient": + return getGradientBackground(); + case "color": + return getColorBackground(); + case "image": + case "wallpaper": + return getImageBackground(); + default: + return null; + } + })()} + {BACKGROUND_SOURCES[item]} +
+ + ); + + return el({}); + }} + + + {/** Dashed divider */} +
+ + {/** Background Tabs */} + + 0 ? "24px" : "0" + }, black calc(100% - ${ + reachedEndOfScroll() ? "0px" : "24px" + }), transparent)`, + + "mask-image": `linear-gradient(to right, transparent, black ${ + scrollX() > 0 ? "24px" : "0" + }, black calc(100% - ${ + reachedEndOfScroll() ? "0px" : "24px" + }), transparent);`, + }} + > + + {([key, value]) => ( + <> + + setBackgroundTab( + key as keyof typeof BACKGROUND_THEMES + ) + } + value={key} + class="flex relative z-10 flex-1 justify-center items-center px-4 py-2 bg-transparent rounded-lg border transition-colors duration-200 text-gray-11 ui-not-selected:hover:border-gray-7 ui-selected:bg-gray-3 ui-selected:border-gray-3 group ui-selected:text-gray-12 disabled:opacity-50 focus:outline-none" + > + {value} + + + )} + + + + {/** End of Background Tabs */} + + ( + project.background.source as { path?: string } + ).path?.includes(w.id) + )?.url ?? undefined + : undefined + } + onChange={(photoUrl) => { + try { + const wallpaper = wallpapers()?.find( + (w) => w.url === photoUrl + ); + if (!wallpaper) return; + + // Get the raw path without any URL prefixes + + debouncedSetProject(wallpaper.rawPath); + + ensurePaddingForBackground(); + } catch (err) { + toast.error("Failed to set wallpaper"); + } + }} + class="grid grid-cols-7 gap-2 h-auto" + > + +
+
+ Loading wallpapers... +
+
+ } + > + + {(photo) => ( + + + + Wallpaper option + + + )} + + + +
+ + {(photo) => ( + + + + Wallpaper option + + + )} + +
+
+
+
+
+
+ + fileInput.click()} + class="p-6 bg-gray-2 text-[13px] w-full rounded-[0.5rem] border border-gray-5 border-dashed flex flex-col items-center justify-center gap-[0.5rem] hover:bg-gray-3 transition-colors duration-100" + > + + + Click to select or drag and drop image + + + } + > + {(source) => ( +
+ Selected background +
+ +
+
+ )} +
+ { + const file = e.currentTarget.files?.[0]; + if (!file) return; + + /* this is a Tauri bug in WebKit so we need to validate the file type manually https://github.com/tauri-apps/tauri/issues/9158 */ - const validExtensions = [ - "jpg", - "jpeg", - "png", - "gif", - "webp", - "bmp", - ]; - const extension = file.name.split(".").pop()?.toLowerCase(); - if (!extension || !validExtensions.includes(extension)) { - toast.error("Invalid image file type"); - return; - } - - try { - const fileName = `bg-${Date.now()}-${file.name}`; - const arrayBuffer = await file.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - - const fullPath = `${await appDataDir()}/${fileName}`; - - await writeFile(fileName, uint8Array, { - baseDir: BaseDirectory.AppData, - }); - - setProject("background", "source", { - type: "image", - path: fullPath, - }); - } catch (err) { - toast.error("Failed to save image"); - } - }} - /> -
- - -
-
- { - setProject("background", "source", { - type: "color", - value, - }); - }} - /> -
- -
- - {(color) => ( -