Skip to content
Merged
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
3 changes: 3 additions & 0 deletions apps/desktop/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
}
],
"shift/no-raw-contour-access": "error",
"shift/prefer-instance-method-on-glyph": "error",
"shift/no-raw-segment-parse": "error",
"shift/no-get-signal-value-method": "error",
"shift/no-unbranded-ids": "error",
"shift/no-snapshot-in-domain": "error",
"shift/no-raw-point-type-check": "error",
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/src/components/EditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ export const EditorView: FC<EditorViewProps> = ({ glyphId }) => {
const debug = useDebugSafe();
const containerRef = useRef<HTMLDivElement>(null);

const [cursorStyle, setCursorStyle] = useState(() => editor.getCursor());
const [cursorStyle, setCursorStyle] = useState(() => editor.cursor);

useEffect(() => {
const fx = effect(() => {
setCursorStyle(editor.getCursor());
setCursorStyle(editor.cursor);
});
return () => fx.dispose();
}, [editor]);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/components/GlyphSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { BooleanOps } from "./BooleanOps";
export const GlyphSidebar = () => {
const editor = getEditor();
const { familyName } = editor.font.metadata;
const zoom = useSignalState(editor.zoom);
const zoom = useSignalState(editor.$zoom);
const zoomPercent = Math.round(zoom * 100);

const [hasPointSelection, setHasPointSelection] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useSignalText } from "@/hooks/useSignalText";
import { getEditor } from "@/store/store";
import { Separator } from "@shift/ui";
import { effect } from "@/lib/reactive";
import { Glyphs } from "@shift/font";

function formatCoords(x: number, y: number): string {
return `(${Math.round(x)}, ${Math.round(y)})`;
Expand Down Expand Up @@ -33,7 +32,7 @@ export function DebugPanel() {
const glyph = editor.glyph.value;
if (!glyph) return "0";

return `${Glyphs.getAllPoints(glyph).length}`;
return `${glyph.allPoints.length}`;
});

const glyphMemoryRef = useSignalText(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
import { formatCodepointAsUPlus } from "@/lib/utils/unicode";
import { SidebarSection } from "./SidebarSection";
import { EditableSidebarInput } from "./EditableSidebarInput";
import Glyph from "@/assets/sidebar-right/placeholder-glyph.svg";
import PlaceholderGlyph from "@/assets/sidebar-right/placeholder-glyph.svg";
import { getEditor } from "@/store/store";
import { useSignalState } from "@/lib/reactive";
import { getGlyphInfo } from "@/store/glyphInfo";
import { deriveGlyphSidebearings, roundSidebearing } from "@/lib/editor/sidebearings";
import { useGlyphSidebearings } from "@/hooks/useGlyphSidebearings";
import { useGlyphXAdvance } from "@/hooks/useGlyphXAdvance";

export const GlyphSection = () => {
const editor = getEditor();
const glyph = useSignalState(editor.glyph);
const sidebearings = useGlyphSidebearings();
const xAdvance = useGlyphXAdvance();
const glyphInfo = getGlyphInfo();

if (!glyph) return null;

const unicode = formatCodepointAsUPlus(glyph.unicode);
const sidebearings = deriveGlyphSidebearings(glyph);
const xAdvance = glyph.xAdvance;

const lsb = roundSidebearing(sidebearings.lsb);
const rsb = roundSidebearing(sidebearings.rsb);

const lsb = sidebearings.lsb === null ? null : Math.round(sidebearings.lsb);
const rsb = sidebearings.rsb === null ? null : Math.round(sidebearings.rsb);
const sidebearingsEnabled = lsb !== null && rsb !== null;

return (
Expand All @@ -38,7 +37,7 @@ export const GlyphSection = () => {
onValueChange={(next) => editor.setLeftSidebearing(next)}
/>
<div className="px-2">
<Glyph />
<PlaceholderGlyph />
</div>
<EditableSidebarInput
label="RSB"
Expand All @@ -54,10 +53,9 @@ export const GlyphSection = () => {
className="text-center"
value={xAdvance}
onValueChange={(width) => editor.setXAdvance(width)}
disabled={!glyph}
/>
</div>
<div className="font-sans mt-2 text-sm">{glyphInfo.getGlyphName(glyph?.unicode ?? 0)}</div>
<div className="font-sans mt-2 text-sm">{glyphInfo.getGlyphName(glyph.unicode)}</div>
</main>
</SidebarSection>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { SidebarSection } from "./SidebarSection";
import { TransformGrid } from "./TransformGrid";
import { EditableSidebarInput, type EditableSidebarInputHandle } from "./EditableSidebarInput";
Expand All @@ -7,17 +7,12 @@ import { getEditor } from "@/store/store";
import { anchorToPoint } from "@/lib/transform/anchor";
import { Bounds } from "@shift/geo";
import ScaleIcon from "@/assets/sidebar-right/scale.svg";
import { useSignalState } from "@/lib/reactive";
import { useSelectionBounds } from "@/hooks/useSelectionBounds";

export const ScaleSection = () => {
const editor = getEditor();
const { anchor, setAnchor } = useTransformOrigin();
const glyph = useSignalState(editor.glyph);
const selectedPointIds = useSignalState(editor.selection.$pointIds);
const selectionBounds = useMemo(
() => editor.getSelectionBounds(),
[editor, glyph, selectedPointIds],
);
const selectionBounds = useSelectionBounds();

const widthRef = useRef<EditableSidebarInputHandle>(null);
const heightRef = useRef<EditableSidebarInputHandle>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useTransformOrigin } from "@/context/TransformOriginContext";
import { getEditor } from "@/store/store";
import { anchorToPoint } from "@/lib/transform/anchor";
import { useSignalState } from "@/lib/reactive";
import { useSelectionBounds } from "@/hooks/useSelectionBounds";

import RotateIcon from "@/assets/sidebar-right/rotate.svg";
import RotateCwIcon from "@/assets/sidebar-right/rotate-cw.svg";
Expand Down Expand Up @@ -92,11 +93,7 @@ export const TransformSection = () => {
const editor = getEditor();
const { anchor } = useTransformOrigin();
const selectedPointIds = useSignalState(editor.selection.$pointIds);
const glyph = useSignalState(editor.glyph);
const selectionBounds = useMemo(
() => editor.getSelectionBounds(),
[editor, glyph, selectedPointIds],
);
const selectionBounds = useSelectionBounds();
const [rotation, setRotation] = useState(0);

const xRef = useRef<EditableSidebarInputHandle>(null);
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { GlyphSidebearings } from "@/lib/model/Glyph";
import { getEditor } from "@/store/store";
import { useSignalState, useSignalTrigger } from "@/lib/reactive";

const EMPTY_SIDEBEARINGS: GlyphSidebearings = { lsb: null, rsb: null };

/**
* Current glyph sidebearings (LSB/RSB), live-updating.
*
* Subscribes to glyph identity, contour patches, and xAdvance; pulls the
* lazy `glyph.sidebearings` getter at render time. Keeps the sidebearings
* computation out of the drag hot path.
*
* @returns `{ lsb, rsb }` — both `null` when the glyph is unavailable.
*/
export function useGlyphSidebearings(): GlyphSidebearings {
const editor = getEditor();
const glyph = useSignalState(editor.glyph);
useSignalTrigger(glyph?.$contours);
useSignalTrigger(glyph?.$xAdvance);
return glyph?.sidebearings ?? EMPTY_SIDEBEARINGS;
}
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getEditor } from "@/store/store";
import { useSignalState, useSignalTrigger } from "@/lib/reactive";

/**
* Current glyph xAdvance, live-updating. Returns `0` when no glyph is loaded.
*/
export function useGlyphXAdvance(): number {
const editor = getEditor();
const glyph = useSignalState(editor.glyph);
useSignalTrigger(glyph?.$xAdvance);
return glyph?.xAdvance ?? 0;
}
24 changes: 24 additions & 0 deletions apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Bounds } from "@shift/geo";
import { getEditor } from "@/store/store";
import { useSignalState, useSignalTrigger } from "@/lib/reactive";

/**
* Current selection bounds (axis-aligned, point-based), live-updating.
*
* Subscribes to the raw inputs that affect bounds (glyph identity, glyph
* contour patches, and selected point ids), then pulls the lazy
* `selection.bounds` getter at render time. This keeps the bounds
* computation out of the reactive hot path during drag — the compute only
* runs when React actually renders, which happens at most once per
* animation frame.
*
* @returns The current selection bounds, or `null` when the glyph is
* unavailable or nothing is selected.
*/
export function useSelectionBounds(): Bounds | null {
const editor = getEditor();
const glyph = useSignalState(editor.glyph);
useSignalTrigger(glyph?.$contours);
useSignalTrigger(editor.selection.$pointIds);
return editor.selection.bounds;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { CloseContourCommand, NudgePointsCommand, SplitSegmentCommand } from "./
import { createBridge, getAllPoints, getPointCount } from "@/testing";
import type { NativeBridge } from "@/bridge";
import type { CommandContext } from "../core";
import type { LineSegment, QuadSegment, CubicSegment } from "@/types/segments";
import { Segment } from "@/lib/model/Segment";
import type { QuadSegment, CubicSegment } from "@/types/segments";
import type { PointId } from "@shift/types";

let bridge: NativeBridge;
Expand Down Expand Up @@ -93,18 +94,18 @@ describe("NudgePointsCommand", () => {
});

describe("SplitSegmentCommand", () => {
function makeLineSegment(p1Id: PointId, p2Id: PointId): LineSegment {
function makeLineSegment(p1Id: PointId, p2Id: PointId): Segment {
const points = getAllPoints(bridge.getEditingSnapshot());
const p1 = points.find((p) => p.id === p1Id)!;
const p2 = points.find((p) => p.id === p2Id)!;

return {
return new Segment({
type: "line",
points: {
anchor1: { id: p1.id, x: p1.x, y: p1.y, pointType: "onCurve", smooth: false },
anchor2: { id: p2.id, x: p2.x, y: p2.y, pointType: "onCurve", smooth: false },
},
};
});
}

describe("line segment", () => {
Expand Down Expand Up @@ -173,7 +174,7 @@ describe("SplitSegmentCommand", () => {
anchor2: { id: p2, x: 100, y: 0, pointType: "onCurve", smooth: false },
},
};
const cmd = new SplitSegmentCommand(segment, 0.5);
const cmd = new SplitSegmentCommand(new Segment(segment), 0.5);

cmd.execute(ctx());

Expand Down Expand Up @@ -201,7 +202,7 @@ describe("SplitSegmentCommand", () => {
anchor2: { id: p2, x: 100, y: 0, pointType: "onCurve", smooth: false },
},
};
const cmd = new SplitSegmentCommand(segment, 0.5);
const cmd = new SplitSegmentCommand(new Segment(segment), 0.5);

cmd.execute(ctx());
cmd.undo(ctx());
Expand Down Expand Up @@ -233,7 +234,7 @@ describe("SplitSegmentCommand", () => {
anchor2: { id: p2, x: 100, y: 0, pointType: "onCurve", smooth: false },
},
};
const cmd = new SplitSegmentCommand(segment, 0.5);
const cmd = new SplitSegmentCommand(new Segment(segment), 0.5);

const result = cmd.execute(ctx());

Expand Down Expand Up @@ -264,7 +265,7 @@ describe("SplitSegmentCommand", () => {
anchor2: { id: p2, x: 100, y: 0, pointType: "onCurve", smooth: false },
},
};
const cmd = new SplitSegmentCommand(segment, 0.5);
const cmd = new SplitSegmentCommand(new Segment(segment), 0.5);

cmd.execute(ctx());
cmd.undo(ctx());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { PointId, ContourId, Point2D } from "@shift/types";
import { BaseCommand, type CommandContext } from "../core/Command";
import { Curve, type CubicCurve, type QuadraticCurve } from "@shift/geo";
import type { Segment, QuadSegment, CubicSegment, LineSegment } from "@/types/segments";
import { Segments as SegmentOps } from "@/lib/geo/Segments";
import { type CubicCurve, type QuadraticCurve } from "@shift/geo";
import type { LineSegment } from "@/types/segments";
import type { Segment } from "@/lib/model/Segment";

/**
* Closes the active contour, connecting the last point back to the first.
Expand Down Expand Up @@ -160,10 +160,9 @@ export class SplitSegmentCommand extends BaseCommand<PointId> {
}

#splitLine(ctx: CommandContext): PointId {
const curve = SegmentOps.toCurve(this.#segment);
const splitPoint = Curve.pointAt(curve, this.#t);
const splitPoint = this.#segment.pointAt(this.#t);

const anchor2Id = this.#segment.points.anchor2.id;
const anchor2Id = this.#segment.anchor2.id;

this.#splitPointId = ctx.glyph.insertPointBefore(anchor2Id, {
x: splitPoint.x,
Expand All @@ -177,20 +176,19 @@ export class SplitSegmentCommand extends BaseCommand<PointId> {
}

#splitQuadratic(ctx: CommandContext): PointId {
const segment = this.#segment as QuadSegment;
const curve = SegmentOps.toCurve(segment) as QuadraticCurve;
const [curveA, curveB] = Curve.splitAt(curve, this.#t) as [QuadraticCurve, QuadraticCurve];
const data = this.#segment.asQuad()!;
const [curveA, curveB] = this.#segment.splitAt(this.#t) as [QuadraticCurve, QuadraticCurve];

const cA = curveA.c;
const mid = curveA.p1;
const cB = curveB.c;

const controlId = segment.points.control.id;
const anchor2Id = segment.points.anchor2.id;
const controlId = data.points.control.id;
const anchor2Id = data.points.anchor2.id;

this.#originalPositions.set(controlId, {
x: segment.points.control.x,
y: segment.points.control.y,
x: data.points.control.x,
y: data.points.control.y,
});

this.#splitPointId = ctx.glyph.insertPointBefore(anchor2Id, {
Expand All @@ -215,26 +213,25 @@ export class SplitSegmentCommand extends BaseCommand<PointId> {
}

#splitCubic(ctx: CommandContext): PointId {
const segment = this.#segment as CubicSegment;
const curve = SegmentOps.toCurve(segment) as CubicCurve;
const [curveA, curveB] = Curve.splitAt(curve, this.#t) as [CubicCurve, CubicCurve];
const data = this.#segment.asCubic()!;
const [curveA, curveB] = this.#segment.splitAt(this.#t) as [CubicCurve, CubicCurve];

const c0A = curveA.c0;
const c1A = curveA.c1;
const mid = curveA.p1;
const c0B = curveB.c0;
const c1B = curveB.c1;

const control1Id = segment.points.control1.id;
const control2Id = segment.points.control2.id;
const control1Id = data.points.control1.id;
const control2Id = data.points.control2.id;

this.#originalPositions.set(control1Id, {
x: segment.points.control1.x,
y: segment.points.control1.y,
x: data.points.control1.x,
y: data.points.control1.y,
});
this.#originalPositions.set(control2Id, {
x: segment.points.control2.x,
y: segment.points.control2.y,
x: data.points.control2.x,
y: data.points.control2.y,
});

const c1AId = ctx.glyph.insertPointBefore(control2Id, {
Expand Down
Loading
Loading