|
<script lang="ts"> |
|
import { createEventDispatcher } from "svelte"; |
|
|
|
import type { Curve, CurveManipulatorGroup } from "@graphite/messages"; |
|
import { clamp } from "@graphite/utility-functions/math"; |
|
|
|
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte"; |
|
|
|
const dispatch = createEventDispatcher<{ |
|
value: Curve; |
|
}>(); |
|
|
|
export let classes: Record<string, boolean> = {}; |
|
let styleName = ""; |
|
export { styleName as style }; |
|
export let styles: Record<string, string | number | undefined> = {}; |
|
export let value: Curve; |
|
export let disabled = false; |
|
export let tooltip: string | undefined = undefined; |
|
|
|
const GRID_SIZE = 4; |
|
|
|
let groups: CurveManipulatorGroup[] = [ |
|
{ |
|
anchor: [0, 0], |
|
handles: [ |
|
[-1, -1], |
|
[0.25, 0.25], |
|
], |
|
}, |
|
{ |
|
anchor: [0.5, 0.5], |
|
handles: [ |
|
[0.25, 0.25], |
|
[0.75, 0.75], |
|
], |
|
}, |
|
{ |
|
anchor: [1, 1], |
|
handles: [ |
|
[0.75, 0.75], |
|
[2, 2], |
|
], |
|
}, |
|
]; |
|
let selectedNodeIndex: number | undefined = undefined; |
|
let draggedNodeIndex: number | undefined = undefined; |
|
let dAttribute = recalculateSvgPath(); |
|
|
|
$: { |
|
groups = [groups[0]].concat(value.manipulatorGroups).concat([groups[groups.length - 1]]); |
|
groups[0].handles[1] = value.firstHandle; |
|
groups[groups.length - 1].handles[0] = value.lastHandle; |
|
dAttribute = recalculateSvgPath(); |
|
} |
|
|
|
function updateCurve() { |
|
dispatch("value", { |
|
manipulatorGroups: groups.slice(1, groups.length - 1), |
|
firstHandle: groups[0].handles[1], |
|
lastHandle: groups[groups.length - 1].handles[0], |
|
}); |
|
} |
|
|
|
function recalculateSvgPath() { |
|
let dAttribute = ""; |
|
let anchor = groups[0].anchor; |
|
let handle = groups[0].handles[1]; |
|
|
|
groups.slice(1).forEach((group) => { |
|
dAttribute += `M${anchor[0]} ${1 - anchor[1]} C${handle[0]} ${1 - handle[1]}, ${group.handles[0][0]} ${1 - group.handles[0][1]}, ${group.anchor[0]} ${1 - group.anchor[1]} `; |
|
anchor = group.anchor; |
|
handle = group.handles[1]; |
|
}); |
|
|
|
return dAttribute; |
|
} |
|
|
|
function handleManipulatorPointerDown(e: PointerEvent, i: number) { |
|
|
|
if (e.button > 0 && i > 0 && i < groups.length - 1) { |
|
draggedNodeIndex = undefined; |
|
selectedNodeIndex = undefined; |
|
|
|
groups.splice(i, 1); |
|
groups = groups; |
|
|
|
dAttribute = recalculateSvgPath(); |
|
|
|
updateCurve(); |
|
|
|
return; |
|
} |
|
|
|
draggedNodeIndex = i; |
|
if (i >= 0) selectedNodeIndex = i; |
|
} |
|
|
|
function getSvgPositionFromPointerEvent(e: PointerEvent): [number, number] | undefined { |
|
if (!(e.target instanceof SVGElement)) return undefined; |
|
|
|
const target = e.target?.closest("svg") || undefined; |
|
if (!target) return undefined; |
|
|
|
const rect = target.getBoundingClientRect(); |
|
const x = (e.x - rect.x) / rect.width; |
|
const y = 1 - (e.y - rect.y) / rect.height; |
|
return [clamp(x), clamp(y)]; |
|
} |
|
|
|
function clampHandles() { |
|
for (let i = 0; i < groups.length - 1; i++) { |
|
const [min, max] = [groups[i].anchor[0], groups[i + 1].anchor[0]]; |
|
|
|
for (let j = 0; j < 2; j++) { |
|
groups[i + j].handles[1 - j][0] = clamp(groups[i + j].handles[1 - j][0], min, max); |
|
groups[i + j].handles[1 - j][1] = clamp(groups[i + j].handles[1 - j][1]); |
|
} |
|
} |
|
} |
|
|
|
function handlePointerUp(e: PointerEvent) { |
|
if (draggedNodeIndex !== undefined) { |
|
draggedNodeIndex = undefined; |
|
return; |
|
} |
|
if (e.button !== 0) return; |
|
const anchor = getSvgPositionFromPointerEvent(e); |
|
if (!anchor) return; |
|
|
|
let nodeIndex = groups.findIndex((group) => group.anchor[0] > anchor[0]); |
|
if (nodeIndex === -1) nodeIndex = groups.length; |
|
|
|
groups.splice(nodeIndex, 0, { |
|
anchor: anchor, |
|
handles: [ |
|
[anchor[0] - 0.05, anchor[1]], |
|
[anchor[0] + 0.05, anchor[1]], |
|
], |
|
}); |
|
selectedNodeIndex = nodeIndex; |
|
clampHandles(); |
|
dAttribute = recalculateSvgPath(); |
|
updateCurve(); |
|
} |
|
|
|
function setHandlePosition(anchorIndex: number, handleIndex: number, position: [number, number]) { |
|
const { anchor, handles } = groups[anchorIndex]; |
|
const otherHandle = handles[1 - handleIndex]; |
|
|
|
const handleVector = [anchor[0] - position[0], anchor[1] - position[1]]; |
|
const handleVectorLength = Math.hypot(...handleVector); |
|
const handleVectorNormalized = [handleVector[0] / handleVectorLength, handleVector[1] / handleVectorLength]; |
|
const otherHandleVectorLength = Math.hypot(anchor[0] - otherHandle[0], anchor[1] - otherHandle[1]); |
|
|
|
handles[handleIndex] = position; |
|
handles[1 - handleIndex] = [anchor[0] + handleVectorNormalized[0] * otherHandleVectorLength, anchor[1] + handleVectorNormalized[1] * otherHandleVectorLength]; |
|
} |
|
|
|
function handlePointerMove(e: PointerEvent) { |
|
if (draggedNodeIndex === undefined || draggedNodeIndex === 0 || draggedNodeIndex === groups.length - 1) return; |
|
const position = getSvgPositionFromPointerEvent(e); |
|
if (!position) return; |
|
|
|
if (draggedNodeIndex > 0) { |
|
position[0] = clamp(position[0], groups[draggedNodeIndex - 1].anchor[0], groups[draggedNodeIndex + 1].anchor[0]); |
|
|
|
const group = groups[draggedNodeIndex]; |
|
group.handles = [ |
|
[group.handles[0][0] + position[0] - group.anchor[0], group.handles[0][1] + position[1] - group.anchor[1]], |
|
[group.handles[1][0] + position[0] - group.anchor[0], group.handles[1][1] + position[1] - group.anchor[1]], |
|
]; |
|
group.anchor = position; |
|
} else { |
|
if (selectedNodeIndex === undefined) return; |
|
setHandlePosition(selectedNodeIndex, -draggedNodeIndex - 1, position); |
|
|
|
const group = groups[selectedNodeIndex]; |
|
if (group.handles[0][0] > group.anchor[0]) { |
|
group.handles = [group.handles[1], group.handles[0]]; |
|
draggedNodeIndex = -3 - draggedNodeIndex; |
|
} |
|
} |
|
|
|
clampHandles(); |
|
dAttribute = recalculateSvgPath(); |
|
updateCurve(); |
|
} |
|
</script> |
|
|
|
<LayoutRow class={"curve-input"} classes={{ disabled, ...classes }} style={styleName} {styles} {tooltip}> |
|
<svg viewBox="0 0 1 1" on:pointermove={handlePointerMove} on:pointerup={handlePointerUp}> |
|
{#each { length: GRID_SIZE - 1 } as _, i} |
|
<path class="grid" d={`M 0 ${(i + 1) / GRID_SIZE} L 1 ${(i + 1) / GRID_SIZE}`} /> |
|
<path class="grid" d={`M ${(i + 1) / GRID_SIZE} 0 L ${(i + 1) / GRID_SIZE} 1`} /> |
|
{/each} |
|
<path class="curve" d={dAttribute} /> |
|
{#if selectedNodeIndex !== undefined} |
|
{@const group = groups[selectedNodeIndex]} |
|
{#each [0, 1] as i} |
|
<path d={`M ${group.anchor[0]} ${1 - group.anchor[1]} L ${group.handles[i][0]} ${1 - group.handles[i][1]}`} class="handle-line" /> |
|
<circle cx={group.handles[i][0]} cy={1 - group.handles[i][1]} class="manipulator handle" r="0.02" on:pointerdown={(e) => handleManipulatorPointerDown(e, -i - 1)} /> |
|
{/each} |
|
{/if} |
|
{#each groups as group, i} |
|
<circle cx={group.anchor[0]} cy={1 - group.anchor[1]} class="manipulator" r="0.02" on:pointerdown={(e) => handleManipulatorPointerDown(e, i)} /> |
|
{/each} |
|
</svg> |
|
<slot /> |
|
</LayoutRow> |
|
|
|
<style lang="scss" global> |
|
.curve-input { |
|
background: var(--color-1-nearblack); |
|
display: flex; |
|
position: relative; |
|
min-width: calc(2 * var(--widget-height)); |
|
max-width: calc(8 * var(--widget-height)); |
|
|
|
.grid { |
|
stroke: var(--color-5-dullgray); |
|
stroke-width: 0.005; |
|
pointer-events: none; |
|
} |
|
|
|
.curve { |
|
fill: none; |
|
stroke: var(--color-e-nearwhite); |
|
stroke-width: 0.01; |
|
} |
|
|
|
.manipulator { |
|
fill: var(--color-1-nearblack); |
|
stroke: var(--color-e-nearwhite); |
|
stroke-width: 0.01; |
|
|
|
&:hover { |
|
fill: var(--color-f-white); |
|
stroke: var(--color-f-white); |
|
} |
|
|
|
&.handle { |
|
fill: var(--color-1-nearblack); |
|
stroke: var(--color-c-brightgray); |
|
|
|
&:hover { |
|
fill: var(--color-a-softgray); |
|
stroke: var(--color-a-softgray); |
|
} |
|
} |
|
} |
|
|
|
.handle-line { |
|
stroke: var(--color-5-dullgray); |
|
stroke-width: 0.005; |
|
pointer-events: none; |
|
} |
|
} |
|
</style> |
|
|