Skip to content

Commit 1b0ef09

Browse files
committed
refactor: replace SVG marker arrowheads with polyline elements, update arrow geometry to use cubic bezier curves, improve color system with lightening utilities
- Replace SVG <marker> arrowheads with inline <polyline> elements for better control and consistent stroke width - Change curve body from quadratic (Q) to cubic bezier (C) with dual control points for smoother curves - Add lightenColor utility and compute arrow color variants (base, light, highlight, final) - Apply 30% lightening to arrow
1 parent 40f939d commit 1b0ef09

6 files changed

Lines changed: 364 additions & 175 deletions

File tree

new-deepnotes/apps/web/src/features/spatial/DisplayArrow.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@ describe("DisplayArrow", () => {
150150
});
151151

152152
const path = wrapper.findAll('path').find((p) =>
153-
p.attributes('stroke-linecap') === 'round',
153+
p.attributes('stroke-width') === '4',
154154
);
155-
expect(path?.attributes('d')).toContain('Q');
155+
expect(path?.attributes('d')).toContain('C');
156156
});
157157

158158
it("renders line body path when bodyType is line", () => {
@@ -162,7 +162,7 @@ describe("DisplayArrow", () => {
162162
});
163163

164164
const path = wrapper.findAll('path').find((p) =>
165-
p.attributes('stroke-linecap') === 'round',
165+
p.attributes('stroke-width') === '4',
166166
);
167167
expect(path?.attributes('d')).toContain('L');
168168
});
@@ -174,9 +174,10 @@ describe("DisplayArrow", () => {
174174
});
175175

176176
const visiblePath = wrapper.findAll('path').find((p) =>
177-
p.attributes('stroke-linecap') === 'round',
177+
p.attributes('stroke-width') === '4',
178178
);
179-
expect(visiblePath?.attributes('stroke')).toBe('#2196f3');
179+
// Grey (#858585) lightened by 30%
180+
expect(visiblePath?.attributes('stroke')).toBe('#aaaaaa');
180181
});
181182

182183
it("uses arrow color stroke when not selected", () => {
@@ -186,7 +187,7 @@ describe("DisplayArrow", () => {
186187
});
187188

188189
const visiblePath = wrapper.findAll('path').find((p) =>
189-
p.attributes('stroke-linecap') === 'round',
190+
p.attributes('stroke-width') === '4',
190191
);
191192
expect(visiblePath?.attributes('stroke')).toBe('#B80909');
192193
});
@@ -197,8 +198,8 @@ describe("DisplayArrow", () => {
197198
props: { id: "arrow-1", model: arrowModel, sourceModel, targetModel },
198199
});
199200

200-
const defs = wrapper.find('defs');
201-
expect(defs.find('marker').exists()).toBe(true);
201+
const polylines = wrapper.findAll('polyline');
202+
expect(polylines.length).toBeGreaterThanOrEqual(1);
202203
});
203204

204205
it("emits select on pointer down", async () => {

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

Lines changed: 85 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import type { NoteModel } from "./note-model";
55
import NoteTiptapEditor from "./NoteTiptapEditor.vue";
66
import { useNoteHeights } from "./useNoteHeights";
77
import { computeArrowEndpoints } from "./arrow-geometry";
8-
import { resolveArrowColor } from "./color-utils";
8+
import { resolveArrowColor, lightenColor } from "./color-utils";
9+
import { isDark } from "@/features/theme/useThemePreference";
910
1011
const props = defineProps<{
1112
id: string;
@@ -24,8 +25,14 @@ const emit = defineEmits<{
2425
2526
const labelFragment = computed(() => props.model.label.value);
2627
27-
const arrowColor = computed(() => {
28-
return resolveArrowColor(props.model.color.value);
28+
const arrowColors = computed(() => {
29+
const base = resolveArrowColor(props.model.color.value);
30+
return {
31+
base,
32+
light: lightenColor(base, 0.3),
33+
highlight: lightenColor(base, 0.6),
34+
final: props.selected ? lightenColor(base, 0.3) : base,
35+
};
2936
});
3037
3138
const { heights: noteHeights } = useNoteHeights();
@@ -39,11 +46,12 @@ const isLooseTarget = computed(() =>
3946
4047
const strokeDasharray = computed(() => {
4148
const style = props.model.bodyStyle.value;
42-
if (style === "dashed") return "8 6";
43-
if (style === "dotted") return "2 4";
44-
return "none";
49+
if (style === "dashed") return "6,6";
50+
return undefined;
4551
});
4652
53+
const ARROW_SIZE = 10;
54+
4755
const geometry = computed(() => {
4856
const s = props.sourceModel;
4957
const t = props.targetModel;
@@ -80,7 +88,6 @@ const geometry = computed(() => {
8088
x1 = fake.x;
8189
y1 = fake.y;
8290
} else {
83-
// No source and no fakePos — can't render this end
8491
return null;
8592
}
8693
@@ -95,12 +102,11 @@ const geometry = computed(() => {
95102
x2 = fake.x;
96103
y2 = fake.y;
97104
} else {
98-
// No target and no fakePos — can't render this end
99105
return null;
100106
}
101107
102-
// Apply rectangle-edge intersection for line body when both notes are present
103-
if (s && t && props.model.bodyType.value === "line" && !sourceAnchor && !targetAnchor) {
108+
// Apply rectangle-edge intersection when both notes are present
109+
if (s && t && !sourceAnchor && !targetAnchor) {
104110
const endpoints = computeArrowEndpoints(
105111
s.pos.value,
106112
t.pos.value,
@@ -124,31 +130,42 @@ const geometry = computed(() => {
124130
const localX2 = x2 - minX;
125131
const localY2 = y2 - minY;
126132
127-
// Curve control point
128-
const mx = (localX1 + localX2) / 2;
129-
const my = (localY1 + localY2) / 2;
130133
const dx = localX2 - localX1;
131134
const dy = localY2 - localY1;
132135
const dist = Math.hypot(dx, dy);
133-
const curveOffset = props.model.bodyType.value === "curve" ? dist * 0.25 : 0;
134-
// Perpendicular offset
135-
const perpX = dy / (dist || 1);
136-
const perpY = -dx / (dist || 1);
137-
const cx = mx + perpX * curveOffset;
138-
const cy = my + perpY * curveOffset;
139-
140-
const pathD =
141-
props.model.bodyType.value === "curve"
142-
? `M ${localX1} ${localY1} Q ${cx} ${cy} ${localX2} ${localY2}`
143-
: `M ${localX1} ${localY1} L ${localX2} ${localY2}`;
144-
145-
// Arrowhead angle at target
146-
let angle = 0;
136+
137+
let pathD: string;
138+
let sourceAngle: number;
139+
let targetAngle: number;
140+
let centerX: number;
141+
let centerY: number;
142+
147143
if (props.model.bodyType.value === "curve") {
148-
// Approximate tangent at endpoint for quadratic bezier
149-
angle = Math.atan2(localY2 - cy, localX2 - cx);
144+
const perpX = dy / (dist || 1);
145+
const perpY = -dx / (dist || 1);
146+
const offset = dist * 0.25;
147+
148+
const c1x = localX1 + dx * 0.5 + perpX * offset;
149+
const c1y = localY1 + dy * 0.5 + perpY * offset;
150+
const c2x = localX2 - dx * 0.5 + perpX * offset;
151+
const c2y = localY2 - dy * 0.5 + perpY * offset;
152+
153+
pathD = `M ${localX1} ${localY1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${localX2} ${localY2}`;
154+
155+
sourceAngle = Math.atan2(c1y - localY1, c1x - localX1);
156+
targetAngle = Math.atan2(localY2 - c2y, localX2 - c2x);
157+
158+
// Cubic bezier midpoint at t=0.5
159+
centerX = 0.125 * localX1 + 0.375 * c1x + 0.375 * c2x + 0.125 * localX2;
160+
centerY = 0.125 * localY1 + 0.375 * c1y + 0.375 * c2y + 0.125 * localY2;
150161
} else {
151-
angle = Math.atan2(localY2 - localY1, localX2 - localX1);
162+
pathD = `M ${localX1} ${localY1} L ${localX2} ${localY2}`;
163+
164+
sourceAngle = Math.atan2(dy, dx);
165+
targetAngle = Math.atan2(dy, dx);
166+
167+
centerX = (localX1 + localX2) / 2;
168+
centerY = (localY1 + localY2) / 2;
152169
}
153170
154171
return {
@@ -159,7 +176,10 @@ const geometry = computed(() => {
159176
localX2,
160177
localY2,
161178
pathD,
162-
angle,
179+
sourceAngle,
180+
targetAngle,
181+
centerX,
182+
centerY,
163183
dist,
164184
sourceX: x1,
165185
sourceY: y1,
@@ -190,50 +210,43 @@ function onPointerDown(e: PointerEvent) {
190210
transform: `translate(${geometry.minX}px, ${geometry.minY}px)`,
191211
}"
192212
>
193-
<!-- Arrow head markers (unique per arrow to avoid color bleeding) -->
194-
<defs>
195-
<marker
196-
:id="`arrowhead-target-${model.source.value}-${model.target.value}`"
197-
markerWidth="10"
198-
markerHeight="10"
199-
refX="9"
200-
refY="5"
201-
orient="auto-start-reverse"
202-
>
203-
<path d="M 0 1 L 9 5 L 0 9" fill="none" :stroke="arrowColor" stroke-width="1.5" />
204-
</marker>
205-
<marker
206-
:id="`arrowhead-source-${model.source.value}-${model.target.value}`"
207-
markerWidth="10"
208-
markerHeight="10"
209-
refX="9"
210-
refY="5"
211-
orient="auto-start-reverse"
212-
>
213-
<path d="M 0 1 L 9 5 L 0 9" fill="none" :stroke="arrowColor" stroke-width="1.5" />
214-
</marker>
215-
</defs>
216-
217213
<!-- Hitbox: thick invisible stroke for easy grabbing -->
218214
<path
219215
:d="geometry.pathD"
220216
fill="none"
221217
stroke="transparent"
222218
stroke-width="20"
223-
class="pointer-events-auto cursor-pointer"
219+
class="pointer-events-auto cursor-grab"
224220
@pointerdown="onPointerDown"
225221
/>
226222

227223
<!-- Visible body -->
228224
<path
229225
:d="geometry.pathD"
230226
fill="none"
231-
:stroke="selected ? '#2196f3' : arrowColor"
232-
:stroke-width="selected ? 4 : 4"
227+
:stroke="arrowColors.final"
228+
stroke-width="4"
233229
:stroke-dasharray="strokeDasharray"
234-
stroke-linecap="round"
235-
:marker-end="model.targetHead.value ? `url(#arrowhead-target-${model.source.value}-${model.target.value})` : ''"
236-
:marker-start="model.sourceHead.value ? `url(#arrowhead-source-${model.source.value}-${model.target.value})` : ''"
230+
/>
231+
232+
<!-- Source open head -->
233+
<polyline
234+
v-if="model.sourceHead.value === 'open'"
235+
:stroke="arrowColors.final"
236+
:points="`${geometry.localX1 - ARROW_SIZE},${geometry.localY1 - ARROW_SIZE} ${geometry.localX1},${geometry.localY1} ${geometry.localX1 - ARROW_SIZE},${geometry.localY1 + ARROW_SIZE}`"
237+
:transform="`rotate(${(geometry.sourceAngle / Math.PI) * 180 + 180},${geometry.localX1},${geometry.localY1})`"
238+
fill="none"
239+
stroke-width="4"
240+
/>
241+
242+
<!-- Target open head -->
243+
<polyline
244+
v-if="model.targetHead.value === 'open'"
245+
:stroke="arrowColors.final"
246+
:points="`${geometry.localX2 - ARROW_SIZE},${geometry.localY2 - ARROW_SIZE} ${geometry.localX2},${geometry.localY2} ${geometry.localX2 - ARROW_SIZE},${geometry.localY2 + ARROW_SIZE}`"
247+
:transform="`rotate(${(geometry.targetAngle / Math.PI) * 180},${geometry.localX2},${geometry.localY2})`"
248+
fill="none"
249+
stroke-width="4"
237250
/>
238251

239252
<!-- Source connection zone -->
@@ -266,30 +279,34 @@ function onPointerDown(e: PointerEvent) {
266279
:cx="geometry.localX1"
267280
:cy="geometry.localY1"
268281
r="4"
269-
:fill="arrowColor"
270-
stroke="white"
282+
:fill="arrowColors.final"
283+
:stroke="isDark ? 'white' : '#1a1a1a'"
271284
stroke-width="1.5"
272285
/>
273286
<circle
274287
v-if="isLooseTarget"
275288
:cx="geometry.localX2"
276289
:cy="geometry.localY2"
277290
r="4"
278-
:fill="arrowColor"
279-
stroke="white"
291+
:fill="arrowColors.final"
292+
:stroke="isDark ? 'white' : '#1a1a1a'"
280293
stroke-width="1.5"
281294
/>
282295

283296
<!-- Arrow label at midpoint -->
284297
<foreignObject
285298
v-if="labelFragment"
286-
:x="(geometry.localX1 + geometry.localX2) / 2 - 60"
287-
:y="(geometry.localY1 + geometry.localY2) / 2 - 16"
299+
:x="geometry.centerX - 60"
300+
:y="geometry.centerY - 16"
288301
width="120"
289302
height="32"
290303
class="pointer-events-auto"
291304
>
292-
<div class="bg-background/90 dark:bg-background/90 h-full w-full rounded px-1 shadow-sm" @focusin="emit('edit-start')">
305+
<div
306+
class="h-full w-full rounded px-1"
307+
:style="{ backgroundColor: isDark ? 'rgba(24,24,24,0.9)' : 'rgba(255,255,255,0.9)' }"
308+
@focusin="emit('edit-start')"
309+
>
293310
<NoteTiptapEditor
294311
:fragment="labelFragment"
295312
:editable="!props.model.readOnly.value"

0 commit comments

Comments
 (0)