Improve viewport rulers by tilting tick marks to align with tilted documents (#3844)

* Add support for tilted rulers

* Fix Ruler Text

* Address PR review

* Fix per review

* Code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Kulcode 2026-05-01 16:40:31 +05:30 committed by Timon
parent 4474de4662
commit 4c1974c200
4 changed files with 116 additions and 34 deletions

View File

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

View File

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

View File

@ -44,6 +44,7 @@
let rulerSpacing = 100;
let rulerInterval = 100;
let rulersVisible = true;
let rulerTilt = 0;
// Rendered SVG viewport data
let artworkSvg = "";
@ -287,11 +288,12 @@
scrollbarMultiplier = { x: multiplier[0], y: multiplier[1] };
}
export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean) {
export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean, tilt: number) {
rulerOrigin = { x: origin[0], y: origin[1] };
rulerSpacing = spacing;
rulerInterval = interval;
rulersVisible = visible;
rulerTilt = tilt;
}
// Update mouse cursor icon
@ -487,8 +489,8 @@
subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", async (data) => {
await tick();
const { origin, spacing, interval, visible } = data;
updateDocumentRulers(origin, spacing, interval, visible);
const { origin, spacing, interval, visible, tilt } = data;
updateDocumentRulers(origin, spacing, interval, visible, tilt);
});
// Update mouse cursor icon
@ -595,13 +597,29 @@
{#if rulersVisible}
<LayoutRow class="ruler-or-scrollbar top-ruler">
<LayoutCol class="ruler-corner"></LayoutCol>
<RulerInput origin={rulerOrigin.x} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Horizontal" bind:this={rulerHorizontal} />
<RulerInput
originX={rulerOrigin.x}
originY={rulerOrigin.y}
tilt={rulerTilt}
majorMarkSpacing={rulerSpacing}
numberInterval={rulerInterval}
direction="Horizontal"
bind:this={rulerHorizontal}
/>
</LayoutRow>
{/if}
<LayoutRow class="viewport-container-inner-1">
{#if rulersVisible}
<LayoutCol class="ruler-or-scrollbar">
<RulerInput origin={rulerOrigin.y} majorMarkSpacing={rulerSpacing} numberInterval={rulerInterval} direction="Vertical" bind:this={rulerVertical} />
<RulerInput
originX={rulerOrigin.x}
originY={rulerOrigin.y}
tilt={rulerTilt}
majorMarkSpacing={rulerSpacing}
numberInterval={rulerInterval}
direction="Vertical"
bind:this={rulerVertical}
/>
</LayoutCol>
{/if}
<LayoutCol class="viewport-container-inner-2" styles={{ cursor: canvasCursor }} data-viewport-container>

View File

@ -5,11 +5,14 @@
const MAJOR_MARK_THICKNESS = 16;
const MINOR_MARK_THICKNESS = 6;
const MICRO_MARK_THICKNESS = 3;
const TAU = 2 * Math.PI;
type RulerDirection = "Horizontal" | "Vertical";
export let direction: RulerDirection = "Vertical";
export let origin: number;
export let originX: number;
export let originY: number;
export let tilt: number;
export let numberInterval: number;
export let majorMarkSpacing: number;
export let minorDivisions = 5;
@ -19,62 +22,121 @@
let rulerLength = 0;
let svgBounds = { width: "0px", height: "0px" };
$: svgPath = computeSvgPath(direction, origin, majorMarkSpacing, minorDivisions, microDivisions, rulerLength);
$: svgTexts = computeSvgTexts(direction, origin, majorMarkSpacing, numberInterval, rulerLength);
type Axis = { sign: number; vec: [number, number] };
function computeSvgPath(direction: RulerDirection, origin: number, majorMarkSpacing: number, minorDivisions: number, microDivisions: number, rulerLength: number): string {
const isVertical = direction === "Vertical";
const lineDirection = isVertical ? "H" : "V";
$: axes = computeAxes(tilt);
$: isHorizontal = direction === "Horizontal";
$: trackedAxis = isHorizontal ? axes.horiz : axes.vert;
$: otherAxis = isHorizontal ? axes.vert : axes.horiz;
$: 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);
const offsetStart = mod(origin, majorMarkSpacing);
const shiftedOffsetStart = offsetStart - majorMarkSpacing;
function computeAxes(tilt: number): { horiz: Axis; vert: Axis } {
const normTilt = ((tilt % TAU) + TAU) % TAU;
const octant = Math.floor((normTilt + Math.PI / 4) / (Math.PI / 2)) % 4;
const divisions = majorMarkSpacing / minorDivisions / microDivisions;
const majorMarksFrequency = minorDivisions * microDivisions;
const [c, s] = [Math.cos(tilt), Math.sin(tilt)];
const posX: Axis = { sign: 1, vec: [c, s] };
const posY: Axis = { sign: 1, vec: [-s, c] };
const negX: Axis = { sign: -1, vec: [-c, -s] };
const negY: Axis = { sign: -1, vec: [s, -c] };
let dPathAttribute = "";
if (octant === 0) return { horiz: posX, vert: posY };
if (octant === 1) return { horiz: negY, vert: posX };
if (octant === 2) return { horiz: negX, vert: negY };
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 computeSvgPath(
direction: RulerDirection,
effectiveOrigin: number,
stretchedSpacing: number,
stretchFactor: number,
minorDivisions: number,
microDivisions: number,
rulerLength: number,
otherAxis: Axis,
): 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];
let path = "";
let i = 0;
for (let location = shiftedOffsetStart; location < rulerLength; location += divisions) {
for (let location = shiftedOffsetStart; location < rulerLength + RULER_THICKNESS; location += divisions) {
let length;
if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS;
else if (i % microDivisions === 0) length = MINOR_MARK_THICKNESS;
else if (i % adaptive.micro === 0) length = MINOR_MARK_THICKNESS;
else length = MICRO_MARK_THICKNESS;
i += 1;
const destination = Math.round(location) + 0.5;
const startPoint = isVertical ? `${RULER_THICKNESS - length},${destination}` : `${destination},${RULER_THICKNESS - length}`;
dPathAttribute += `M${startPoint}${lineDirection}${RULER_THICKNESS} `;
const [sx, sy] = direction === "Horizontal" ? [destination, syBase] : [sxBase, destination];
path += `M${sx},${sy}l${dx * length},${dy * length} `;
}
return dPathAttribute;
return path;
}
function computeSvgTexts(direction: RulerDirection, origin: number, majorMarkSpacing: number, numberInterval: number, rulerLength: number): { transform: string; text: string }[] {
function computeSvgTexts(
direction: RulerDirection,
effectiveOrigin: number,
stretchedSpacing: number,
numberInterval: number,
rulerLength: number,
trackedAxis: Axis,
otherAxis: Axis,
tilt: number,
): { transform: string; text: string }[] {
const isVertical = direction === "Vertical";
const offsetStart = mod(origin, majorMarkSpacing);
const shiftedOffsetStart = offsetStart - majorMarkSpacing;
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 svgTextCoordinates = [];
const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing;
const increments = Math.round((shiftedOffsetStart - effectiveOrigin) / stretchedSpacing);
let labelNumber = increments * numberInterval * trackedAxis.sign;
let labelNumber = (Math.ceil(-origin / majorMarkSpacing) - 1) * numberInterval;
const results: { transform: string; text: string }[] = [];
for (let location = shiftedOffsetStart; location < rulerLength; location += majorMarkSpacing) {
for (let location = shiftedOffsetStart; location < rulerLength; location += stretchedSpacing) {
const destination = Math.round(location);
const x = isVertical ? 9 : destination + 2;
const y = isVertical ? destination + 1 : 9;
const x = isVertical ? 9 : destination + 2 + tipOffsetX;
const y = isVertical ? destination + 1 + tipOffsetY : 9;
let transform = `translate(${x} ${y})`;
if (isVertical) transform += " rotate(270)";
const text = numberInterval >= 1 ? `${labelNumber}` : labelNumber.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, "");
const num = Math.abs(labelNumber) < 1e-9 ? 0 : labelNumber;
const text = numberInterval >= 1 ? `${num}` : num.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, "");
svgTextCoordinates.push({ transform, text });
results.push({ transform, text });
labelNumber += numberInterval;
labelNumber += numberInterval * trackedAxis.sign;
}
return svgTextCoordinates;
return results;
}
export function resize() {
@ -83,7 +145,7 @@
const isVertical = direction === "Vertical";
const newLength = isVertical ? rulerInput.clientHeight : rulerInput.clientWidth;
const roundedUp = (Math.floor(newLength / majorMarkSpacing) + 1) * majorMarkSpacing;
const roundedUp = (Math.floor(newLength / stretchedSpacing) + 2) * stretchedSpacing;
if (roundedUp !== rulerLength) {
rulerLength = roundedUp;