Skip to content

Commit 4135f92

Browse files
authored
Move components when resizing viz (#14904)
Move components when resizing viz https://github.com/user-attachments/assets/b57e804a-7d4a-4c26-b116-630bae3b450e Fixes #14586. Also fix node/visualization size consistency when viz size is reduced below its minimum.
1 parent 34d778d commit 4135f92

10 files changed

Lines changed: 741 additions & 87 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
- [Add right click menu for multiple components][14640].
4141
- [Execution can be scheduled for the specific version tag][14883]
4242
- [Add component spacing options][14888]
43+
- [When resizing component, other components are moved to make room][14904]
4344
- [A comment can be attached to the asset version][14923]
4445
- [New tabular view of session log with filtering][14953]
4546
- [Input ports no longer highlight on hover unless being connected to][14968]
@@ -64,6 +65,7 @@
6465
[14640]: https://github.com/enso-org/enso/pull/14640
6566
[14883]: https://github.com/enso-org/enso/pull/14883
6667
[14888]: https://github.com/enso-org/enso/pull/14888
68+
[14904]: https://github.com/enso-org/enso/pull/14904
6769
[14823]: https://github.com/enso-org/enso/pull/14823
6870
[14953]: https://github.com/enso-org/enso/pull/14953
6971
[14968]: https://github.com/enso-org/enso/pull/14968

app/gui/src/dashboard/utilities/__tests__/jsonSchema.test.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,30 +52,29 @@ fc.test.prop({ value: fc.fc.string() })('string schema', ({ value }) => {
5252
})
5353

5454
const NUMBER_SCHEMA = { type: 'number' } as const
55-
fc.test.prop({ value: fc.fc.float() })('number schema', ({ value }) => {
56-
if (Number.isFinite(value)) {
55+
fc.test.prop({ value: fc.fc.float({ noNaN: true, noDefaultInfinity: true }) })(
56+
'number schema',
57+
({ value }) => {
5758
const constSchema = { const: value, type: 'number' }
5859
v.expect(AJV.validate(NUMBER_SCHEMA, value)).toBe(true)
5960
v.expect(AJV.validate(constSchema, value)).toBe(true)
6061
v.expect(jsonSchema.constantValueOfSchema({}, constSchema)[0]).toBe(value)
61-
}
62-
})
62+
},
63+
)
6364

6465
fc.test.prop({
65-
value: fc.fc.float().filter((n) => n > 0),
66+
value: fc.fc.float({ noNaN: true, noDefaultInfinity: true, min: 0, minExcluded: true }),
6667

6768
multiplier: fc.fc.integer({ min: -1_000_000, max: 1_000_000 }),
6869
})('number multiples', ({ value, multiplier }) => {
6970
const schema = { type: 'number', multipleOf: value }
70-
if (Number.isFinite(value)) {
71-
v.expect(AJV.validate(schema, 0)).toBe(true)
72-
v.expect(AJV.validate(schema, value)).toBe(true)
71+
v.expect(AJV.validate(schema, 0)).toBe(true)
72+
v.expect(AJV.validate(schema, value)).toBe(true)
7373

74-
if (Math.abs(value * (multiplier + 0.5)) < Number.MAX_SAFE_INTEGER) {
75-
v.expect(AJV.validate(schema, value * multiplier)).toBe(true)
76-
if (value !== 0) {
77-
v.expect(AJV.validate(schema, value * (multiplier + 0.5))).toBe(false)
78-
}
74+
if (Math.abs(value * (multiplier + 0.5)) < Number.MAX_SAFE_INTEGER) {
75+
v.expect(AJV.validate(schema, value * multiplier)).toBe(true)
76+
if (value !== 0) {
77+
v.expect(AJV.validate(schema, value * (multiplier + 0.5))).toBe(false)
7978
}
8079
}
8180
})

app/gui/src/project-view/components/GraphEditor/GraphNode.vue

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import GraphNodeMessage from '@/components/GraphEditor/GraphNodeMessage.vue'
2929
import GraphNodeSubmenu from '@/components/GraphEditor/GraphNodeSubmenu.vue'
3030
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
3131
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
32+
import { useNodesDisplacing } from '@/components/GraphEditor/nodesDisplacing'
3233
import { useResizeHandles } from '@/components/resizeHandles'
3334
import ResizeHandles from '@/components/ResizeHandles.vue'
3435
import SvgIcon from '@/components/SvgIcon.vue'
@@ -90,6 +91,8 @@ const nodeExecution = useNodeExecution()
9091
const nodeId = computed(() => asNodeId(props.node.rootExpr.externalId))
9192
const primaryApplication = computed(() => props.node.primaryApplication)
9293
94+
const scale = computed(() => navigator?.scale ?? 1)
95+
9396
const nodePosition = computed(() => {
9497
// Positions of nodes that are not yet placed are set to `Infinity`.
9598
if (props.node.position.equals(Vec2.Infinity)) return Vec2.Zero
@@ -99,8 +102,25 @@ const nodePosition = computed(() => {
99102
onUnmounted(() => graph.unregisterNodeRect(nodeId.value))
100103
101104
const rootNode = ref<HTMLElement>()
102-
const contentNode = ref<HTMLElement>()
103-
const nodeSize = useResizeObserver(rootNode)
105+
const widgetTreeNode = ref<HTMLElement>()
106+
107+
const widgetsDomSizeClientPx = useResizeObserver(widgetTreeNode, false)
108+
const widgetsDomSize = ref(new Vec2(0, 0))
109+
// Maintain the size in scene px. The values reported by the resize observer are in client px, so they are dependent on
110+
// the scale; however, changes to the scale don't cause resize events--so the resize observer is non-reactively (via the
111+
// DOM) dependent on reactive state. Thus, we must correct for the scale by non-reactively sampling it at the time a
112+
// resize is observed.
113+
watch(widgetsDomSizeClientPx, (size) => (widgetsDomSize.value = size.scale(1 / scale.value)), {
114+
immediate: true,
115+
flush: 'sync',
116+
})
117+
// Compute the node's natural size based on the size of its widgets. We measure the widget tree instead of the node
118+
// directly, because measuring the node would cause a cycle:
119+
// - This value is used as in input to determine the size of the visualization.
120+
// - The size of the visualization affects the size of the node.
121+
const nodeDomSize = computed(() =>
122+
widgetsDomSize.value.add(new Vec2(NODE_CONTENT_PADDING * 2, NODE_CONTENT_PADDING * 2)),
123+
)
104124
105125
providePopoverRoot(rootNode)
106126
@@ -180,25 +200,25 @@ function ensureSelected() {
180200
181201
const outputHovered = computed(() => graph.nodeOutputHovered.get(nodeId.value))
182202
183-
const scale = computed(() => navigator?.scale ?? 1)
184-
const nodeRect = computed(() => new Rect(props.node.position, nodeSize.value))
185-
203+
const { displaceNodesForResize } = useNodesDisplacing()
186204
const {
187205
visualizationWidth,
188206
isVisualizationEnabled,
189207
isVisualizationPreviewed,
190-
visRect,
208+
vizHeight,
191209
visualization,
192210
} = useNodeVisualization({
193211
vis: () => props.node.vis,
194212
nodeHovered: () => nodeHovered.value || outputHovered.value,
195-
nodeRect,
213+
nodeWidgetsSize: nodeDomSize,
214+
nodePos: () => props.node.position,
196215
scale,
197216
isFocused: detailedView,
198217
typeinfo: () => expressionInfo.value?.typeInfo,
199218
dataSource: () => ({ type: 'node', nodeId: props.node.rootExpr.externalId }) as const,
200219
hidden: toRef(props, 'edited'),
201220
emit,
221+
onResize: (rect0, rect1) => displaceNodesForResize(nodeId.value, rect0, rect1),
202222
})
203223
204224
watch(isVisualizationPreviewed, (newVal) => {
@@ -248,15 +268,15 @@ const nodeEditHandler = nodeEditBindings.handler({
248268
edit: () => actionHandlers['component.startEditing'].action(),
249269
})
250270
251-
/// The visualization's contribution to the node's height.
252-
const vizBelowNode = computed(() => (visRect.value ? visRect.value.size.y - nodeSize.value.y : 0))
253-
254-
const nodeOuterRect = ref<Rect>()
271+
let prevNodeRect: Rect | undefined = undefined
255272
watchEffect(() => {
256-
const newValue = visRect.value ?? nodeRect.value
257-
if (!newValue.size.isZero() && !nodeOuterRect.value?.equals(newValue)) {
258-
nodeOuterRect.value = newValue
259-
emit('update:rect', newValue)
273+
if (nodeDomSize.value.isZero()) return
274+
const width = Math.max(nodeDomSize.value.x, visualizationWidth.value)
275+
const height = nodeDomSize.value.y + vizHeight.value
276+
const newRect = new Rect(props.node.position, new Vec2(width, height))
277+
if (!prevNodeRect?.equals(newRect)) {
278+
emit('update:rect', newRect)
279+
prevNodeRect = newRect
260280
}
261281
})
262282
@@ -286,15 +306,16 @@ function useRecomputation() {
286306
* takes the size of the largest resizable widget present. If the user resizes the node, and the node is in expanded
287307
* mode, the specified height overrides any widget preferences.
288308
*/
289-
const nodeHeight = computed(() => props.node.height)
309+
const nodeHeightOverride = computed(() => props.node.height)
310+
const nodeHeightOverridden = computed(() => props.node.height != null)
290311
const nodeStyle = computed(() => {
291312
return {
292313
transform: transform.value,
293-
minWidth: isVisualizationEnabled.value ? `${visualizationWidth.value ?? 200}px` : undefined,
294-
height: nodeHeight.value ? `${nodeHeight.value}px` : undefined,
314+
minWidth: `${visualizationWidth.value ?? 200}px`,
315+
height: nodeHeightOverride.value ? `${nodeHeightOverride.value}px` : undefined,
295316
'--node-group-color': baseColor.value,
296317
...(props.node.zIndex ? { 'z-index': props.node.zIndex } : {}),
297-
'--viz-below-node': `${vizBelowNode.value}px`,
318+
'--viz-below-node': `${vizHeight.value}px`,
298319
}
299320
})
300321
@@ -310,7 +331,7 @@ const { progressAnimating, backgroundProgressEvents } = watchProgress()
310331
311332
const showProgressBar = computed(() => nodeProgress.value !== 100 || progressAnimating.value)
312333
313-
const nodeClass = computed(() => {
334+
const nodeClass = computed<Record<string, boolean>>(() => {
314335
return {
315336
selected: selected.value,
316337
pending: pending.value,
@@ -320,7 +341,7 @@ const nodeClass = computed(() => {
320341
menuVisible: menuVisible.value,
321342
menuFull: menuFull.value,
322343
edited: props.edited,
323-
nodeHeightOverridden: nodeHeight.value != null,
344+
nodeHeightOverridden: nodeHeightOverridden.value,
324345
}
325346
})
326347
@@ -462,7 +483,7 @@ const nodeName = computed(() => props.node.pattern?.code())
462483
// === Node resizing ===
463484
464485
const resizeHandles = useResizeHandles({
465-
size: nodeSize,
486+
size: nodeDomSize,
466487
scale,
467488
})
468489
resizeHandles.onResizeHeight((value) => emit('update:height', value))
@@ -517,7 +538,6 @@ resizeHandles.onResizeHeight((value) => emit('update:height', value))
517538
</div>
518539
</template>
519540
<div
520-
ref="contentNode"
521541
:class="{ content: true, dragged: isDragged }"
522542
:style="contentNodeStyle"
523543
v-on="pointerEvents"
@@ -526,6 +546,7 @@ resizeHandles.onResizeHeight((value) => emit('update:height', value))
526546
@pointermove="updateNodeHover"
527547
>
528548
<ComponentWidgetTree
549+
ref="widgetTreeNode"
529550
:ast="props.node.innerExpr"
530551
:nodeId="nodeId"
531552
:rootElement="rootNode"

app/gui/src/project-view/components/GraphEditor/GraphNode/nodeVisualization.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { type VisualizationDataSource } from '@/stores/visualization'
77
import { type Opt } from '@/util/data/opt'
88
import { Rect } from '@/util/data/rect'
99
import { Vec2 } from '@/util/data/vec2'
10-
import { computed, ref, shallowRef, toValue, watch } from 'vue'
10+
import { computed, ref, toValue, watch } from 'vue'
1111
import type { ComponentProps } from 'vue-component-type-helpers'
1212
import type { VisualizationIdentifier, VisualizationMetadata } from 'ydoc-shared/yjsModel'
1313

@@ -22,26 +22,30 @@ interface Emit {
2222
interface NodeVisualizationOptions {
2323
vis: ToValue<Opt<VisualizationMetadata>>
2424
nodeHovered: ToValue<boolean>
25-
nodeRect: ToValue<Rect>
25+
nodeWidgetsSize: ToValue<Vec2>
26+
nodePos: ToValue<Vec2>
2627
scale: ToValue<number>
2728
isFocused: ToValue<boolean>
2829
typeinfo: ToValue<Opt<TypeInfo>>
2930
dataSource: ToValue<Opt<VisualizationDataSource | RawDataSource>>
3031
hidden: ToValue<boolean>
3132
emit: Emit
33+
onResize?: (rect0: Rect, rect1: Rect) => void
3234
}
3335

3436
/** Composable managing the state of the visualization for a node. */
3537
export function useNodeVisualization({
3638
vis,
3739
nodeHovered,
38-
nodeRect,
40+
nodeWidgetsSize,
41+
nodePos,
3942
scale,
4043
isFocused,
4144
typeinfo,
4245
dataSource,
4346
hidden,
4447
emit,
48+
onResize,
4549
}: NodeVisualizationOptions) {
4650
const keyboard = injectBubblingKeyboard()
4751
const metadata = computed(() => toValue(vis))
@@ -91,24 +95,40 @@ export function useNodeVisualization({
9195
if (!visible && visualizationHovered.value) visualizationHovered.value = false
9296
})
9397

94-
const visSize = shallowRef<Vec2>()
95-
const visibleVisRect = computed((): Opt<Rect> => {
96-
if (!isVisualizationVisible.value || toValue(hidden) || !visSize.value) return null
97-
const nodeRectValue = toValue(nodeRect)
98-
return new Rect(
99-
nodeRectValue.pos,
100-
new Vec2(visSize.value.x, nodeRectValue.size.y + visSize.value.y),
101-
)
98+
const effectiveHeight = ref<number>()
99+
const visibleVisHeight = computed((): number => {
100+
if (!isVisualizationVisible.value || toValue(hidden)) return 0
101+
return effectiveHeight.value ?? 0
102+
})
103+
const effectiveWidth = ref<number>()
104+
const visibleVisWidth = computed((): number => {
105+
if (!isVisualizationVisible.value || toValue(hidden)) return 0
106+
return effectiveWidth.value ?? 0
107+
})
108+
const visibleSize = computed<Vec2 | undefined>((prev) => {
109+
if (effectiveHeight.value == null || effectiveWidth.value == null) return
110+
const size = new Vec2(visibleVisWidth.value, visibleVisHeight.value)
111+
return prev?.equals(size) ? prev : size
112+
})
113+
let resizing = false
114+
watch(visibleSize, (size1, size0) => {
115+
if (!resizing || !size1 || !size0 || size1.equals(size0)) return
116+
const widgets = toValue(nodeWidgetsSize)
117+
const pos = toValue(nodePos)
118+
const fullSize = (vizSize: Vec2) =>
119+
new Vec2(Math.max(widgets.x, vizSize.x), widgets.y + vizSize.y)
120+
const rect0 = new Rect(pos, fullSize(size0))
121+
const rect1 = new Rect(pos, fullSize(size1))
122+
onResize?.(rect0, rect1)
102123
})
103124

104125
const visualization = computed((): ComponentProps<typeof GraphVisualization> => {
105-
const { size: nodeSize, pos: nodePosition } = toValue(nodeRect)
106126
return {
107127
show: isVisualizationVisible.value,
108128
width: visualizationWidth.value,
109-
nodeSize,
129+
nodeSize: toValue(nodeWidgetsSize),
110130
scale: toValue(scale),
111-
nodePosition,
131+
nodePosition: toValue(nodePos),
112132
currentType: metadata.value?.identifier,
113133
dataSource: toValue(dataSource) ?? undefined,
114134
typeinfo: toValue(typeinfo) ?? undefined,
@@ -118,19 +138,21 @@ export function useNodeVisualization({
118138
isFullscreenAllowed: true,
119139
isResizable: true,
120140
'onUpdate:hovered': (event) => (visualizationHovered.value = event),
121-
'onUpdate:effectiveSize': (event) => (visSize.value = event),
141+
'onUpdate:effectiveHeight': (event) => (effectiveHeight.value = event),
142+
'onUpdate:effectiveWidth': (event) => (effectiveWidth.value = event),
122143
'onUpdate:id': (event) => emit('update:visualizationId', event),
123144
'onUpdate:enabled': (event) => emit('update:visualizationEnabled', event),
124145
'onUpdate:height': (event) => emit('update:visualizationHeight', event),
125146
'onUpdate:width': (event) => (visualizationWidth.value = event),
147+
'onUpdate:resizing': (event) => (resizing = event),
126148
}
127149
})
128150

129151
return {
130-
visualizationWidth,
152+
visualizationWidth: visibleVisWidth,
131153
isVisualizationEnabled,
132154
isVisualizationPreviewed,
133-
visRect: visibleVisRect,
155+
vizHeight: visibleVisHeight,
134156
visualization,
135157
}
136158
}

0 commit comments

Comments
 (0)