Implement ruler bounds visualization for the AABB of selected layers (#4090)

* Implement ruler bounds visualization for the AABB of selected layers

* Code review fixes
This commit is contained in:
Keavon Chambers 2026-05-01 18:23:51 -07:00 committed by GitHub
parent eddd742f9b
commit 42f4c1396b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 127 additions and 37 deletions

View File

@ -246,6 +246,8 @@ pub enum FrontendMessage {
visible: bool, visible: bool,
tilt: f64, tilt: f64,
flip: bool, flip: bool,
#[serde(rename = "selectionQuad")]
selection_quad: Option<[(f64, f64); 4]>,
}, },
UpdateDocumentScrollbars { UpdateDocumentScrollbars {
position: (f64, f64), position: (f64, f64),

View File

@ -838,6 +838,23 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
let ruler_spacing = ruler_interval * ruler_scale; let ruler_spacing = ruler_interval * ruler_scale;
// Compute the selection bounding box as 4 viewport-space corners preserving orientation
let selection_quad = if !self.graph_view_overlay_open {
self.network_interface
.selected_nodes()
.0
.iter()
.filter(|node| self.network_interface.is_layer(node, &[]))
.filter_map(|layer| self.metadata().bounding_box_document(LayerNodeIdentifier::new(*layer, &self.network_interface)))
.reduce(Quad::combine_bounds)
.map(|[min, max]| {
let corners = [DVec2::new(min.x, min.y), DVec2::new(max.x, min.y), DVec2::new(max.x, max.y), DVec2::new(min.x, max.y)];
corners.map(|c| document_to_viewport.transform_point2(c).into())
})
} else {
None
};
responses.add(FrontendMessage::UpdateDocumentRulers { responses.add(FrontendMessage::UpdateDocumentRulers {
origin: ruler_origin.into(), origin: ruler_origin.into(),
spacing: ruler_spacing, spacing: ruler_spacing,
@ -845,6 +862,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
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, flip: !self.graph_view_overlay_open && current_ptz.flip,
selection_quad,
}); });
} }
DocumentMessage::RenderScrollbars => { DocumentMessage::RenderScrollbars => {

View File

@ -144,6 +144,8 @@
--color-data-invalid: #d6536e; // Same as --color-error-red --color-data-invalid: #d6536e; // Same as --color-error-red
--color-data-invalid-dim: #a7324a; --color-data-invalid-dim: #a7324a;
--color-overlay-blue: #00a8ff;
--color-none: white; --color-none: white;
--color-none-repeat: no-repeat; --color-none-repeat: no-repeat;
--color-none-position: center center; --color-none-position: center center;

View File

@ -47,6 +47,7 @@
let rulerTilt = 0; let rulerTilt = 0;
let rulerFlip = false; let rulerFlip = false;
let rulerCursorPosition: { x: number; y: number } | undefined; let rulerCursorPosition: { x: number; y: number } | undefined;
let rulerSelectionQuad: [number, number][] | undefined;
let viewportBounds: DOMRect | undefined; let viewportBounds: DOMRect | undefined;
// Rendered SVG viewport data // Rendered SVG viewport data
@ -291,13 +292,14 @@
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, flip: boolean) { export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean, tilt: number, flip: boolean, selectionQuad: [number, number][] | undefined) {
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; rulerFlip = flip;
rulerSelectionQuad = selectionQuad;
} }
function updateRulerCursorPosition(e: PointerEvent) { function updateRulerCursorPosition(e: PointerEvent) {
@ -498,8 +500,8 @@
subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", async (data) => { subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", async (data) => {
await tick(); await tick();
const { origin, spacing, interval, visible, tilt, flip } = data; const { origin, spacing, interval, visible, tilt, flip, selectionQuad } = data;
updateDocumentRulers(origin, spacing, interval, visible, tilt, flip); updateDocumentRulers(origin, spacing, interval, visible, tilt, flip, selectionQuad || undefined);
}); });
// Update mouse cursor icon // Update mouse cursor icon
@ -615,6 +617,7 @@
numberInterval={rulerInterval} numberInterval={rulerInterval}
direction="Horizontal" direction="Horizontal"
cursorPosition={rulerCursorPosition} cursorPosition={rulerCursorPosition}
selectionQuad={rulerSelectionQuad}
bind:this={rulerHorizontal} bind:this={rulerHorizontal}
/> />
</LayoutRow> </LayoutRow>
@ -631,6 +634,7 @@
numberInterval={rulerInterval} numberInterval={rulerInterval}
direction="Vertical" direction="Vertical"
cursorPosition={rulerCursorPosition} cursorPosition={rulerCursorPosition}
selectionQuad={rulerSelectionQuad}
bind:this={rulerVertical} bind:this={rulerVertical}
/> />
</LayoutCol> </LayoutCol>

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
const SELECTION_ENDPOINT_SIZE = 5;
const RULER_THICKNESS = 16; const RULER_THICKNESS = 16;
const MAJOR_MARK_THICKNESS = 16; const MAJOR_MARK_THICKNESS = 16;
const MINOR_MARK_THICKNESS = 6; const MINOR_MARK_THICKNESS = 6;
@ -19,6 +20,7 @@
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; export let cursorPosition: { x: number; y: number } | undefined = undefined;
export let selectionQuad: [number, number][] | undefined = undefined;
let rulerInput: HTMLDivElement | undefined; let rulerInput: HTMLDivElement | undefined;
let rulerLength = 0; let rulerLength = 0;
@ -37,6 +39,7 @@
$: svgPath = computeSvgPath(direction, effectiveOrigin, stretchedSpacing, stretchFactor, minorDivisions, microDivisions, rulerLength, crossAxisDirection); $: svgPath = computeSvgPath(direction, effectiveOrigin, stretchedSpacing, stretchFactor, minorDivisions, microDivisions, rulerLength, crossAxisDirection);
$: svgTexts = computeSvgTexts(direction, effectiveOrigin, stretchedSpacing, numberInterval, rulerLength, trackedAxis, crossAxisDirection); $: svgTexts = computeSvgTexts(direction, effectiveOrigin, stretchedSpacing, numberInterval, rulerLength, trackedAxis, crossAxisDirection);
$: cursorIndicatorPath = computeCursorIndicator(direction, cursorPosition, crossAxisDirection); $: cursorIndicatorPath = computeCursorIndicator(direction, cursorPosition, crossAxisDirection);
$: selectionExtent = computeSelectionExtent(direction, selectionQuad, 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;
@ -165,6 +168,14 @@
return `M${sx},${sy}l${dx * length},${dy * length}`; return `M${sx},${sy}l${dx * length},${dy * length}`;
} }
function computeSelectionExtent(direction: RulerDirection, quad: [number, number][] | undefined, crossAxisDirection: [number, number]): { min: number; max: number } | undefined {
if (!quad || quad.length === 0) return undefined;
const projected = quad.map(([x, y]) => projectOntoRuler(direction, x, y, crossAxisDirection));
return { min: Math.min(...projected), max: Math.max(...projected) };
}
export function resize() { export function resize() {
if (!rulerInput) return; if (!rulerInput) return;
@ -190,56 +201,109 @@
onMount(resize); onMount(resize);
</script> </script>
<div class={`ruler-input ${direction.toLowerCase()}`} bind:this={rulerInput}> <div class="ruler-input">
<svg style:width={svgBounds.width} style:height={svgBounds.height}> <div class={`ruler-area ${direction === "Horizontal" ? "horizontal" : "vertical"}`} bind:this={rulerInput}>
<path d={svgPath} /> <svg style:width={svgBounds.width} style:height={svgBounds.height}>
{#each svgTexts as svgText} <path d={svgPath} />
<text transform={svgText.transform}>{svgText.text}</text> {#each svgTexts as svgText}
{/each} <text transform={svgText.transform}>{svgText.text}</text>
{#if cursorIndicatorPath} {/each}
<path class="cursor-indicator" d={cursorIndicatorPath} /> {#if cursorIndicatorPath}
{/if} <path class="cursor-indicator" d={cursorIndicatorPath} />
</svg> {/if}
</svg>
</div>
{#if selectionExtent}
{@const isVertical = direction === "Vertical"}
{@const minPos = Math.round(selectionExtent.min)}
{@const maxPos = Math.round(selectionExtent.max)}
{@const half = Math.floor(SELECTION_ENDPOINT_SIZE / 2)}
{@const overlap = Math.ceil(SELECTION_ENDPOINT_SIZE / 2)}
<div class="selection-overlay-container" style:width={isVertical ? `${RULER_THICKNESS + overlap}px` : "100%"} style:height={isVertical ? "100%" : `${RULER_THICKNESS + overlap}px`}>
<div
class="selection-line"
style:left={isVertical ? `${RULER_THICKNESS}px` : `${minPos}px`}
style:top={isVertical ? `${minPos}px` : `${RULER_THICKNESS}px`}
style:width={isVertical ? "1px" : `${maxPos - minPos}px`}
style:height={isVertical ? `${maxPos - minPos}px` : "1px"}
></div>
{#each [minPos, maxPos] as pos}
<div
class="selection-endpoint"
style:left={isVertical ? `${RULER_THICKNESS - half}px` : `${pos - half}px`}
style:top={isVertical ? `${pos - half}px` : `${RULER_THICKNESS - half}px`}
style:width={`${SELECTION_ENDPOINT_SIZE}px`}
style:height={`${SELECTION_ENDPOINT_SIZE}px`}
></div>
{/each}
</div>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
.ruler-input { .ruler-input {
flex: 1 1 100%; flex: 1 1 100%;
background: var(--color-2-mildblack);
overflow: hidden;
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
&.horizontal { .ruler-area {
height: 16px; background: var(--color-2-mildblack);
border-bottom: 1px solid var(--color-5-dullgray); width: 100%;
} height: 100%;
position: relative;
overflow: hidden;
&.vertical { &.horizontal {
width: 16px; height: 16px;
border-right: 1px solid var(--color-5-dullgray); border-bottom: 1px solid var(--color-5-dullgray);
svg text {
text-anchor: end;
} }
}
svg { &.vertical {
position: absolute; width: 16px;
border-right: 1px solid var(--color-5-dullgray);
path { svg text {
stroke-width: 1px; text-anchor: end;
stroke: var(--color-5-dullgray);
&.cursor-indicator {
stroke: var(--color-8-uppergray);
} }
} }
text { svg {
font-size: 12px; position: absolute;
fill: var(--color-8-uppergray);
path {
stroke-width: 1px;
stroke: var(--color-5-dullgray);
&.cursor-indicator {
stroke: var(--color-8-uppergray);
}
}
text {
font-size: 12px;
fill: var(--color-8-uppergray);
}
} }
} }
.selection-overlay-container {
overflow: hidden;
position: absolute;
z-index: 1;
top: 0;
left: 0;
}
.selection-line {
position: absolute;
background: var(--color-8-uppergray);
}
.selection-endpoint {
position: absolute;
background: var(--color-2-mildblack);
border: 1px solid var(--color-overlay-blue);
box-sizing: border-box;
}
} }
</style> </style>