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,
visible: bool,
tilt: f64,
flip: bool,
},
UpdateDocumentScrollbars {
position: (f64, f64),

View File

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

View File

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

View File

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