diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 7150f13b9a..5cfc6ad9ed 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -245,6 +245,7 @@ pub enum FrontendMessage { interval: f64, visible: bool, tilt: f64, + flip: bool, }, UpdateDocumentScrollbars { position: (f64, f64), diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 422f21880a..7a0d406596 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -844,6 +844,7 @@ impl MessageHandler> 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 => { diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 51f771f25e..b1a051866c 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -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} /> @@ -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} /> @@ -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 > diff --git a/frontend/src/components/widgets/inputs/RulerInput.svelte b/frontend/src/components/widgets/inputs/RulerInput.svelte index 8a90c5c2c7..b79bb5608b 100644 --- a/frontend/src/components/widgets/inputs/RulerInput.svelte +++ b/frontend/src/components/widgets/inputs/RulerInput.svelte @@ -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} {svgText.text} {/each} + {#if cursorIndicatorPath} + + {/if} @@ -201,6 +230,10 @@ path { stroke-width: 1px; stroke: var(--color-5-dullgray); + + &.cursor-indicator { + stroke: var(--color-8-uppergray); + } } text {