Skip to content

Commit fcefd1e

Browse files
committed
refactor: improve arrow geometry, add per-section height controls and resize handles, replace SVG grid with canvas rendering
- Fix arrow endpoint positioning: add ARROW_OFFSET constant and apply offset to anchor points and auto-computed endpoints - Reverse target arrowhead angle by adding Math.PI to point in correct direction - Add sizeToCSS utility to convert numeric sizes to CSS px values - Implement per-section height controls: add headHeightCSS and bodyHeightCSS computed properties - Add section
1 parent ec36cbe commit fcefd1e

3 files changed

Lines changed: 240 additions & 46 deletions

File tree

new-deepnotes/apps/web/src/features/spatial/DisplayArrow.vue

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const strokeDasharray = computed(() => {
5454
});
5555
5656
const ARROW_SIZE = 10;
57+
const ARROW_OFFSET = 4;
5758
5859
const geometry = computed(() => {
5960
const s = props.sourceModel;
@@ -81,11 +82,13 @@ const geometry = computed(() => {
8182
let y2: number;
8283
8384
if (s) {
84-
x1 = s.pos.value.x + nw1 / 2;
85-
y1 = s.pos.value.y + h1 / 2;
85+
const scx = s.pos.value.x + nw1 / 2;
86+
const scy = s.pos.value.y + h1 / 2;
87+
x1 = scx;
88+
y1 = scy;
8689
if (sourceAnchor) {
87-
x1 = s.pos.value.x + sourceAnchor.x;
88-
y1 = s.pos.value.y + sourceAnchor.y;
90+
x1 = scx + sourceAnchor.x * (nw1 / 2 + ARROW_OFFSET);
91+
y1 = scy + sourceAnchor.y * (h1 / 2 + ARROW_OFFSET);
8992
}
9093
} else if (fake) {
9194
x1 = fake.x;
@@ -95,11 +98,13 @@ const geometry = computed(() => {
9598
}
9699
97100
if (t) {
98-
x2 = t.pos.value.x + nw2 / 2;
99-
y2 = t.pos.value.y + h2 / 2;
101+
const tcx = t.pos.value.x + nw2 / 2;
102+
const tcy = t.pos.value.y + h2 / 2;
103+
x2 = tcx;
104+
y2 = tcy;
100105
if (targetAnchor) {
101-
x2 = t.pos.value.x + targetAnchor.x;
102-
y2 = t.pos.value.y + targetAnchor.y;
106+
x2 = tcx + targetAnchor.x * (nw2 / 2 + ARROW_OFFSET);
107+
y2 = tcy + targetAnchor.y * (h2 / 2 + ARROW_OFFSET);
103108
}
104109
} else if (fake) {
105110
x2 = fake.x;
@@ -123,6 +128,23 @@ const geometry = computed(() => {
123128
y1 = endpoints.y1;
124129
x2 = endpoints.x2;
125130
y2 = endpoints.y2;
131+
132+
// Push endpoints outward by ARROW_OFFSET so heads don't sit on the edge
133+
const scx = s.pos.value.x + nw1 / 2;
134+
const scy = s.pos.value.y + h1 / 2;
135+
const dx1 = x1 - scx;
136+
const dy1 = y1 - scy;
137+
const d1 = Math.hypot(dx1, dy1) || 1;
138+
x1 += (dx1 / d1) * ARROW_OFFSET;
139+
y1 += (dy1 / d1) * ARROW_OFFSET;
140+
141+
const tcx = t.pos.value.x + nw2 / 2;
142+
const tcy = t.pos.value.y + h2 / 2;
143+
const dx2 = x2 - tcx;
144+
const dy2 = y2 - tcy;
145+
const d2 = Math.hypot(dx2, dy2) || 1;
146+
x2 += (dx2 / d2) * ARROW_OFFSET;
147+
y2 += (dy2 / d2) * ARROW_OFFSET;
126148
}
127149
128150
// Compute normals (direction from note center to edge point)
@@ -181,7 +203,7 @@ const geometry = computed(() => {
181203
pathD = `M ${localX1} ${localY1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${localX2} ${localY2}`;
182204
183205
sourceAngle = Math.atan2(sourceNy, sourceNx);
184-
targetAngle = Math.atan2(targetNy, targetNx);
206+
targetAngle = Math.atan2(targetNy, targetNx) + Math.PI;
185207
186208
// Cubic bezier midpoint at t=0.5
187209
centerX = 0.125 * localX1 + 0.375 * c1x + 0.375 * c2x + 0.125 * localX2;

new-deepnotes/apps/web/src/features/spatial/DisplayNote.vue

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,43 @@ const textColor = computed(() => noteTextColor(isDark.value));
8181
const borderColor = computed(() => noteBorderColor(isDark.value, props.selected));
8282
const dividerColor = computed(() => noteDividerColor(isDark.value));
8383
84+
function sizeToCSS(size: string): string {
85+
if (/^-?\d+(\.\d+)?$/.test(size.trim())) {
86+
return `${size}px`;
87+
}
88+
return size;
89+
}
90+
91+
const headHeightCSS = computed(() => {
92+
const h = props.model.head.height.value;
93+
const collapsed = props.model.collapsing.collapsed.value;
94+
if (!props.model.head.enabled.value) return undefined;
95+
if (collapsed) {
96+
const c = h.collapsed;
97+
if (c === "Auto" || c === "Minimum") {
98+
if (numEnabledSections.value === 1) return "0px";
99+
return sizeToCSS(h.expanded);
100+
}
101+
return sizeToCSS(c);
102+
}
103+
return sizeToCSS(h.expanded);
104+
});
105+
106+
const bodyHeightCSS = computed(() => {
107+
const h = props.model.body.height.value;
108+
const collapsed = props.model.collapsing.collapsed.value;
109+
if (!props.model.body.enabled.value) return undefined;
110+
if (collapsed) {
111+
const c = h.collapsed;
112+
if (c === "Auto" || c === "Minimum") {
113+
if (numEnabledSections.value === 1) return "0px";
114+
return sizeToCSS(h.expanded);
115+
}
116+
return sizeToCSS(c);
117+
}
118+
return sizeToCSS(h.expanded);
119+
});
120+
84121
const headFrag = computed(() => props.model.head.value.value);
85122
const bodyFrag = computed(() => props.model.body.value.value);
86123
@@ -159,6 +196,7 @@ let hasDragged = false;
159196
160197
function onPointerDown(e: PointerEvent) {
161198
if (e.button !== 0) return;
199+
if (isEditing.value) return;
162200
e.stopPropagation();
163201
164202
if (e.shiftKey) {
@@ -231,13 +269,15 @@ let resizeStartWidth = 0;
231269
let resizeStartHeight = 0;
232270
let resizeStartPosX = 0;
233271
let resizeStartPosY = 0;
272+
let resizingSection: "head" | "body" | null = null;
234273
235274
function onResizePointerDown(e: PointerEvent, handle: ResizeHandle) {
236275
if (e.button !== 0) return;
237276
if (props.model.readOnly.value) return;
238277
e.stopPropagation();
239278
resizePointerId = e.pointerId;
240279
resizeHandle = handle;
280+
resizingSection = null;
241281
resizeStartX = e.clientX;
242282
resizeStartY = e.clientY;
243283
const widthVal = (props.model.width as any)?.value ?? props.model.width;
@@ -253,12 +293,78 @@ function onResizePointerDown(e: PointerEvent, handle: ResizeHandle) {
253293
el.setPointerCapture(e.pointerId);
254294
}
255295
296+
function onSectionResizePointerDown(e: PointerEvent, section: "head" | "body") {
297+
if (e.button !== 0) return;
298+
if (props.model.readOnly.value) return;
299+
e.stopPropagation();
300+
resizePointerId = e.pointerId;
301+
resizeHandle = "s";
302+
resizingSection = section;
303+
resizeStartX = e.clientX;
304+
resizeStartY = e.clientY;
305+
const sectionMap = props.model.rawMap.get(section) as import("yjs").Map<unknown>;
306+
const hMap = sectionMap.get("height") as import("yjs").Map<string>;
307+
const hVal = hMap.get("expanded") ?? "Auto";
308+
resizeStartHeight = hVal === "Auto" || hVal === "Minimum" ? 80 : parseFloat(hVal);
309+
isDragging.value = true;
310+
const el = e.currentTarget as HTMLElement;
311+
el.setPointerCapture(e.pointerId);
312+
}
313+
314+
function onSectionCornerResizePointerDown(e: PointerEvent, handle: ResizeHandle, section: "head" | "body") {
315+
if (e.button !== 0) return;
316+
if (props.model.readOnly.value) return;
317+
e.stopPropagation();
318+
resizePointerId = e.pointerId;
319+
resizeHandle = handle;
320+
resizingSection = section;
321+
resizeStartX = e.clientX;
322+
resizeStartY = e.clientY;
323+
const widthVal = (props.model.width as any)?.value ?? props.model.width;
324+
const w = widthVal?.expanded;
325+
resizeStartWidth = w === "Auto" ? 160 : parseFloat(w ?? "160");
326+
const sectionMap = props.model.rawMap.get(section) as import("yjs").Map<unknown>;
327+
const hMap = sectionMap.get("height") as import("yjs").Map<string>;
328+
const hVal = hMap.get("expanded") ?? "Auto";
329+
resizeStartHeight = hVal === "Auto" || hVal === "Minimum" ? 80 : parseFloat(hVal);
330+
resizeStartPosX = props.model.pos.value.x;
331+
resizeStartPosY = props.model.pos.value.y;
332+
isDragging.value = true;
333+
const el = e.currentTarget as HTMLElement;
334+
el.setPointerCapture(e.pointerId);
335+
}
336+
256337
function onResizePointerMove(e: PointerEvent) {
257338
if (resizePointerId !== e.pointerId || !resizeHandle) return;
258339
const z = props.zoom || 1;
259340
const dx = (e.clientX - resizeStartX) / z;
260341
const dy = (e.clientY - resizeStartY) / z;
261342
343+
if (resizingSection) {
344+
const nextHeight = Math.max(
345+
40,
346+
Math.round(resizeStartHeight + dy),
347+
);
348+
const sectionMap = props.model.rawMap.get(resizingSection) as import("yjs").Map<unknown>;
349+
const hMap = sectionMap.get("height") as import("yjs").Map<string>;
350+
hMap.set("expanded", String(nextHeight));
351+
352+
const isWest = resizeHandle.includes("w");
353+
if (isWest || resizeHandle.includes("e")) {
354+
const nextWidth = Math.max(
355+
80,
356+
Math.round(isWest ? resizeStartWidth - dx : resizeStartWidth + dx),
357+
);
358+
const widthMap = props.model.rawMap.get("width") as import("yjs").Map<string>;
359+
widthMap.set("expanded", String(nextWidth));
360+
if (isWest) {
361+
const posMap = props.model.rawMap.get("pos") as import("yjs").Map<number>;
362+
posMap.set("x", Math.round(resizeStartPosX + dx));
363+
}
364+
}
365+
return;
366+
}
367+
262368
const isWest = resizeHandle.includes("w");
263369
const nextWidth = Math.max(
264370
80,
@@ -291,6 +397,7 @@ function onResizePointerUp(e: PointerEvent) {
291397
if (resizePointerId !== e.pointerId) return;
292398
resizePointerId = null;
293399
resizeHandle = null;
400+
resizingSection = null;
294401
isDragging.value = false;
295402
const el = e.currentTarget as HTMLElement;
296403
if (el.releasePointerCapture) {
@@ -337,9 +444,9 @@ function onContextMenu(e: MouseEvent) {
337444
<div
338445
v-if="model.head.enabled.value && !model.collapsing.collapsed.value"
339446
class="flex"
340-
style="min-height: 36.45px"
447+
:style="{ height: headHeightCSS, minHeight: '36.45px' }"
341448
>
342-
<div class="flex-1" @pointerdown.stop @focusin="emit('edit-start')">
449+
<div class="flex-1" @dblclick.stop="emit('edit-start')" @focusin="emit('edit-start')">
343450
<NoteTiptapEditor
344451
:fragment="headFrag!"
345452
:editable="isEditing && !model.readOnly.value"
@@ -374,20 +481,38 @@ function onContextMenu(e: MouseEvent) {
374481
v-if="model.resizable.value && !model.readOnly.value && props.selected"
375482
class="absolute z-[2147483646] cursor-ns-resize"
376483
style="top: -3px; left: 0; right: 0; height: 7px;"
377-
@pointerdown="(e: PointerEvent) => onResizePointerDown(e, 's')"
484+
@pointerdown="(e: PointerEvent) => onSectionResizePointerDown(e, 'head')"
378485
@pointermove="onResizePointerMove"
379486
@pointerup="onResizePointerUp"
380487
@pointercancel="onResizePointerUp"
381488
/>
489+
<template v-if="model.resizable.value && !model.readOnly.value && props.selected && !isFlexChild">
490+
<div
491+
class="absolute z-[2147483647] h-2.5 w-2.5 rounded-full"
492+
style="left: 0%; top: 0%; background-color: #2196f3; transform: translate(-50%, -50%);"
493+
@pointerdown="(e: PointerEvent) => onSectionCornerResizePointerDown(e, 'sw', 'head')"
494+
@pointermove="onResizePointerMove"
495+
@pointerup="onResizePointerUp"
496+
@pointercancel="onResizePointerUp"
497+
/>
498+
<div
499+
class="absolute z-[2147483647] h-2.5 w-2.5 rounded-full"
500+
style="left: 100%; top: 0%; background-color: #2196f3; transform: translate(-50%, -50%);"
501+
@pointerdown="(e: PointerEvent) => onSectionCornerResizePointerDown(e, 'se', 'head')"
502+
@pointermove="onResizePointerMove"
503+
@pointerup="onResizePointerUp"
504+
@pointercancel="onResizePointerUp"
505+
/>
506+
</template>
382507
</div>
383508

384509
<!-- body section -->
385510
<div
386511
v-if="model.body.enabled.value && !model.collapsing.collapsed.value"
387512
class="flex"
388-
style="min-height: 36.45px"
513+
:style="{ height: bodyHeightCSS, minHeight: '36.45px' }"
389514
>
390-
<div class="flex-1" @pointerdown.stop @focusin="emit('edit-start')">
515+
<div class="flex-1" @dblclick.stop="emit('edit-start')" @focusin="emit('edit-start')">
391516
<NoteTiptapEditor
392517
:fragment="bodyFrag!"
393518
:editable="isEditing && !model.readOnly.value"
@@ -422,11 +547,29 @@ function onContextMenu(e: MouseEvent) {
422547
v-if="model.resizable.value && !model.readOnly.value && props.selected"
423548
class="absolute z-[2147483646] cursor-ns-resize"
424549
style="top: -3px; left: 0; right: 0; height: 7px;"
425-
@pointerdown="(e: PointerEvent) => onResizePointerDown(e, 's')"
550+
@pointerdown="(e: PointerEvent) => onSectionResizePointerDown(e, 'body')"
426551
@pointermove="onResizePointerMove"
427552
@pointerup="onResizePointerUp"
428553
@pointercancel="onResizePointerUp"
429554
/>
555+
<template v-if="model.resizable.value && !model.readOnly.value && props.selected && !isFlexChild">
556+
<div
557+
class="absolute z-[2147483647] h-2.5 w-2.5 rounded-full"
558+
style="left: 0%; top: 0%; background-color: #2196f3; transform: translate(-50%, -50%);"
559+
@pointerdown="(e: PointerEvent) => onSectionCornerResizePointerDown(e, 'sw', 'body')"
560+
@pointermove="onResizePointerMove"
561+
@pointerup="onResizePointerUp"
562+
@pointercancel="onResizePointerUp"
563+
/>
564+
<div
565+
class="absolute z-[2147483647] h-2.5 w-2.5 rounded-full"
566+
style="left: 100%; top: 0%; background-color: #2196f3; transform: translate(-50%, -50%);"
567+
@pointerdown="(e: PointerEvent) => onSectionCornerResizePointerDown(e, 'se', 'body')"
568+
@pointermove="onResizePointerMove"
569+
@pointerup="onResizePointerUp"
570+
@pointercancel="onResizePointerUp"
571+
/>
572+
</template>
430573
</div>
431574

432575
<!-- container section -->

0 commit comments

Comments
 (0)