import { Point, projectPointOntoLine, Rect, rectContainsPoint, } from "@noya-app/noya-geometry"; import { isMoving } from "../utils/isMoving"; import { BaseCanvasData, createCanvasPlugin } from "./baseCanvas"; import { BaseContextProperties, ScreenPointProperties, } from "./contextProperties"; import { modeSwitch, NoneMode } from "./PluginSystem"; import { PointerData } from "./pointerPlugin"; import { useReactCanvas } from "./ReactCanvas"; export const DEFAULT_GRADIENT_HANDLE_SIZE = 12; export const GRADIENT_EDITOR_SURFACE_ATTRIBUTE = "data-gradient-editor-surface"; export type GradientStopHandle = { id: string; index: number; type: "start" | "end" | "stop"; position: number; point: Point; color?: string; }; export type GradientEditorGradient = { rect: Rect; stops: GradientStopHandle[]; }; export type GradientEditorMode = | NoneMode | { type: "draggingGradientStop"; stopId: string; snapshot: StateSnapshot; }; export type GradientEditorData = { handleSize: number; hoveredStopId?: string; activeStopId?: string; zoom: number; } & Pick; export interface GradientEditorContextProperties extends BaseContextProperties< GradientEditorMode, GradientEditorData >, ScreenPointProperties { canEditGradient: () => boolean; getGradient: () => GradientEditorGradient | undefined; getStateSnapshot: () => StateSnapshot; moveStop: (parameters: { id: string; point: Point; position: number; snapshot: StateSnapshot; }) => void; } export type GradientEditorPluginOptions = { handleSize?: number; movementThreshold?: number; }; function clamp01(value: number) { return Math.min(Math.max(value, 0), 1); } function getHandleRect( stop: GradientStopHandle, data: Pick ): Rect { const size = data.handleSize / data.zoom; return { x: stop.point.x - size / 2, y: stop.point.y - size / 2, width: size, height: size, }; } function findStopAtPoint({ point, gradient, data, }: { point: Point; gradient: GradientEditorGradient; data: GradientEditorData; }) { return gradient.stops.find((stop) => rectContainsPoint(getHandleRect(stop, data), point) ); } export function gradientEditorPlugin( options: GradientEditorPluginOptions = {} ) { const { handleSize = DEFAULT_GRADIENT_HANDLE_SIZE, movementThreshold = 0 } = options; return createCanvasPlugin({ name: "gradientEditor", data: { handleSize, hoveredStopId: undefined, activeStopId: undefined, zoom: 1, }, handlers: (context: GradientEditorContextProperties) => ({ onPointerDown(event) { const canEditGradient = context.canEditGradient(); if (!canEditGradient) return; if (context.getMode().type !== "none") return; const data = context.getData(); const pointerDown = data.pointerDown; if (!pointerDown) return; const gradient = context.getGradient(); if (!gradient) return; const stop = findStopAtPoint({ point: pointerDown.anchor, gradient, data, }); if (!stop) return; context.setMode({ type: "draggingGradientStop", stopId: stop.id, snapshot: context.getStateSnapshot(), }); context.setData((current) => ({ ...current, activeStopId: stop.id, })); event.preventDefault(); }, onPointerMove(event) { const canEditGradient = context.canEditGradient(); if (!canEditGradient) return; const data = context.getData(); const gradient = context.getGradient(); if (!gradient) { if (data.hoveredStopId) { context.setData((current) => ({ ...current, hoveredStopId: undefined, })); } return; } modeSwitch(context.getMode(), { none: () => { if (data.pointerDown) return; const pointer = data.pointer; const hoveredStop = pointer ? findStopAtPoint({ point: pointer, gradient, data }) : undefined; if (hoveredStop?.id !== data.hoveredStopId) { context.setData((current) => ({ ...current, hoveredStopId: hoveredStop?.id, })); } }, draggingGradientStop: (mode) => { const pointerDown = data.pointerDown; if (!pointerDown) return; if ( !isMoving( pointerDown.anchor, pointerDown.head, data.zoom, movementThreshold ) ) { return; } const stop = gradient.stops.find( (candidate) => candidate.id === mode.stopId ); const start = gradient.stops.find( (candidate) => candidate.type === "start" ); const end = gradient.stops.find( (candidate) => candidate.type === "end" ); if (!stop || !start || !end) return; let nextPoint = pointerDown.head; let nextPosition = stop.position; if (stop.type === "stop") { const projection = projectPointOntoLine(pointerDown.head, [ start.point, end.point, ]); nextPoint = projection.projected; nextPosition = projection.t; } else if (stop.type === "start") { nextPosition = 0; } else if (stop.type === "end") { nextPosition = 1; } context.moveStop({ id: stop.id, point: nextPoint, position: clamp01(nextPosition), snapshot: mode.snapshot, }); event.preventDefault(); }, }); }, onPointerUp(event) { const canEditGradient = context.canEditGradient(); if (!canEditGradient) return; modeSwitch(context.getMode(), { draggingGradientStop: () => { context.setMode({ type: "none" }); context.setData((current) => ({ ...current, activeStopId: undefined, })); event.preventDefault(); }, }); }, onPointerLeave() { if (context.getMode().type !== "none") return; const data = context.getData(); if (data.hoveredStopId) { context.setData((current) => ({ ...current, hoveredStopId: undefined, })); } }, onPointerCancel() { context.setMode({ type: "none" }); context.setData((current) => ({ ...current, activeStopId: undefined, hoveredStopId: undefined, })); }, }), getCursor: ({ data }) => { if (data.activeStopId || data.hoveredStopId) { return "pointer"; } return undefined; }, }); } export function useGradientEditor() { const ReactCanvas = useReactCanvas(); const data = ReactCanvas.useData() as GradientEditorData & BaseCanvasData; const mode = ReactCanvas.useMode() as GradientEditorMode; const context = ReactCanvas.useContextPropertiesRef() as { getGradient?: () => GradientEditorGradient | undefined; }; const gradient = context.getGradient?.(); return { data, mode, gradient }; }