feat: enhance canvas functionality with scissors mode and node template updates

- Implemented visual feedback and cursor changes for scissors mode in dark and light themes, improving user interaction during edge manipulation.
- Updated node template picker to include new keywords for AI image generation, enhancing searchability.
- Renamed and categorized node types for clarity, including updates to asset and prompt nodes.
- Added support for video nodes and adjusted related components for improved media handling on the canvas.
This commit is contained in:
Matthias
2026-03-28 21:11:52 +01:00
parent 02f36fdc7b
commit cbfa14a40b
18 changed files with 1329 additions and 24 deletions

View File

@@ -0,0 +1,79 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
const ALLOWED_HOSTS = new Set([
"videos.pexels.com",
"player.vimeo.com",
"vod-progressive.pexels.com",
]);
/**
* Proxies Pexels/Vimeo MP4 streams so playback works when the browsers
* Referer (e.g. localhost) would be rejected by the CDN.
* Forwards Range for seeking; whitelists known video hosts from the Pexels API.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
const raw = request.nextUrl.searchParams.get("u");
if (!raw) {
return new NextResponse("Missing u", { status: 400 });
}
let target: URL;
try {
target = new URL(raw);
} catch {
return new NextResponse("Invalid URL", { status: 400 });
}
if (target.protocol !== "https:") {
return new NextResponse("HTTPS only", { status: 400 });
}
if (!ALLOWED_HOSTS.has(target.hostname)) {
return new NextResponse("Host not allowed", { status: 403 });
}
const upstreamHeaders: HeadersInit = {
Referer: "https://www.pexels.com/",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0",
};
const range = request.headers.get("range");
if (range) upstreamHeaders.Range = range;
const ifRange = request.headers.get("if-range");
if (ifRange) upstreamHeaders["If-Range"] = ifRange;
let upstream: Response;
try {
upstream = await fetch(target.toString(), {
headers: upstreamHeaders,
redirect: "follow",
});
} catch {
return new NextResponse("Upstream fetch failed", { status: 502 });
}
const out = new Headers();
const copy = [
"content-type",
"content-length",
"accept-ranges",
"content-range",
"etag",
"last-modified",
"cache-control",
] as const;
for (const name of copy) {
const v = upstream.headers.get(name);
if (v) out.set(name, v);
}
if (!upstream.body) {
return new NextResponse(null, { status: upstream.status, headers: out });
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: out,
});
}

View File

@@ -203,12 +203,54 @@
stroke: rgba(189, 195, 199, 0.35); stroke: rgba(189, 195, 199, 0.35);
} }
/* Scherenmodus: Scheren-Cursor (Teal, Fallback crosshair) */ /*
.react-flow.canvas-scissors-mode .react-flow__pane, * Scherenmodus: Lucide „scissors-line-dashed“, 90° gegen Uhrzeigersinn (in SVG).
.react-flow.canvas-scissors-mode .react-flow__edge-interaction { * Dunkles Flow: helle Outline + heller Glow · helles Flow: dunkle Outline + dunkler Glow.
cursor: */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%230d9488' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='6' r='3'/%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Cpath d='M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12'/%3E%3C/svg%3E") .react-flow.dark.canvas-scissors-mode .react-flow__pane,
12 12, .react-flow.dark.canvas-scissors-mode .react-flow__edge-interaction {
crosshair; cursor: url("/cursors/scissors-cursor-dark-canvas.svg") 12 12, crosshair;
}
.react-flow:not(.dark).canvas-scissors-mode .react-flow__pane,
.react-flow:not(.dark).canvas-scissors-mode .react-flow__edge-interaction {
cursor: url("/cursors/scissors-cursor-light-canvas.svg") 12 12, crosshair;
}
/* Scherenmodus: Hover auf Verbindung = Aufleuchten (Farben wie Scheren-Cursor) */
.react-flow.dark.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path {
transition:
stroke 0.12s ease,
filter 0.12s ease;
}
.react-flow.dark.canvas-scissors-mode
.react-flow__edge:not(.temp):hover
.react-flow__edge-path {
stroke: #ffffff !important;
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.9))
drop-shadow(0 0 10px rgba(255, 255, 255, 0.5));
}
.react-flow:not(.dark).canvas-scissors-mode
.react-flow__edge:not(.temp)
.react-flow__edge-path {
transition:
stroke 0.12s ease,
filter 0.12s ease;
}
.react-flow:not(.dark).canvas-scissors-mode
.react-flow__edge:not(.temp):hover
.react-flow__edge-path {
stroke: #27272a !important;
filter: drop-shadow(0 0 2px rgba(39, 39, 42, 0.75))
drop-shadow(0 0 9px rgba(39, 39, 42, 0.4));
}
@media (prefers-reduced-motion: reduce) {
.react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path {
transition: none;
}
} }
} }

View File

@@ -30,7 +30,7 @@ const NODE_SEARCH_KEYWORDS: Partial<
> = { > = {
image: ["image", "photo", "foto"], image: ["image", "photo", "foto"],
text: ["text", "typo"], text: ["text", "typo"],
prompt: ["prompt", "ai", "generate"], prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"],
note: ["note", "sticky", "notiz"], note: ["note", "sticky", "notiz"],
frame: ["frame", "artboard"], frame: ["frame", "artboard"],
compare: ["compare", "before", "after"], compare: ["compare", "before", "after"],

View File

@@ -2,9 +2,10 @@
const nodeTemplates = [ const nodeTemplates = [
{ type: "image", label: "Bild", icon: "🖼️", category: "Quelle" }, { type: "image", label: "Bild", icon: "🖼️", category: "Quelle" },
{ type: "asset", label: "Asset", icon: "🛍️", category: "Quelle" }, { type: "asset", label: "FreePik", icon: "🛍️", category: "Quelle" },
{ type: "video", label: "Pexels", icon: "🎬", category: "Quelle" },
{ type: "text", label: "Text", icon: "📝", category: "Quelle" }, { type: "text", label: "Text", icon: "📝", category: "Quelle" },
{ type: "prompt", label: "Prompt", icon: "✨", category: "Quelle" }, { type: "prompt", label: "KI-Bild", icon: "✨", category: "Quelle" },
{ type: "note", label: "Notiz", icon: "📌", category: "Layout" }, { type: "note", label: "Notiz", icon: "📌", category: "Layout" },
{ type: "frame", label: "Frame", icon: "🖥️", category: "Layout" }, { type: "frame", label: "Frame", icon: "🖥️", category: "Layout" },
{ type: "group", label: "Gruppe", icon: "📁", category: "Layout" }, { type: "group", label: "Gruppe", icon: "📁", category: "Layout" },

View File

@@ -240,6 +240,49 @@ function isEdgeCuttable(edge: RFEdge): boolean {
return true; return true;
} }
/** Abstand in px zwischen Abtastpunkten beim Durchschneiden (kleiner = zuverlässiger bei schnellen Bewegungen). */
const SCISSORS_SEGMENT_SAMPLE_STEP_PX = 4;
function addCuttableEdgeIdAtClientPoint(
clientX: number,
clientY: number,
edgesList: RFEdge[],
strokeIds: Set<string>,
): void {
const id = getIntersectedEdgeId({ x: clientX, y: clientY });
if (!id) return;
const found = edgesList.find((e) => e.id === id);
if (found && isEdgeCuttable(found)) strokeIds.add(id);
}
/** Alle Kanten erfassen, deren Hit-Zone die Strecke von (x0,y0) nach (x1,y1) schneidet. */
function collectCuttableEdgesAlongScreenSegment(
x0: number,
y0: number,
x1: number,
y1: number,
edgesList: RFEdge[],
strokeIds: Set<string>,
): void {
const dx = x1 - x0;
const dy = y1 - y0;
const dist = Math.hypot(dx, dy);
if (dist < 0.5) {
addCuttableEdgeIdAtClientPoint(x1, y1, edgesList, strokeIds);
return;
}
const steps = Math.max(1, Math.ceil(dist / SCISSORS_SEGMENT_SAMPLE_STEP_PX));
for (let i = 0; i <= steps; i++) {
const t = i / steps;
addCuttableEdgeIdAtClientPoint(
x0 + dx * t,
y0 + dy * t,
edgesList,
strokeIds,
);
}
}
function hasHandleKey( function hasHandleKey(
handles: { source?: string; target?: string } | undefined, handles: { source?: string; target?: string } | undefined,
key: "source" | "target", key: "source" | "target",
@@ -1703,13 +1746,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
(event: React.DragEvent) => { (event: React.DragEvent) => {
event.preventDefault(); event.preventDefault();
const nodeType = event.dataTransfer.getData( const rawData = event.dataTransfer.getData(
"application/lemonspace-node-type", "application/lemonspace-node-type",
); );
if (!nodeType) { if (!rawData) {
return; return;
} }
// Support both plain type string (sidebar) and JSON payload (browser panels)
let nodeType: string;
let payloadData: Record<string, unknown> | undefined;
try {
const parsed = JSON.parse(rawData);
if (typeof parsed === "object" && parsed.type) {
nodeType = parsed.type;
payloadData = parsed.data;
} else {
nodeType = rawData;
}
} catch {
nodeType = rawData;
}
const position = screenToFlowPosition({ const position = screenToFlowPosition({
x: event.clientX, x: event.clientX,
y: event.clientY, y: event.clientY,
@@ -1729,7 +1788,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
positionY: position.y, positionY: position.y,
width: defaults.width, width: defaults.width,
height: defaults.height, height: defaults.height,
data: { ...defaults.data, canvasId }, data: { ...defaults.data, ...payloadData, canvasId },
clientRequestId, clientRequestId,
}).then((realId) => { }).then((realId) => {
syncPendingMoveForClientRequest(clientRequestId, realId); syncPendingMoveForClientRequest(clientRequestId, realId);
@@ -1798,13 +1857,19 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
setScissorStrokePreview(points); setScissorStrokePreview(points);
const handleMove = (ev: PointerEvent) => { const handleMove = (ev: PointerEvent) => {
points.push({ x: ev.clientX, y: ev.clientY }); const prev = points[points.length - 1]!;
const nx = ev.clientX;
const ny = ev.clientY;
collectCuttableEdgesAlongScreenSegment(
prev.x,
prev.y,
nx,
ny,
edgesRef.current,
strokeIds,
);
points.push({ x: nx, y: ny });
setScissorStrokePreview([...points]); setScissorStrokePreview([...points]);
const id = getIntersectedEdgeId({ x: ev.clientX, y: ev.clientY });
if (id) {
const found = edgesRef.current.find((ed) => ed.id === id);
if (found && isEdgeCuttable(found)) strokeIds.add(id);
}
}; };
const handleUp = () => { const handleUp = () => {

View File

@@ -7,6 +7,7 @@ import FrameNode from "./nodes/frame-node";
import NoteNode from "./nodes/note-node"; import NoteNode from "./nodes/note-node";
import CompareNode from "./nodes/compare-node"; import CompareNode from "./nodes/compare-node";
import AssetNode from "./nodes/asset-node"; import AssetNode from "./nodes/asset-node";
import VideoNode from "./nodes/video-node";
/** /**
* Node-Type-Map für React Flow. * Node-Type-Map für React Flow.
@@ -25,4 +26,5 @@ export const nodeTypes = {
note: NoteNode, note: NoteNode,
compare: CompareNode, compare: CompareNode,
asset: AssetNode, asset: AssetNode,
video: VideoNode,
} as const; } as const;

View File

@@ -92,7 +92,7 @@ export default function AiImageNode({
if (!canvasId) throw new Error("Missing canvasId"); if (!canvasId) throw new Error("Missing canvasId");
const prompt = nodeData.prompt; const prompt = nodeData.prompt;
if (!prompt) throw new Error("No prompt — use Generate from a Prompt node"); if (!prompt) throw new Error("No prompt — use Generate from a KI-Bild node");
const edges = getEdges(); const edges = getEdges();
const incomingEdges = edges.filter((e) => e.target === id); const incomingEdges = edges.filter((e) => e.target === id);
@@ -187,7 +187,7 @@ export default function AiImageNode({
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<ImageIcon className="h-10 w-10 opacity-30" /> <ImageIcon className="h-10 w-10 opacity-30" />
<p className="px-6 text-center text-xs opacity-60"> <p className="px-6 text-center text-xs opacity-60">
Connect a Prompt node and click Generate Verbinde einen KI-Bild-Knoten und starte die Generierung dort.
</p> </p>
</div> </div>
)} )}

View File

@@ -138,7 +138,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
> >
<div className="flex items-center justify-between border-b px-3 py-2"> <div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> <span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Asset FreePik
</span> </span>
<Button <Button
size="sm" size="sm"
@@ -167,7 +167,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={previewUrl} src={previewUrl}
alt={data.title ?? "Asset preview"} alt={data.title ?? "FreePik-Vorschau"}
className={`h-full w-full object-cover object-right transition-opacity ${ className={`h-full w-full object-cover object-right transition-opacity ${
isPreviewLoading ? "opacity-0" : "opacity-100" isPreviewLoading ? "opacity-0" : "opacity-100"
}`} }`}

View File

@@ -17,6 +17,7 @@ const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
group: { minWidth: 150, minHeight: 100 }, group: { minWidth: 150, minHeight: 100 },
image: { minWidth: 140, minHeight: 120, keepAspectRatio: true }, image: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false }, asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false },
video: { minWidth: 200, minHeight: 120, keepAspectRatio: true },
// Chrome 88 + min. Viewport 120 → äußere Mindesthöhe 208 (siehe canvas onNodesChange) // Chrome 88 + min. Viewport 120 → äußere Mindesthöhe 208 (siehe canvas onNodesChange)
"ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false }, "ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false },
compare: { minWidth: 300, minHeight: 200 }, compare: { minWidth: 300, minHeight: 200 },

View File

@@ -0,0 +1,255 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Handle, Position, useStore, type NodeProps } from "@xyflow/react";
import { useAction, useMutation } from "convex/react";
import { Play } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper";
import {
VideoBrowserPanel,
type VideoBrowserSessionState,
} from "@/components/canvas/video-browser-panel";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
type VideoNodeData = {
canvasId?: string;
pexelsId?: number;
mp4Url?: string;
thumbnailUrl?: string;
width?: number;
height?: number;
duration?: number;
attribution?: {
userName: string;
userUrl: string;
videoUrl: string;
};
};
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function VideoNode({
id,
data,
selected,
width,
height,
}: NodeProps) {
const d = data as VideoNodeData;
const [panelOpen, setPanelOpen] = useState(false);
const [browserState, setBrowserState] = useState<VideoBrowserSessionState>({
term: "",
orientation: "",
durationFilter: "all",
results: [],
page: 1,
totalPages: 1,
});
const resizeNode = useMutation(api.nodes.resize);
const updateData = useMutation(api.nodes.updateData);
const refreshPexelsPlayback = useAction(api.pexels.getVideoByPexelsId);
const edges = useStore((s) => s.edges);
const nodes = useStore((s) => s.nodes);
const linkedSearchTerm = useMemo(() => {
const incoming = edges.filter((e) => e.target === id);
for (const edge of incoming) {
const sourceNode = nodes.find((n) => n.id === edge.source);
if (sourceNode?.type !== "text") continue;
const content = (sourceNode.data as { content?: string }).content;
if (typeof content === "string" && content.trim().length > 0) {
return content.trim();
}
}
return "";
}, [edges, id, nodes]);
const openVideoBrowser = useCallback(() => {
setBrowserState((s) =>
linkedSearchTerm
? { ...s, term: linkedSearchTerm, results: [], page: 1, totalPages: 1 }
: s,
);
setPanelOpen(true);
}, [linkedSearchTerm]);
const hasVideo = Boolean(d.mp4Url && d.thumbnailUrl);
const hasAutoSizedRef = useRef(false);
const playbackRefreshAttempted = useRef(false);
useEffect(() => {
playbackRefreshAttempted.current = false;
}, [d.mp4Url]);
const handleVideoError = useCallback(() => {
const pexelsId = d.pexelsId;
if (pexelsId == null || playbackRefreshAttempted.current) return;
playbackRefreshAttempted.current = true;
void (async () => {
try {
const fresh = await refreshPexelsPlayback({ pexelsId });
await updateData({
nodeId: id as Id<"nodes">,
data: {
...d,
mp4Url: fresh.mp4Url,
width: fresh.width,
height: fresh.height,
duration: fresh.duration,
},
});
} catch {
playbackRefreshAttempted.current = false;
}
})();
}, [d, id, refreshPexelsPlayback, updateData]);
useEffect(() => {
if (!hasVideo) return;
if (hasAutoSizedRef.current) return;
const w = d.width;
const h = d.height;
if (typeof w !== "number" || typeof h !== "number" || w <= 0 || h <= 0)
return;
const currentWidth = typeof width === "number" ? width : 0;
const currentHeight = typeof height === "number" ? height : 0;
if (currentWidth <= 0 || currentHeight <= 0) return;
if (currentWidth !== 320 || currentHeight !== 180) {
hasAutoSizedRef.current = true;
return;
}
hasAutoSizedRef.current = true;
const aspectRatio = w / h;
const targetWidth = 320;
const targetHeight = Math.round(targetWidth / aspectRatio);
void resizeNode({
nodeId: id as Id<"nodes">,
width: targetWidth,
height: targetHeight,
});
}, [d.width, d.height, hasVideo, height, id, resizeNode, width]);
const showPreview = hasVideo && d.thumbnailUrl;
const playbackSrc =
d.mp4Url != null && d.mp4Url.length > 0
? `/api/pexels-video?u=${encodeURIComponent(d.mp4Url)}`
: undefined;
return (
<BaseNodeWrapper nodeType="video" selected={selected}>
<Handle
type="target"
position={Position.Left}
className="h-3! w-3! border-2! border-background! bg-primary!"
/>
<div className="flex h-full min-h-0 w-full flex-col">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Pexels
</span>
<button
type="button"
onClick={openVideoBrowser}
className={`nodrag h-6 rounded px-2 text-xs transition-colors ${
hasVideo
? "text-muted-foreground hover:bg-accent hover:text-foreground"
: "bg-primary text-primary-foreground hover:bg-primary/90"
}`}
>
{hasVideo ? "Change" : "Browse Videos"}
</button>
</div>
{/* Content: flex-1 + min-h-0 keeps media inside the node; avoid aspect-ratio here (grid overflow). */}
{showPreview ? (
<>
<div className="relative min-h-0 flex-1 overflow-hidden bg-muted/30">
<video
key={d.mp4Url}
src={playbackSrc}
poster={d.thumbnailUrl}
className="nodrag h-full w-full object-cover"
controls
playsInline
preload="metadata"
onError={handleVideoError}
/>
{typeof d.duration === "number" && d.duration > 0 && (
<div className="pointer-events-none absolute top-1.5 right-1.5 rounded bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white tabular-nums">
{formatDuration(d.duration)}
</div>
)}
</div>
{/* Attribution */}
{d.attribution ? (
<div className="flex shrink-0 flex-col gap-1 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[10px] text-muted-foreground">
by {d.attribution.userName}
</span>
<a
href={d.attribution.videoUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-[10px] text-muted-foreground underline hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
Pexels
</a>
</div>
</div>
) : (
<div className="shrink-0 px-3 py-2" />
)}
</>
) : (
<div className="flex min-h-0 flex-col items-center justify-center gap-3 px-4 py-8 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Play className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-xs font-medium">No video selected</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
Browse free stock videos from Pexels
</p>
</div>
</div>
)}
</div>
{/* Video browser modal */}
{panelOpen && d.canvasId ? (
<VideoBrowserPanel
nodeId={id}
canvasId={d.canvasId}
initialState={browserState}
onStateChange={setBrowserState}
onClose={() => setPanelOpen(false)}
/>
) : null}
<Handle
type="source"
position={Position.Right}
className="h-3! w-3! border-2! border-background! bg-primary!"
/>
</BaseNodeWrapper>
);
}

View File

@@ -0,0 +1,564 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent,
type PointerEvent,
} from "react";
import { createPortal } from "react-dom";
import { useAction, useMutation } from "convex/react";
import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types";
import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types";
import { toast } from "@/lib/toast";
type Orientation = "" | "landscape" | "portrait" | "square";
type DurationFilter = "all" | "short" | "medium" | "long";
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
function pexelsVideoProxySrc(mp4Url: string): string {
return `/api/pexels-video?u=${encodeURIComponent(mp4Url)}`;
}
export interface VideoBrowserSessionState {
term: string;
orientation: Orientation;
durationFilter: DurationFilter;
results: PexelsVideo[];
page: number;
totalPages: number;
}
interface Props {
nodeId: string;
canvasId: string;
onClose: () => void;
initialState?: VideoBrowserSessionState;
onStateChange?: (state: VideoBrowserSessionState) => void;
}
export function VideoBrowserPanel({
nodeId,
canvasId,
onClose,
initialState,
onStateChange,
}: Props) {
const [isMounted, setIsMounted] = useState(false);
const [term, setTerm] = useState(initialState?.term ?? "");
const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? "");
const [orientation, setOrientation] = useState<Orientation>(
initialState?.orientation ?? "",
);
const [durationFilter, setDurationFilter] = useState<DurationFilter>(
initialState?.durationFilter ?? "all",
);
const [results, setResults] = useState<PexelsVideo[]>(
initialState?.results ?? [],
);
const [page, setPage] = useState(initialState?.page ?? 1);
const [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectingVideoId, setSelectingVideoId] = useState<number | null>(
null,
);
const [previewingVideoId, setPreviewingVideoId] = useState<number | null>(
null,
);
const searchVideos = useAction(api.pexels.searchVideos);
const popularVideos = useAction(api.pexels.popularVideos);
const updateData = useMutation(api.nodes.updateData);
const resizeNode = useMutation(api.nodes.resize);
const shouldSkipInitialSearchRef = useRef(
Boolean(initialState?.results?.length),
);
const requestSequenceRef = useRef(0);
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const isSelecting = selectingVideoId !== null;
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
// Debounce
useEffect(() => {
const timeout = setTimeout(() => setDebouncedTerm(term), 400);
return () => clearTimeout(timeout);
}, [term]);
useEffect(() => {
setPreviewingVideoId(null);
}, [debouncedTerm, orientation, durationFilter, page]);
// Escape
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
// Session state sync
useEffect(() => {
if (!onStateChange) return;
onStateChange({ term, orientation, durationFilter, results, page, totalPages });
}, [durationFilter, onStateChange, orientation, page, results, term, totalPages]);
const getDurationParams = useCallback(
(filter: DurationFilter): { minDuration?: number; maxDuration?: number } => {
switch (filter) {
case "short":
return { maxDuration: 30 };
case "medium":
return { minDuration: 30, maxDuration: 60 };
case "long":
return { minDuration: 60 };
default:
return {};
}
},
[],
);
const runSearch = useCallback(
async (searchTerm: string, requestedPage: number) => {
const seq = ++requestSequenceRef.current;
setIsLoading(true);
setErrorMessage(null);
try {
const isSearch = searchTerm.trim().length > 0;
const durationParams = getDurationParams(durationFilter);
const response = isSearch
? await searchVideos({
query: searchTerm,
orientation: orientation || undefined,
page: requestedPage,
perPage: 20,
...durationParams,
})
: await popularVideos({
page: requestedPage,
perPage: 20,
});
if (seq !== requestSequenceRef.current) return;
const videos = response.videos ?? [];
setResults(videos);
setPage(requestedPage);
// Estimate total pages from next_page presence
const estimatedTotal = response.total_results ?? videos.length * requestedPage;
const perPage = response.per_page ?? 20;
setTotalPages(Math.max(1, Math.ceil(estimatedTotal / perPage)));
if (scrollAreaRef.current) scrollAreaRef.current.scrollTop = 0;
} catch (error) {
if (seq !== requestSequenceRef.current) return;
console.error("Pexels video search error", error);
setErrorMessage(
error instanceof Error ? error.message : "Search failed",
);
} finally {
if (seq === requestSequenceRef.current) setIsLoading(false);
}
},
[searchVideos, popularVideos, orientation, durationFilter, getDurationParams],
);
// Trigger search
useEffect(() => {
if (shouldSkipInitialSearchRef.current) {
shouldSkipInitialSearchRef.current = false;
return;
}
void runSearch(debouncedTerm, 1);
}, [debouncedTerm, orientation, durationFilter, runSearch]);
const handleSelect = useCallback(
async (video: PexelsVideo) => {
if (isSelecting) return;
setSelectingVideoId(video.id);
let file: PexelsVideoFile;
try {
file = pickVideoFile(video.video_files);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Videoformat nicht verfügbar",
);
setSelectingVideoId(null);
return;
}
try {
await updateData({
nodeId: nodeId as Id<"nodes">,
data: {
pexelsId: video.id,
mp4Url: file.link,
thumbnailUrl: video.image,
width: video.width,
height: video.height,
duration: video.duration,
attribution: {
userName: video.user.name,
userUrl: video.user.url,
videoUrl: video.url,
},
canvasId,
},
});
// Auto-resize to match aspect ratio
const aspectRatio =
video.width > 0 && video.height > 0
? video.width / video.height
: 16 / 9;
const targetWidth = 320;
const targetHeight = Math.round(targetWidth / aspectRatio);
await resizeNode({
nodeId: nodeId as Id<"nodes">,
width: targetWidth,
height: targetHeight,
});
onClose();
} catch (error) {
console.error("Failed to select video", error);
} finally {
setSelectingVideoId(null);
}
},
[canvasId, isSelecting, nodeId, onClose, resizeNode, updateData],
);
const handlePreviousPage = useCallback(() => {
if (isLoading || page <= 1) return;
void runSearch(debouncedTerm, page - 1);
}, [debouncedTerm, isLoading, page, runSearch]);
const handleNextPage = useCallback(() => {
if (isLoading || page >= totalPages) return;
void runSearch(debouncedTerm, page + 1);
}, [debouncedTerm, isLoading, page, runSearch, totalPages]);
const modal = useMemo(
() => (
<div
className="nowheel nodrag nopan fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={onClose}
onWheelCapture={(event) => event.stopPropagation()}
onPointerDownCapture={(event) => event.stopPropagation()}
>
<div
className="nowheel nodrag nopan relative flex max-h-[80vh] w-[720px] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"
onClick={(event) => event.stopPropagation()}
onWheelCapture={(event) => event.stopPropagation()}
onPointerDownCapture={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Browse Pexels videos"
>
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b px-5 py-4">
<h2 className="text-sm font-semibold">Browse Pexels Videos</h2>
<button
onClick={onClose}
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Close video browser"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Search + Filters */}
<div className="flex shrink-0 flex-col gap-3 border-b px-5 py-3">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search videos..."
value={term}
onChange={(event) => setTerm(event.target.value)}
className="pl-9"
autoFocus
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Format:</span>
{(["", "landscape", "portrait", "square"] as const).map((o) => (
<button
key={o || "all"}
type="button"
onClick={() => setOrientation(o)}
className={`rounded px-2 py-0.5 text-xs transition-colors ${
orientation === o
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent"
}`}
>
{o === "" ? "Alle" : o === "landscape" ? "Quer" : o === "portrait" ? "Hoch" : "Quadrat"}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Dauer:</span>
{(
[
["all", "Alle"],
["short", "<30s"],
["medium", "3060s"],
["long", ">60s"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setDurationFilter(key)}
className={`rounded px-2 py-0.5 text-xs transition-colors ${
durationFilter === key
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent"
}`}
>
{label}
</button>
))}
</div>
</div>
</div>
{/* Results */}
<div
ref={scrollAreaRef}
className="nowheel nodrag nopan flex-1 overflow-y-auto p-5"
onWheelCapture={(event) => event.stopPropagation()}
>
{isLoading && results.length === 0 ? (
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 12 }).map((_, index) => (
<div
key={index}
className="aspect-video animate-pulse rounded-lg bg-muted"
/>
))}
</div>
) : errorMessage ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-foreground">Search failed</p>
<p className="max-w-md text-xs">{errorMessage}</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void runSearch(debouncedTerm, page)}
>
Erneut versuchen
</Button>
</div>
) : results.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
<Search className="h-8 w-8" />
<p className="text-sm">
{term.trim() ? "Keine Videos gefunden" : "Videos werden geladen..."}
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
{results.map((video) => {
const isSelectingThis = selectingVideoId === video.id;
const previewFile = pickPreviewVideoFile(video.video_files);
const previewSrc = previewFile
? pexelsVideoProxySrc(previewFile.link)
: null;
const isPreview = previewingVideoId === video.id;
const stopBubbling = (e: PointerEvent | MouseEvent) => {
e.stopPropagation();
};
return (
<div
key={video.id}
className="group relative aspect-video overflow-hidden rounded-lg border-2 border-transparent bg-muted transition-all hover:border-primary focus-within:border-primary"
title={`${video.user.name}${formatDuration(video.duration)}`}
>
<button
type="button"
disabled={isSelecting}
onClick={() => void handleSelect(video)}
aria-busy={isSelectingThis}
aria-label={`Video von ${video.user.name} auswählen`}
className={`absolute inset-0 z-0 rounded-[inherit] border-0 bg-transparent p-0 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed ${
isPreview ? "pointer-events-none" : "cursor-pointer"
}`}
>
<span className="sr-only">Auswählen</span>
</button>
{isPreview && previewSrc ? (
<video
key={previewSrc}
src={previewSrc}
className="nodrag absolute inset-0 z-15 h-full w-full object-cover"
controls
muted
loop
playsInline
autoPlay
preload="metadata"
onPointerDownCapture={stopBubbling}
/>
) : (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={video.image}
alt=""
className="pointer-events-none absolute inset-0 z-1 h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
/>
)}
{previewSrc ? (
<button
type="button"
className="nodrag pointer-events-auto absolute bottom-1 left-1 z-20 flex h-8 w-8 items-center justify-center rounded-md bg-black/65 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
aria-label={isPreview ? "Vorschau beenden" : "Vorschau abspielen"}
onPointerDown={stopBubbling}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setPreviewingVideoId((cur) =>
cur === video.id ? null : video.id,
);
}}
>
{isPreview ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5 fill-current" />
)}
</button>
) : null}
{isPreview ? (
<button
type="button"
className="nodrag pointer-events-auto absolute top-1 right-1 z-20 rounded-md bg-black/65 px-2 py-1 text-[10px] font-medium text-white backdrop-blur-sm hover:bg-black/80"
onPointerDown={stopBubbling}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleSelect(video);
}}
>
Auswählen
</button>
) : null}
<div className="pointer-events-none absolute bottom-1 right-1 z-12 rounded bg-black/60 px-1 py-0.5 text-[9px] font-medium text-white tabular-nums">
{formatDuration(video.duration)}
</div>
<div className="pointer-events-none absolute inset-0 z-11 bg-black/0 transition-colors group-hover:bg-black/10" />
{isSelectingThis ? (
<div className="pointer-events-none absolute inset-0 z-25 flex flex-col items-center justify-center gap-1 bg-black/55 text-[11px] text-white">
<Loader2 className="h-4 w-4 animate-spin" />
Anwenden...
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex shrink-0 flex-col gap-3 border-t px-5 py-3">
{results.length > 0 ? (
<div className="flex items-center justify-center gap-2" aria-live="polite">
<Button
variant="outline"
size="sm"
onClick={handlePreviousPage}
disabled={isLoading || page <= 1}
>
Zurück
</Button>
<span className="text-xs text-muted-foreground">
Seite {page} von {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={isLoading || page >= totalPages}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Laden...
</>
) : (
"Weiter"
)}
</Button>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
Videos by{" "}
<a
href="https://www.pexels.com"
target="_blank"
rel="noopener noreferrer"
className="underline transition-colors hover:text-foreground"
>
Pexels
</a>
. Free to use, attribution appreciated.
</p>
<span className="text-[11px] text-muted-foreground">
{results.length > 0 ? `${results.length} Videos` : ""}
</span>
</div>
</div>
</div>
</div>
),
[
debouncedTerm,
durationFilter,
errorMessage,
handleNextPage,
handlePreviousPage,
handleSelect,
isLoading,
isSelecting,
onClose,
orientation,
page,
results,
runSearch,
previewingVideoId,
selectingVideoId,
term,
totalPages,
],
);
if (!isMounted) return null;
return createPortal(modal, document.body);
}

View File

@@ -19,6 +19,7 @@ import type * as helpers from "../helpers.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as nodes from "../nodes.js"; import type * as nodes from "../nodes.js";
import type * as openrouter from "../openrouter.js"; import type * as openrouter from "../openrouter.js";
import type * as pexels from "../pexels.js";
import type * as polar from "../polar.js"; import type * as polar from "../polar.js";
import type * as storage from "../storage.js"; import type * as storage from "../storage.js";
@@ -40,6 +41,7 @@ declare const fullApi: ApiFromModules<{
http: typeof http; http: typeof http;
nodes: typeof nodes; nodes: typeof nodes;
openrouter: typeof openrouter; openrouter: typeof openrouter;
pexels: typeof pexels;
polar: typeof polar; polar: typeof polar;
storage: typeof storage; storage: typeof storage;
}>; }>;

164
convex/pexels.ts Normal file
View File

@@ -0,0 +1,164 @@
"use node";
import { v } from "convex/values";
import { action } from "./_generated/server";
/** Canonical API base (legacy /videos/ ohne /v1/ ist deprecated laut Pexels-Doku). */
const PEXELS_VIDEO_API = "https://api.pexels.com/v1/videos";
interface PexelsVideoFile {
id: number;
quality: "hd" | "sd" | "uhd" | "hls";
file_type: string;
width: number;
height: number;
fps: number;
link: string;
}
interface PexelsVideo {
id: number;
width: number;
height: number;
url: string;
image: string;
duration: number;
user: { id: number; name: string; url: string };
video_files: PexelsVideoFile[];
}
function pickPlayableVideoFile(files: PexelsVideoFile[]): PexelsVideoFile {
const playable = files.filter((f) => {
if (f.quality === "hls") return false;
const url = f.link.toLowerCase();
if (url.includes(".m3u8")) return false;
return url.includes(".mp4");
});
if (playable.length === 0) {
throw new Error("No progressive MP4 in Pexels video_files");
}
return (
playable.find((f) => f.quality === "hd") ??
playable.find((f) => f.quality === "uhd") ??
playable.find((f) => f.quality === "sd") ??
playable[0]
);
}
/** Frische MP4-URL (Signing kann ablaufen) — gleiche Auswahl wie beim ersten Pick. */
export const getVideoByPexelsId = action({
args: { pexelsId: v.number() },
handler: async (_ctx, { pexelsId }) => {
const apiKey = process.env.PEXELS_API_KEY;
if (!apiKey) {
throw new Error("PEXELS_API_KEY not set");
}
const res = await fetch(`${PEXELS_VIDEO_API}/${pexelsId}`, {
headers: { Authorization: apiKey },
});
if (!res.ok) {
throw new Error(`Pexels API error: ${res.status} ${res.statusText}`);
}
const video = (await res.json()) as PexelsVideo;
const file = pickPlayableVideoFile(video.video_files);
return {
mp4Url: file.link,
width: video.width,
height: video.height,
duration: video.duration,
};
},
});
export const searchVideos = action({
args: {
query: v.string(),
orientation: v.optional(
v.union(
v.literal("landscape"),
v.literal("portrait"),
v.literal("square"),
),
),
minDuration: v.optional(v.number()),
maxDuration: v.optional(v.number()),
page: v.optional(v.number()),
perPage: v.optional(v.number()),
},
handler: async (_ctx, args) => {
const apiKey = process.env.PEXELS_API_KEY;
if (!apiKey) {
throw new Error("PEXELS_API_KEY not set");
}
const params = new URLSearchParams({
query: args.query,
per_page: String(args.perPage ?? 20),
page: String(args.page ?? 1),
...(args.orientation && { orientation: args.orientation }),
...(args.minDuration != null && {
min_duration: String(args.minDuration),
}),
...(args.maxDuration != null && {
max_duration: String(args.maxDuration),
}),
});
const res = await fetch(`${PEXELS_VIDEO_API}/search?${params}`, {
headers: { Authorization: apiKey },
});
if (!res.ok) {
throw new Error(`Pexels API error: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as {
videos: PexelsVideo[];
total_results: number;
next_page?: string;
page?: number;
per_page?: number;
};
return data;
},
});
export const popularVideos = action({
args: {
page: v.optional(v.number()),
perPage: v.optional(v.number()),
},
handler: async (_ctx, args) => {
const apiKey = process.env.PEXELS_API_KEY;
if (!apiKey) {
throw new Error("PEXELS_API_KEY not set");
}
const params = new URLSearchParams({
per_page: String(args.perPage ?? 20),
page: String(args.page ?? 1),
});
const res = await fetch(`${PEXELS_VIDEO_API}/popular?${params}`, {
headers: { Authorization: apiKey },
});
if (!res.ok) {
throw new Error(`Pexels API error: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as {
videos: PexelsVideo[];
total_results?: number;
next_page?: string;
page?: number;
per_page?: number;
};
return data;
},
});

View File

@@ -15,7 +15,7 @@ export const CANVAS_NODE_TEMPLATES = [
}, },
{ {
type: "prompt", type: "prompt",
label: "Prompt", label: "KI-Bild",
width: 320, width: 320,
height: 220, height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" }, defaultData: { prompt: "", model: "", aspectRatio: "1:1" },

View File

@@ -101,6 +101,7 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
text: [13, 148, 136], text: [13, 148, 136],
note: [13, 148, 136], note: [13, 148, 136],
asset: [13, 148, 136], asset: [13, 148, 136],
video: [13, 148, 136],
group: [100, 116, 139], group: [100, 116, 139],
frame: [249, 115, 22], frame: [249, 115, 22],
compare: [100, 116, 139], compare: [100, 116, 139],
@@ -185,6 +186,7 @@ export const NODE_HANDLE_MAP: Record<
note: { source: undefined, target: undefined }, note: { source: undefined, target: undefined },
compare: { source: "compare-out", target: "left" }, compare: { source: "compare-out", target: "left" },
asset: { source: undefined, target: undefined }, asset: { source: undefined, target: undefined },
video: { source: undefined, target: undefined },
}; };
/** /**
@@ -208,6 +210,7 @@ export const NODE_DEFAULTS: Record<
note: { width: 208, height: 100, data: { content: "" } }, note: { width: 208, height: 100, data: { content: "" } },
compare: { width: 500, height: 380, data: {} }, compare: { width: 500, height: 380, data: {} },
asset: { width: 260, height: 240, data: {} }, asset: { width: 260, height: 240, data: {} },
video: { width: 320, height: 180, data: {} },
}; };
type MediaNodeKind = "asset" | "image"; type MediaNodeKind = "asset" | "image";

81
lib/pexels-types.ts Normal file
View File

@@ -0,0 +1,81 @@
export interface PexelsVideoFile {
id: number;
/** Pexels liefert u. a. `hls` mit `.m3u8` — nicht für `<video src>`. */
quality: "hd" | "sd" | "uhd" | "hls";
file_type: string;
width: number;
height: number;
fps: number;
link: string;
}
export interface PexelsVideo {
id: number;
width: number;
height: number;
url: string;
image: string;
duration: number;
user: {
id: number;
name: string;
url: string;
};
video_files: PexelsVideoFile[];
}
export interface VideoNodeData {
canvasId?: string;
pexelsId?: number;
mp4Url?: string;
thumbnailUrl?: string;
width?: number;
height?: number;
duration?: number;
attribution?: {
userName: string;
userUrl: string;
videoUrl: string;
};
}
function isProgressiveMp4Candidate(f: PexelsVideoFile): boolean {
if (f.quality === "hls") return false;
const url = f.link.toLowerCase();
if (url.includes(".m3u8")) return false;
return url.includes(".mp4");
}
/**
* Progressive MP4 für HTML5-`<video>` — niemals HLS/m3u8 (API setzt dort teils fälschlich `video/mp4`).
*/
export function pickVideoFile(files: PexelsVideoFile[]): PexelsVideoFile {
const playable = files.filter(isProgressiveMp4Candidate);
if (playable.length === 0) {
throw new Error("Kein MP4-Download von Pexels verfügbar (nur HLS?).");
}
return (
playable.find((f) => f.quality === "hd") ??
playable.find((f) => f.quality === "uhd") ??
playable.find((f) => f.quality === "sd") ??
playable[0]
);
}
/**
* Kleinste sinnvolle MP4 für Raster-Vorschau (bevorzugt SD, sonst kleinste Auflösung).
*/
export function pickPreviewVideoFile(files: PexelsVideoFile[]): PexelsVideoFile | null {
const playable = files.filter(isProgressiveMp4Candidate);
if (playable.length === 0) return null;
const sd = playable.filter((f) => f.quality === "sd");
const pool = sd.length > 0 ? sd : playable;
return pool.reduce((best, f) => {
const a = (f.width || 0) * (f.height || 0);
const b = (best.width || 0) * (best.height || 0);
if (a === 0 && b === 0) return f;
if (a === 0) return best;
if (b === 0) return f;
return a < b ? f : best;
});
}

View File

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true">
<!-- Lucide scissors-line-dashed, rotated -90deg; light strokes for dark canvas -->
<g transform="rotate(-90 12 12)" fill="none" stroke-linecap="round" stroke-linejoin="round">
<g stroke="rgba(255,255,255,0.6)" stroke-width="2.85" opacity="0.95">
<path d="M5.42 9.42 8 12"/>
<circle cx="4" cy="8" r="2" fill="none"/>
<path d="m14 6-8.58 8.58"/>
<circle cx="4" cy="16" r="2" fill="none"/>
<path d="M10.8 14.8 14 18"/>
<path d="M16 12h-2"/>
<path d="M22 12h-2"/>
</g>
<g stroke="#ffffff" stroke-width="1.08">
<path d="M5.42 9.42 8 12"/>
<circle cx="4" cy="8" r="2" fill="none"/>
<path d="m14 6-8.58 8.58"/>
<circle cx="4" cy="16" r="2" fill="none"/>
<path d="M10.8 14.8 14 18"/>
<path d="M16 12h-2"/>
<path d="M22 12h-2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 947 B

View File

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true">
<!-- Lucide scissors-line-dashed, rotated -90deg; dark strokes for light canvas -->
<g transform="rotate(-90 12 12)" fill="none" stroke-linecap="round" stroke-linejoin="round">
<g stroke="rgba(24,24,27,0.55)" stroke-width="2.85" opacity="0.95">
<path d="M5.42 9.42 8 12"/>
<circle cx="4" cy="8" r="2" fill="none"/>
<path d="m14 6-8.58 8.58"/>
<circle cx="4" cy="16" r="2" fill="none"/>
<path d="M10.8 14.8 14 18"/>
<path d="M16 12h-2"/>
<path d="M22 12h-2"/>
</g>
<g stroke="#27272a" stroke-width="1.08">
<path d="M5.42 9.42 8 12"/>
<circle cx="4" cy="8" r="2" fill="none"/>
<path d="m14 6-8.58 8.58"/>
<circle cx="4" cy="16" r="2" fill="none"/>
<path d="M10.8 14.8 14 18"/>
<path d="M16 12h-2"/>
<path d="M22 12h-2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 945 B