Add a pointer hover marker line to the rulers (#4088)

* Add a pointer hover marker line to the rulers

* Fix rulers and pointer marker when document is flipped

* Reduce duplicate code

* Fix ruler label placement

* Performance
This commit is contained in:
Keavon Chambers 2026-05-01 15:18:01 -07:00 committed by GitHub
parent 29f6e686ee
commit 0acfd3e178
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 75 additions and 25 deletions

View File

@ -245,6 +245,7 @@ pub enum FrontendMessage {
interval: f64, interval: f64,
visible: bool, visible: bool,
tilt: f64, tilt: f64,
flip: bool,
}, },
UpdateDocumentScrollbars { UpdateDocumentScrollbars {
position: (f64, f64), position: (f64, f64),

View File

@ -844,6 +844,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
interval: ruler_interval, interval: ruler_interval,
visible: self.rulers_visible, visible: self.rulers_visible,
tilt: if self.graph_view_overlay_open { 0. } else { current_ptz.tilt() }, tilt: if self.graph_view_overlay_open { 0. } else { current_ptz.tilt() },
flip: !self.graph_view_overlay_open && current_ptz.flip,
}); });
} }
DocumentMessage::RenderScrollbars => { DocumentMessage::RenderScrollbars => {

View File

@ -45,6 +45,9 @@
let rulerInterval = 100; let rulerInterval = 100;
let rulersVisible = true; let rulersVisible = true;
let rulerTilt = 0; let rulerTilt = 0;
let rulerFlip = false;
let rulerCursorPosition: { x: number; y: number } | undefined;
let viewportBounds: DOMRect | undefined;
// Rendered SVG viewport data // Rendered SVG viewport data
let artworkSvg = ""; let artworkSvg = "";
@ -288,12 +291,17 @@
scrollbarMultiplier = { x: multiplier[0], y: multiplier[1] }; scrollbarMultiplier = { x: multiplier[0], y: multiplier[1] };
} }
export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean, tilt: number) { export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean, tilt: number, flip: boolean) {
rulerOrigin = { x: origin[0], y: origin[1] }; rulerOrigin = { x: origin[0], y: origin[1] };
rulerSpacing = spacing; rulerSpacing = spacing;
rulerInterval = interval; rulerInterval = interval;
rulersVisible = visible; rulersVisible = visible;
rulerTilt = tilt; rulerTilt = tilt;
rulerFlip = flip;
}
function updateRulerCursorPosition(e: PointerEvent) {
if (viewportBounds) rulerCursorPosition = { x: e.clientX - viewportBounds.left, y: e.clientY - viewportBounds.top };
} }
// Update mouse cursor icon // Update mouse cursor icon
@ -416,6 +424,7 @@
canvasHeight = Math.ceil(parseFloat(getComputedStyle(viewport).height)); canvasHeight = Math.ceil(parseFloat(getComputedStyle(viewport).height));
devicePixelRatio = window.devicePixelRatio || 1; devicePixelRatio = window.devicePixelRatio || 1;
viewportBounds = viewport.getBoundingClientRect();
// Resize the rulers // Resize the rulers
rulerHorizontal?.resize(); rulerHorizontal?.resize();
@ -489,8 +498,8 @@
subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", async (data) => { subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", async (data) => {
await tick(); await tick();
const { origin, spacing, interval, visible, tilt } = data; const { origin, spacing, interval, visible, tilt, flip } = data;
updateDocumentRulers(origin, spacing, interval, visible, tilt); updateDocumentRulers(origin, spacing, interval, visible, tilt, flip);
}); });
// Update mouse cursor icon // Update mouse cursor icon
@ -601,9 +610,11 @@
originX={rulerOrigin.x} originX={rulerOrigin.x}
originY={rulerOrigin.y} originY={rulerOrigin.y}
tilt={rulerTilt} tilt={rulerTilt}
flip={rulerFlip}
majorMarkSpacing={rulerSpacing} majorMarkSpacing={rulerSpacing}
numberInterval={rulerInterval} numberInterval={rulerInterval}
direction="Horizontal" direction="Horizontal"
cursorPosition={rulerCursorPosition}
bind:this={rulerHorizontal} bind:this={rulerHorizontal}
/> />
</LayoutRow> </LayoutRow>
@ -615,9 +626,11 @@
originX={rulerOrigin.x} originX={rulerOrigin.x}
originY={rulerOrigin.y} originY={rulerOrigin.y}
tilt={rulerTilt} tilt={rulerTilt}
flip={rulerFlip}
majorMarkSpacing={rulerSpacing} majorMarkSpacing={rulerSpacing}
numberInterval={rulerInterval} numberInterval={rulerInterval}
direction="Vertical" direction="Vertical"
cursorPosition={rulerCursorPosition}
bind:this={rulerVertical} bind:this={rulerVertical}
/> />
</LayoutCol> </LayoutCol>
@ -664,6 +677,8 @@
class:viewport={!$appWindow.viewportHolePunch} class:viewport={!$appWindow.viewportHolePunch}
class:viewport-transparent={$appWindow.viewportHolePunch} class:viewport-transparent={$appWindow.viewportHolePunch}
on:pointerdown={(e) => canvasPointerDown(e)} on:pointerdown={(e) => canvasPointerDown(e)}
on:pointermove={updateRulerCursorPosition}
on:pointerleave={() => (rulerCursorPosition = undefined)}
bind:this={viewport} bind:this={viewport}
data-viewport data-viewport
> >

View File

@ -13,10 +13,12 @@
export let originX: number; export let originX: number;
export let originY: number; export let originY: number;
export let tilt: number; export let tilt: number;
export let flip: boolean = false;
export let numberInterval: number; export let numberInterval: number;
export let majorMarkSpacing: number; export let majorMarkSpacing: number;
export let minorDivisions = 5; export let minorDivisions = 5;
export let microDivisions = 2; export let microDivisions = 2;
export let cursorPosition: { x: number; y: number } | undefined = undefined;
let rulerInput: HTMLDivElement | undefined; let rulerInput: HTMLDivElement | undefined;
let rulerLength = 0; let rulerLength = 0;
@ -28,11 +30,13 @@
$: isHorizontal = direction === "Horizontal"; $: isHorizontal = direction === "Horizontal";
$: trackedAxis = isHorizontal ? axes.horiz : axes.vert; $: trackedAxis = isHorizontal ? axes.horiz : axes.vert;
$: otherAxis = isHorizontal ? axes.vert : axes.horiz; $: otherAxis = isHorizontal ? axes.vert : axes.horiz;
$: crossAxisDirection = flipVector(otherAxis.vec, flip);
$: stretchFactor = 1 / Math.max(Math.abs(isHorizontal ? trackedAxis.vec[0] : trackedAxis.vec[1]), 1e-10); $: stretchFactor = 1 / Math.max(Math.abs(isHorizontal ? trackedAxis.vec[0] : trackedAxis.vec[1]), 1e-10);
$: stretchedSpacing = majorMarkSpacing * stretchFactor; $: stretchedSpacing = majorMarkSpacing * stretchFactor;
$: effectiveOrigin = computeEffectiveOrigin(direction, originX, originY, otherAxis); $: effectiveOrigin = projectOntoRuler(direction, originX, originY, crossAxisDirection);
$: svgPath = computeSvgPath(direction, effectiveOrigin, stretchedSpacing, stretchFactor, minorDivisions, microDivisions, rulerLength, otherAxis); $: svgPath = computeSvgPath(direction, effectiveOrigin, stretchedSpacing, stretchFactor, minorDivisions, microDivisions, rulerLength, crossAxisDirection);
$: svgTexts = computeSvgTexts(direction, effectiveOrigin, stretchedSpacing, numberInterval, rulerLength, trackedAxis, otherAxis, tilt); $: svgTexts = computeSvgTexts(direction, effectiveOrigin, stretchedSpacing, numberInterval, rulerLength, trackedAxis, crossAxisDirection);
$: cursorIndicatorPath = computeCursorIndicator(direction, cursorPosition, crossAxisDirection);
function computeAxes(tilt: number): { horiz: Axis; vert: Axis } { function computeAxes(tilt: number): { horiz: Axis; vert: Axis } {
const normTilt = ((tilt % TAU) + TAU) % TAU; const normTilt = ((tilt % TAU) + TAU) % TAU;
@ -50,13 +54,24 @@
return { horiz: posY, vert: negX }; return { horiz: posY, vert: negX };
} }
function computeEffectiveOrigin(direction: RulerDirection, ox: number, oy: number, otherAxis: Axis): number { function flipVector(vec: [number, number], flipped: boolean): [number, number] {
const [vx, vy] = otherAxis.vec; return flipped ? [-vec[0], vec[1]] : vec;
if (direction === "Horizontal") { }
return Math.abs(vy) < 1e-10 ? ox : ox - oy * (vx / vy);
} else { function projectOntoRuler(direction: RulerDirection, x: number, y: number, vec: [number, number]): number {
return Math.abs(vx) < 1e-10 ? oy : oy - ox * (vy / vx); const [vx, vy] = vec;
} if (direction === "Horizontal") return Math.abs(vy) < 1e-10 ? x : x - y * (vx / vy);
return Math.abs(vx) < 1e-10 ? y : y - x * (vy / vx);
}
function tickMarkGeometry(direction: RulerDirection, vx: number, vy: number): { dx: number; dy: number; sxBase: number; syBase: number } {
const reversal = direction === "Horizontal" ? (vy > 0 ? -1 : 1) : vx > 0 ? -1 : 1;
return {
dx: vx * reversal,
dy: vy * reversal,
sxBase: direction === "Horizontal" ? 0 : RULER_THICKNESS,
syBase: direction === "Horizontal" ? RULER_THICKNESS : 0,
};
} }
function computeSvgPath( function computeSvgPath(
@ -67,17 +82,14 @@
minorDivisions: number, minorDivisions: number,
microDivisions: number, microDivisions: number,
rulerLength: number, rulerLength: number,
otherAxis: Axis, crossAxisDirection: [number, number],
): string { ): string {
const adaptive = stretchFactor > 1.3 ? { minor: minorDivisions, micro: 1 } : { minor: minorDivisions, micro: microDivisions }; const adaptive = stretchFactor > 1.3 ? { minor: minorDivisions, micro: 1 } : { minor: minorDivisions, micro: microDivisions };
const divisions = stretchedSpacing / adaptive.minor / adaptive.micro; const divisions = stretchedSpacing / adaptive.minor / adaptive.micro;
const majorMarksFrequency = adaptive.minor * adaptive.micro; const majorMarksFrequency = adaptive.minor * adaptive.micro;
const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing; const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing;
const [vx, vy] = otherAxis.vec; const { dx, dy, sxBase, syBase } = tickMarkGeometry(direction, crossAxisDirection[0], crossAxisDirection[1]);
const flip = direction === "Horizontal" ? (vy > 0 ? -1 : 1) : vx > 0 ? -1 : 1;
const [dx, dy] = [vx * flip, vy * flip];
const [sxBase, syBase] = direction === "Horizontal" ? [0, RULER_THICKNESS] : [RULER_THICKNESS, 0];
let path = ""; let path = "";
let i = 0; let i = 0;
@ -103,16 +115,15 @@
numberInterval: number, numberInterval: number,
rulerLength: number, rulerLength: number,
trackedAxis: Axis, trackedAxis: Axis,
otherAxis: Axis, crossAxisDirection: [number, number],
tilt: number,
): { transform: string; text: string }[] { ): { transform: string; text: string }[] {
const isVertical = direction === "Vertical"; const isVertical = direction === "Vertical";
const [vx, vy] = otherAxis.vec; const { dx: tipDx, dy: tipDy } = tickMarkGeometry(direction, crossAxisDirection[0], crossAxisDirection[1]);
const flip = isVertical ? (vx > 0 ? -1 : 1) : vy > 0 ? -1 : 1; const forwardTip = isVertical ? -tipDy : tipDx;
const tiltScale = tilt >= 0 ? 1 : 0.5; const tiltScale = forwardTip >= 0 ? 1 : 0.5;
const tipOffsetX = vx * flip * MAJOR_MARK_THICKNESS * tiltScale; const tipOffsetX = tipDx * MAJOR_MARK_THICKNESS * tiltScale;
const tipOffsetY = vy * flip * MAJOR_MARK_THICKNESS * tiltScale; const tipOffsetY = tipDy * MAJOR_MARK_THICKNESS * tiltScale;
const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing; const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing;
const increments = Math.round((shiftedOffsetStart - effectiveOrigin) / stretchedSpacing); const increments = Math.round((shiftedOffsetStart - effectiveOrigin) / stretchedSpacing);
@ -139,6 +150,21 @@
return results; return results;
} }
function computeCursorIndicator(direction: RulerDirection, cursor: { x: number; y: number } | undefined, crossAxisDirection: [number, number]): string {
if (cursor === undefined) return "";
const projected = projectOntoRuler(direction, cursor.x, cursor.y, crossAxisDirection);
const { dx, dy, sxBase, syBase } = tickMarkGeometry(direction, crossAxisDirection[0], crossAxisDirection[1]);
// Scale the line so it spans the full ruler bar thickness
const thicknessComponent = Math.abs(direction === "Horizontal" ? dy : dx);
const length = thicknessComponent < 1e-10 ? RULER_THICKNESS : RULER_THICKNESS / thicknessComponent;
const destination = Math.round(projected) + 0.5;
const [sx, sy] = direction === "Horizontal" ? [destination, syBase] : [sxBase, destination];
return `M${sx},${sy}l${dx * length},${dy * length}`;
}
export function resize() { export function resize() {
if (!rulerInput) return; if (!rulerInput) return;
@ -170,6 +196,9 @@
{#each svgTexts as svgText} {#each svgTexts as svgText}
<text transform={svgText.transform}>{svgText.text}</text> <text transform={svgText.transform}>{svgText.text}</text>
{/each} {/each}
{#if cursorIndicatorPath}
<path class="cursor-indicator" d={cursorIndicatorPath} />
{/if}
</svg> </svg>
</div> </div>
@ -201,6 +230,10 @@
path { path {
stroke-width: 1px; stroke-width: 1px;
stroke: var(--color-5-dullgray); stroke: var(--color-5-dullgray);
&.cursor-indicator {
stroke: var(--color-8-uppergray);
}
} }
text { text {