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:
parent
eddd742f9b
commit
42f4c1396b
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue