@@ -5,7 +5,8 @@ import type { NoteModel } from "./note-model";
55import NoteTiptapEditor from " ./NoteTiptapEditor.vue" ;
66import { useNoteHeights } from " ./useNoteHeights" ;
77import { 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
1011const props = defineProps <{
1112 id: string ;
@@ -24,8 +25,14 @@ const emit = defineEmits<{
2425
2526const 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
3138const { heights : noteHeights } = useNoteHeights ();
@@ -39,11 +46,12 @@ const isLooseTarget = computed(() =>
3946
4047const 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+
4755const 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