Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ Create polylines made of straight or curved segments.

**Editing:**

- **Toggle corner/curve:** Click a point to switch between corner and smooth curve (handles will appear).
- **Adjust curvature:**: Drag the handles to refine the shape.
- **Delete points:** Press `Del` or `Backspace` while points are selected to remove those points from the path.
- **Toggle corner/curve:** Hold `Alt` (or `Option` on Mac) and click a point to switch between corner and smooth curve (handles will appear when a single point is selected).
- **Adjust curvature:** Drag the handles to refine the shape.
- **Sharp corner between curves:** Hold `Alt` (or `Option` on Mac) while dragging a handle to move it independently.
- **Re-link handles:** Double-click the point to snap handles back together.

Expand Down
79 changes: 53 additions & 26 deletions src/path/PathEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
let visibleMidpoint: number | undefined;
let isHandleHovered = false;
let lastHandleClick: number | null = null;
let selectedCorner: number | null = null;
let selectedCorners: number[] = [];

let isAltPressed = false;

Expand Down Expand Up @@ -69,6 +69,11 @@

/** Determine visible midpoint, if any **/
const onPointerMove = (evt: PointerEvent) => {
if (selectedCorners.length > 0 || !midpoints.some(m => m.visible)) {
visibleMidpoint = undefined;
return;
}

const [px, py] = transform.elementToImage(evt.offsetX, evt.offsetY);

const getDistSq = (pt: number[]) =>
Expand Down Expand Up @@ -96,7 +101,22 @@
visibleMidpoint = undefined;
}

const onShapePointerUp = () => selectedCorner = null;
/**
* SVG element keeps losing focus when interacting with
* shapes–this function refocuses.
*/
const reclaimFocus = () => {
if (document.activeElement !== svgEl)
svgEl.focus();
}

/**
* De-selects all corners and reclaims focus.
*/
const onShapePointerUp = () => {
selectedCorners = [];
reclaimFocus();
}

/**
* Updates state, waiting for potential click.
Expand All @@ -111,30 +131,37 @@
}

/** Selection handling logic **/
const onHandlePointerUp = (idx: number) => () => {
const onHandlePointerUp = (idx: number) => (evt: PointerEvent) => {
if (!lastHandleClick) return;

// Drag, not click
if (performance.now() - lastHandleClick > CLICK_THRESHOLD) return;

// Click on a CORNER instantly selects and converts to curve
const { type } = geom.points[idx];

if (type === 'CORNER') {
selectedCorner = idx;
const isSelected = selectedCorners.includes(idx);

// Clicking on a handle with alt key pressed toggles between corner/curve
if (isAltPressed) {
const polyline = togglePolylineCorner(shape, idx, viewportScale);
dispatch('change', polyline);
} else {
const isSelected = selectedCorner === idx;
if (isSelected) {
// If already selected, toggle to corner
const polyline = togglePolylineCorner(shape, idx,viewportScale);
dispatch('change', polyline);
} else {
// Just select
selectedCorner = idx;

// Ensure the toggled corner is selected, and deselect others
if (!isSelected || selectedCorners.length > 1) {
selectedCorners = [idx];
}
} else if (evt.metaKey || evt.ctrlKey || evt.shiftKey) {
if (isSelected)
selectedCorners = selectedCorners.filter(i => i !== idx);
else
selectedCorners = [...selectedCorners, idx];
} else {
if (isSelected && selectedCorners.length > 1)
// Keep selected, de-select others
selectedCorners = [idx]
else if (isSelected)
// De-select
selectedCorners = [];
else
selectedCorners = [idx];
}
}

Expand Down Expand Up @@ -292,7 +319,7 @@
const onAddPoint = (midpointIdx: number) => async (evt: PointerEvent) => {
evt.stopPropagation();

selectedCorner = null;
// selectedCorner = null;

const points = [
...geom.points.slice(0, midpointIdx + 1),
Expand Down Expand Up @@ -328,13 +355,11 @@ const onAddPoint = (midpointIdx: number) => async (evt: PointerEvent) => {
}

const onDeleteSelected = () => {
if (selectedCorner === null) return;

// Open path needs 2 points min, closed path needs 3
const minLen = geom.closed ? 4 : 3;
if (geom.points.length < minLen) return;
const minLen = geom.closed ? 3 : 2;
if (geom.points.length - selectedCorners.length < minLen) return;

const points = geom.points.filter((_, i) => i !== selectedCorner);
const points = geom.points.filter((_, i) => !selectedCorners.includes(i));
const bounds = boundsFromPoints(approximateAsPolygon(points, geom.closed));

dispatch('change', {
Expand All @@ -346,7 +371,7 @@ const onAddPoint = (midpointIdx: number) => async (evt: PointerEvent) => {
}
});

selectedCorner = null;
selectedCorners = [];
}

onMount(() => {
Expand Down Expand Up @@ -419,8 +444,9 @@ const onAddPoint = (midpointIdx: number) => async (evt: PointerEvent) => {
d={d} />
</g>

<!-- Bezier handles only on the selected corner -->
{#if selectedCorner !== null}
<!-- Bezier handles only when a single corner is selected -->
{#if selectedCorners.length === 1}
{@const selectedCorner = selectedCorners[0]}
{@const corner = geom.points[selectedCorner]}
{#if corner.type === 'CURVE'}
{#if corner.inHandle}
Expand All @@ -447,6 +473,7 @@ const onAddPoint = (midpointIdx: number) => async (evt: PointerEvent) => {
x={pt.point[0]}
y={pt.point[1]}
scale={viewportScale}
selected={selectedCorners.includes(idx)}
on:dblclick={onDoubleClick(idx)}
on:pointerenter={onEnterHandle}
on:pointerleave={onLeaveHandle}
Expand Down