@mhkeller/layercake-annotations 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -0
- package/dist/Annotations.svelte +15 -0
- package/dist/Annotations.svelte.d.ts +14 -0
- package/dist/Editor.svelte +213 -0
- package/dist/Editor.svelte.d.ts +19 -0
- package/dist/README.md +200 -0
- package/dist/Static.svelte +24 -0
- package/dist/Static.svelte.d.ts +12 -0
- package/dist/components/AnnotationEditor.svelte +127 -0
- package/dist/components/AnnotationEditor.svelte.d.ts +14 -0
- package/dist/components/AnnotationsData.svelte +56 -0
- package/dist/components/AnnotationsData.svelte.d.ts +15 -0
- package/dist/components/ArrowZone.svelte +366 -0
- package/dist/components/ArrowZone.svelte.d.ts +27 -0
- package/dist/components/ArrowheadMarker.svelte +18 -0
- package/dist/components/ArrowheadMarker.svelte.d.ts +14 -0
- package/dist/components/Arrows.svelte +175 -0
- package/dist/components/Arrows.svelte.d.ts +20 -0
- package/dist/components/Draggable.svelte +121 -0
- package/dist/components/Draggable.svelte.d.ts +33 -0
- package/dist/components/EditableText.svelte +131 -0
- package/dist/components/EditableText.svelte.d.ts +16 -0
- package/dist/components/ResizeHandles.svelte +146 -0
- package/dist/components/ResizeHandles.svelte.d.ts +21 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +11 -0
- package/dist/modules/arrowUtils.d.ts +17 -0
- package/dist/modules/arrowUtils.js +32 -0
- package/dist/modules/coordinates.d.ts +85 -0
- package/dist/modules/coordinates.js +139 -0
- package/dist/modules/createRef.svelte.d.ts +7 -0
- package/dist/modules/createRef.svelte.js +10 -0
- package/dist/modules/filterObject.d.ts +7 -0
- package/dist/modules/filterObject.js +14 -0
- package/dist/modules/invertScale.d.ts +1 -0
- package/dist/modules/invertScale.js +5 -0
- package/dist/modules/newAnnotation.d.ts +9 -0
- package/dist/modules/newAnnotation.js +26 -0
- package/dist/modules/ordinalInvert.d.ts +9 -0
- package/dist/modules/ordinalInvert.js +22 -0
- package/dist/types.d.ts +162 -0
- package/package.json +61 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/** @typedef {import('../types.js').ModifyAnnotationFn} ModifyAnnotationFn */
|
|
3
|
+
|
|
4
|
+
import { getContext } from 'svelte';
|
|
5
|
+
|
|
6
|
+
import Draggable from './Draggable.svelte';
|
|
7
|
+
import EditableText from './EditableText.svelte';
|
|
8
|
+
import ResizeHandles from './ResizeHandles.svelte';
|
|
9
|
+
import ArrowZone from './ArrowZone.svelte';
|
|
10
|
+
|
|
11
|
+
import invertScale from '../modules/invertScale.js';
|
|
12
|
+
import filterObject from '../modules/filterObject.js';
|
|
13
|
+
|
|
14
|
+
let { d, containerClass } = $props();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Layer Cake configuration
|
|
18
|
+
*/
|
|
19
|
+
const { config, xScale, yScale, xGet, yGet, percentRange } = getContext('LayerCake');
|
|
20
|
+
let units = $derived($percentRange === true ? '%' : 'px');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* State variables
|
|
24
|
+
*/
|
|
25
|
+
let isEditable = $state(false);
|
|
26
|
+
let noteDimensions = $state([0, 0]);
|
|
27
|
+
// svelte-ignore state_referenced_locally
|
|
28
|
+
let width = $state(d.width);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Arrow sides - simplified to just west and east
|
|
32
|
+
*/
|
|
33
|
+
const arrowSides = ['west', 'east'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Context variables
|
|
37
|
+
* @type {ModifyAnnotationFn}
|
|
38
|
+
*/
|
|
39
|
+
const modifyAnnotation = getContext('modifyAnnotation');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Coordinates
|
|
43
|
+
*/
|
|
44
|
+
let left = $derived(`calc(${$xGet(d)}${units} + ${d.dx}%)`);
|
|
45
|
+
let top = $derived(`calc(${$yGet(d)}${units} + ${d.dy}%)`);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {Array} [position] - The x and y pixel coordinates of the draggable element.
|
|
49
|
+
*/
|
|
50
|
+
async function ondrag(position = []) {
|
|
51
|
+
const [x, y] = position;
|
|
52
|
+
const xVal = x ? invertScale($xScale, x) : [];
|
|
53
|
+
const yVal = y ? invertScale($yScale, y) : [];
|
|
54
|
+
|
|
55
|
+
const newProps = filterObject(
|
|
56
|
+
{
|
|
57
|
+
[$config.x]: xVal[0],
|
|
58
|
+
[$config.y]: yVal[0],
|
|
59
|
+
dx: xVal[1],
|
|
60
|
+
dy: yVal[1]
|
|
61
|
+
},
|
|
62
|
+
(d) => d !== undefined
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Always save current width
|
|
66
|
+
if (width) {
|
|
67
|
+
newProps.width = width;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
modifyAnnotation(d.id, newProps);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Text alignment - initialized from data, saved on change
|
|
75
|
+
* Cmd+click cycles: left → center → right → left
|
|
76
|
+
*/
|
|
77
|
+
// svelte-ignore state_referenced_locally
|
|
78
|
+
let alignment = $state(d.align || 'left');
|
|
79
|
+
|
|
80
|
+
function onclick(e) {
|
|
81
|
+
if (e.metaKey) {
|
|
82
|
+
let newAlignment;
|
|
83
|
+
if (alignment === 'left') {
|
|
84
|
+
newAlignment = 'center';
|
|
85
|
+
} else if (alignment === 'center') {
|
|
86
|
+
newAlignment = 'right';
|
|
87
|
+
} else {
|
|
88
|
+
newAlignment = 'left';
|
|
89
|
+
}
|
|
90
|
+
alignment = newAlignment;
|
|
91
|
+
modifyAnnotation(d.id, { align: newAlignment });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const grabbers = ['west', 'east'];
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
{#if d}
|
|
99
|
+
<Draggable
|
|
100
|
+
id={d.id}
|
|
101
|
+
{left}
|
|
102
|
+
{top}
|
|
103
|
+
{ondrag}
|
|
104
|
+
{width}
|
|
105
|
+
{onclick}
|
|
106
|
+
canDrag={!isEditable}
|
|
107
|
+
bannedTargets={['arrow-zone']}
|
|
108
|
+
bind:noteDimensions
|
|
109
|
+
{containerClass}
|
|
110
|
+
>
|
|
111
|
+
<div class="layercake-annotation" data-id={d.id}>
|
|
112
|
+
<EditableText bind:text={d.text} bind:isEditable {alignment} />
|
|
113
|
+
</div>
|
|
114
|
+
<ResizeHandles bind:width {ondrag} {grabbers} {containerClass} />
|
|
115
|
+
</Draggable>
|
|
116
|
+
|
|
117
|
+
{#each arrowSides as side}
|
|
118
|
+
<ArrowZone {d} {side} {noteDimensions} />
|
|
119
|
+
{/each}
|
|
120
|
+
{/if}
|
|
121
|
+
|
|
122
|
+
<style>
|
|
123
|
+
.layercake-annotation {
|
|
124
|
+
width: 100%;
|
|
125
|
+
height: 100%;
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export default AnnotationEditor;
|
|
2
|
+
export type ModifyAnnotationFn = import("../types.js").ModifyAnnotationFn;
|
|
3
|
+
type AnnotationEditor = {
|
|
4
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
5
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
6
|
+
};
|
|
7
|
+
declare const AnnotationEditor: import("svelte").Component<{
|
|
8
|
+
d: any;
|
|
9
|
+
containerClass: any;
|
|
10
|
+
}, {}, "">;
|
|
11
|
+
type $$ComponentProps = {
|
|
12
|
+
d: any;
|
|
13
|
+
containerClass: any;
|
|
14
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Adds text annotations that get their x and y placement using the `xScale` and `yScale`.
|
|
4
|
+
-->
|
|
5
|
+
<script>
|
|
6
|
+
/** @typedef {import('../types.js').Annotation} Annotation */
|
|
7
|
+
|
|
8
|
+
import { getContext } from 'svelte';
|
|
9
|
+
|
|
10
|
+
const { xGet, yGet, percentRange } = getContext('LayerCake');
|
|
11
|
+
|
|
12
|
+
/** @type {{ annotations?: Annotation[], getText?: (d: Annotation) => string }} */
|
|
13
|
+
let { annotations = $bindable([]), getText = (d) => d.text } = $props();
|
|
14
|
+
|
|
15
|
+
let units = $derived($percentRange === true ? '%' : 'px');
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<div class="layercake-annotations">
|
|
19
|
+
{#each annotations as d, i}
|
|
20
|
+
<!-- Wrapper mirrors Draggable structure -->
|
|
21
|
+
<div
|
|
22
|
+
class="static-wrapper"
|
|
23
|
+
data-id={i}
|
|
24
|
+
style:left={`calc(${$xGet(d)}${units} + ${d.dx || 0}%)`}
|
|
25
|
+
style:top={`calc(${$yGet(d)}${units} + ${d.dy || 0}%)`}
|
|
26
|
+
style:width={d.width}
|
|
27
|
+
>
|
|
28
|
+
<div class="layercake-annotation" style:text-align={d.align || 'left'}>
|
|
29
|
+
<pre>{getText(d)}</pre>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
{/each}
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<style>
|
|
36
|
+
/* Mirrors Draggable.svelte CSS exactly */
|
|
37
|
+
.static-wrapper {
|
|
38
|
+
position: absolute;
|
|
39
|
+
display: inline-block;
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
transition: border-color 250ms;
|
|
42
|
+
border-radius: 2px;
|
|
43
|
+
padding: 3px;
|
|
44
|
+
border: 1px solid transparent;
|
|
45
|
+
}
|
|
46
|
+
.layercake-annotation {
|
|
47
|
+
width: 100%;
|
|
48
|
+
height: 100%;
|
|
49
|
+
}
|
|
50
|
+
pre {
|
|
51
|
+
margin: 0;
|
|
52
|
+
font-family: inherit;
|
|
53
|
+
white-space: pre-wrap;
|
|
54
|
+
word-wrap: break-word;
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export default AnnotationsData;
|
|
2
|
+
export type Annotation = import("../types.js").Annotation;
|
|
3
|
+
type AnnotationsData = {
|
|
4
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
5
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
6
|
+
};
|
|
7
|
+
/** Adds text annotations that get their x and y placement using the `xScale` and `yScale`. */
|
|
8
|
+
declare const AnnotationsData: import("svelte").Component<{
|
|
9
|
+
annotations?: Annotation[];
|
|
10
|
+
getText?: (d: Annotation) => string;
|
|
11
|
+
}, {}, "annotations">;
|
|
12
|
+
type $$ComponentProps = {
|
|
13
|
+
annotations?: Annotation[];
|
|
14
|
+
getText?: (d: Annotation) => string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Draggable zones for creating/editing arrows. Only supports "west" and "east" sides.
|
|
4
|
+
- When no arrow exists: shows one handle at annotation edge to create arrow
|
|
5
|
+
- When arrow exists: shows TWO handles - one at source, one at target
|
|
6
|
+
Updates arrow position in real-time during drag.
|
|
7
|
+
-->
|
|
8
|
+
<script>
|
|
9
|
+
/** @typedef {import('../types.js').HoverState} HoverState */
|
|
10
|
+
/** @typedef {import('../types.js').DragState} DragState */
|
|
11
|
+
/** @typedef {import('../types.js').SetArrowFn} SetArrowFn */
|
|
12
|
+
/** @typedef {import('../types.js').ModifyArrowFn} ModifyArrowFn */
|
|
13
|
+
/** @typedef {import('../types.js').ModifyAnnotationFn} ModifyAnnotationFn */
|
|
14
|
+
/**
|
|
15
|
+
* @template T
|
|
16
|
+
* @typedef {import('../types.js').Ref<T>} Ref
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { getContext } from 'svelte';
|
|
20
|
+
import invertScale from '../modules/invertScale.js';
|
|
21
|
+
import {
|
|
22
|
+
getAnnotationBox,
|
|
23
|
+
getArrowSource,
|
|
24
|
+
getArrowTarget,
|
|
25
|
+
calculateSourceDx,
|
|
26
|
+
calculateSourceDy,
|
|
27
|
+
HANDLE_OFFSET_PX
|
|
28
|
+
} from '../modules/coordinates.js';
|
|
29
|
+
|
|
30
|
+
const { xScale, yScale, x, y, config, width, height } = getContext('LayerCake');
|
|
31
|
+
|
|
32
|
+
let { d, side, noteDimensions } = $props();
|
|
33
|
+
|
|
34
|
+
/** @type {Ref<HoverState | null>} */
|
|
35
|
+
const hovering = getContext('hovering');
|
|
36
|
+
/** @type {SetArrowFn} */
|
|
37
|
+
const setArrow = getContext('setArrow');
|
|
38
|
+
/** @type {ModifyArrowFn} */
|
|
39
|
+
const modifyArrow = getContext('modifyArrow');
|
|
40
|
+
/** @type {ModifyAnnotationFn} */
|
|
41
|
+
const modifyAnnotation = getContext('modifyAnnotation');
|
|
42
|
+
/** @type {Ref<boolean>} */
|
|
43
|
+
const moving = getContext('moving');
|
|
44
|
+
/** @type {Ref<DragState | null>} */
|
|
45
|
+
const dragState = getContext('previewArrow');
|
|
46
|
+
|
|
47
|
+
/** Handle diameter in pixels */
|
|
48
|
+
const diameterPx = 15;
|
|
49
|
+
|
|
50
|
+
/** State - which handle is being dragged */
|
|
51
|
+
let draggingSource = $state(false);
|
|
52
|
+
let draggingTarget = $state(false);
|
|
53
|
+
let dragX = $state(null);
|
|
54
|
+
let dragY = $state(null);
|
|
55
|
+
|
|
56
|
+
/** Get the existing arrow for this side (if any) */
|
|
57
|
+
let arrow = $derived(d.arrows?.find((a) => a.side === side));
|
|
58
|
+
|
|
59
|
+
/** Default clockwise direction based on side (null = straight line, so don't use ??) */
|
|
60
|
+
let clockwise = $derived(
|
|
61
|
+
arrow?.clockwise !== undefined ? arrow.clockwise : side === 'west' ? false : true
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/** Build scales object for coordinate utilities */
|
|
65
|
+
function getScales() {
|
|
66
|
+
return {
|
|
67
|
+
xScale: $xScale,
|
|
68
|
+
yScale: $yScale,
|
|
69
|
+
x: $x,
|
|
70
|
+
y: $y,
|
|
71
|
+
width: $width,
|
|
72
|
+
height: $height
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Annotation box position and dimensions */
|
|
77
|
+
let annoBox = $derived(getAnnotationBox(d, getScales()));
|
|
78
|
+
|
|
79
|
+
/** Default source offsets */
|
|
80
|
+
let defaultSourceDx = $derived(side === 'west' ? -HANDLE_OFFSET_PX : HANDLE_OFFSET_PX);
|
|
81
|
+
let defaultSourceDy = $derived(noteDimensions[1] / 2);
|
|
82
|
+
|
|
83
|
+
/** Current source position in pixels */
|
|
84
|
+
let sourcePos = $derived.by(() => {
|
|
85
|
+
if (arrow) {
|
|
86
|
+
return getArrowSource(d, arrow, getScales(), noteDimensions[1]);
|
|
87
|
+
}
|
|
88
|
+
// Default position when no arrow exists
|
|
89
|
+
const dx = defaultSourceDx;
|
|
90
|
+
const dy = defaultSourceDy;
|
|
91
|
+
if (side === 'east') {
|
|
92
|
+
return { x: annoBox.left + annoBox.width + dx, y: annoBox.top + dy };
|
|
93
|
+
}
|
|
94
|
+
return { x: annoBox.left + dx, y: annoBox.top + dy };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
let sourceX = $derived(sourcePos.x);
|
|
98
|
+
let sourceY = $derived(sourcePos.y);
|
|
99
|
+
|
|
100
|
+
/** Current target position in pixels (when arrow exists) */
|
|
101
|
+
let targetX = $derived.by(() => {
|
|
102
|
+
if (!arrow) return sourceX + (side === 'west' ? -50 : 50);
|
|
103
|
+
return getArrowTarget(arrow, getScales()).x;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
let targetY = $derived.by(() => {
|
|
107
|
+
if (!arrow) return sourceY;
|
|
108
|
+
return getArrowTarget(arrow, getScales()).y;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Zone positions for display
|
|
113
|
+
*/
|
|
114
|
+
let sourceDisplayX = $derived(draggingSource ? dragX : sourceX);
|
|
115
|
+
let sourceDisplayY = $derived(draggingSource ? dragY : sourceY);
|
|
116
|
+
let targetDisplayX = $derived(draggingTarget ? dragX : targetX);
|
|
117
|
+
let targetDisplayY = $derived(draggingTarget ? dragY : targetY);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Update drag state for live arrow rendering
|
|
121
|
+
*/
|
|
122
|
+
function updateDragState() {
|
|
123
|
+
if (!draggingSource && !draggingTarget) {
|
|
124
|
+
dragState.value = null;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
dragState.value = {
|
|
129
|
+
annotationId: d.id,
|
|
130
|
+
side,
|
|
131
|
+
sourceX: draggingSource ? dragX : sourceX,
|
|
132
|
+
sourceY: draggingSource ? dragY : sourceY,
|
|
133
|
+
targetX: draggingTarget ? dragX : targetX,
|
|
134
|
+
targetY: draggingTarget ? dragY : targetY,
|
|
135
|
+
clockwise
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Toggle clockwise on cmd+click - cycle order depends on side */
|
|
140
|
+
function onclick(e) {
|
|
141
|
+
if (!e.metaKey || !arrow) return;
|
|
142
|
+
|
|
143
|
+
let newClockwise;
|
|
144
|
+
if (side === 'east') {
|
|
145
|
+
// East: clockwise → straight → counter-clockwise → clockwise
|
|
146
|
+
if (clockwise === true) {
|
|
147
|
+
newClockwise = null;
|
|
148
|
+
} else if (clockwise === null) {
|
|
149
|
+
newClockwise = false;
|
|
150
|
+
} else {
|
|
151
|
+
newClockwise = true;
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
// West: counter-clockwise → straight → clockwise → counter-clockwise
|
|
155
|
+
if (clockwise === false) {
|
|
156
|
+
newClockwise = null;
|
|
157
|
+
} else if (clockwise === null) {
|
|
158
|
+
newClockwise = true;
|
|
159
|
+
} else {
|
|
160
|
+
newClockwise = false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
modifyArrow(d.id, side, { clockwise: newClockwise });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Start dragging source handle */
|
|
168
|
+
function onSourceMousedown() {
|
|
169
|
+
moving.value = true;
|
|
170
|
+
draggingSource = true;
|
|
171
|
+
dragX = sourceX;
|
|
172
|
+
dragY = sourceY;
|
|
173
|
+
updateDragState();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Start dragging target handle (or create mode) */
|
|
177
|
+
function onTargetMousedown() {
|
|
178
|
+
moving.value = true;
|
|
179
|
+
draggingTarget = true;
|
|
180
|
+
dragX = arrow ? targetX : sourceX;
|
|
181
|
+
dragY = arrow ? targetY : sourceY;
|
|
182
|
+
updateDragState();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Track mouse during drag */
|
|
186
|
+
function onmousemove(e) {
|
|
187
|
+
if (!draggingSource && !draggingTarget) return;
|
|
188
|
+
|
|
189
|
+
dragX += e.movementX;
|
|
190
|
+
dragY += e.movementY;
|
|
191
|
+
updateDragState();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** On release, save the arrow */
|
|
195
|
+
function onmouseup() {
|
|
196
|
+
// Only process if we were actually dragging
|
|
197
|
+
if (!draggingSource && !draggingTarget) return;
|
|
198
|
+
|
|
199
|
+
const scales = getScales();
|
|
200
|
+
|
|
201
|
+
if (draggingSource && dragX !== null && dragY !== null) {
|
|
202
|
+
// Update source position using shared coordinate utils
|
|
203
|
+
const newSourceDx = calculateSourceDx(dragX, d, side, scales);
|
|
204
|
+
const newSourceDy = calculateSourceDy(dragY, d, scales);
|
|
205
|
+
|
|
206
|
+
if (arrow) {
|
|
207
|
+
modifyArrow(d.id, side, {
|
|
208
|
+
source: { dx: newSourceDx, dy: newSourceDy }
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
// Creating new arrow - need target too
|
|
212
|
+
const [targetDataX, targetOffsetX] = invertScale($xScale, targetX);
|
|
213
|
+
const [targetDataY, targetOffsetY] = invertScale($yScale, targetY);
|
|
214
|
+
|
|
215
|
+
setArrow(d.id, {
|
|
216
|
+
side,
|
|
217
|
+
clockwise,
|
|
218
|
+
source: { dx: newSourceDx, dy: newSourceDy },
|
|
219
|
+
target: {
|
|
220
|
+
[$config.x]: targetDataX,
|
|
221
|
+
[$config.y]: targetDataY,
|
|
222
|
+
dx: targetOffsetX,
|
|
223
|
+
dy: targetOffsetY
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (draggingTarget && dragX !== null && dragY !== null) {
|
|
230
|
+
// Update target position (convert to data space)
|
|
231
|
+
const [targetDataX, targetOffsetX] = invertScale($xScale, dragX);
|
|
232
|
+
const [targetDataY, targetOffsetY] = invertScale($yScale, dragY);
|
|
233
|
+
|
|
234
|
+
// Keep existing source or use defaults
|
|
235
|
+
const existingSourceDx = arrow?.source?.dx ?? defaultSourceDx;
|
|
236
|
+
|
|
237
|
+
setArrow(d.id, {
|
|
238
|
+
side,
|
|
239
|
+
clockwise,
|
|
240
|
+
source: {
|
|
241
|
+
dx: existingSourceDx,
|
|
242
|
+
dy: arrow?.source?.dy ?? defaultSourceDy
|
|
243
|
+
},
|
|
244
|
+
target: {
|
|
245
|
+
[$config.x]: targetDataX,
|
|
246
|
+
[$config.y]: targetDataY,
|
|
247
|
+
dx: targetOffsetX,
|
|
248
|
+
dy: targetOffsetY
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Clear state
|
|
254
|
+
moving.value = false;
|
|
255
|
+
draggingSource = false;
|
|
256
|
+
draggingTarget = false;
|
|
257
|
+
dragX = null;
|
|
258
|
+
dragY = null;
|
|
259
|
+
dragState.value = null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function onmouseover(handle) {
|
|
263
|
+
if (moving.value) return;
|
|
264
|
+
hovering.value = { annotationId: d.id, type: 'arrow', side, handle };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function onmouseout() {
|
|
268
|
+
if (moving.value) return;
|
|
269
|
+
hovering.value = null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Show handles when hovering over ANY part of this annotation (body, any arrow zone)
|
|
273
|
+
let isAnnotationHovered = $derived(hovering.value?.annotationId === d.id);
|
|
274
|
+
let isDragging = $derived(draggingSource || draggingTarget);
|
|
275
|
+
</script>
|
|
276
|
+
|
|
277
|
+
{#if arrow}
|
|
278
|
+
<!-- Source handle (when arrow exists) -->
|
|
279
|
+
<div
|
|
280
|
+
onmousedown={onSourceMousedown}
|
|
281
|
+
{onclick}
|
|
282
|
+
onkeydown={(e) => e.key === 'Enter' && onclick(e)}
|
|
283
|
+
onfocus={() => onmouseover('source')}
|
|
284
|
+
onblur={onmouseout}
|
|
285
|
+
onmouseover={() => onmouseover('source')}
|
|
286
|
+
{onmouseout}
|
|
287
|
+
role="button"
|
|
288
|
+
tabindex="0"
|
|
289
|
+
aria-label="Arrow source handle - Cmd+click to toggle curve direction"
|
|
290
|
+
class:visible={isAnnotationHovered || isDragging}
|
|
291
|
+
class:dragging={draggingSource}
|
|
292
|
+
class="arrow-zone source {side}"
|
|
293
|
+
style:left="{sourceDisplayX - diameterPx / 2}px"
|
|
294
|
+
style:top="{sourceDisplayY - diameterPx / 2}px"
|
|
295
|
+
></div>
|
|
296
|
+
|
|
297
|
+
<!-- Target handle (when arrow exists) -->
|
|
298
|
+
<div
|
|
299
|
+
onmousedown={onTargetMousedown}
|
|
300
|
+
{onclick}
|
|
301
|
+
onkeydown={(e) => e.key === 'Enter' && onclick(e)}
|
|
302
|
+
onfocus={() => onmouseover('target')}
|
|
303
|
+
onblur={onmouseout}
|
|
304
|
+
onmouseover={() => onmouseover('target')}
|
|
305
|
+
{onmouseout}
|
|
306
|
+
role="button"
|
|
307
|
+
tabindex="0"
|
|
308
|
+
aria-label="Arrow target handle - drag to move arrow endpoint"
|
|
309
|
+
class:visible={isAnnotationHovered || isDragging}
|
|
310
|
+
class:dragging={draggingTarget}
|
|
311
|
+
class="arrow-zone target {side}"
|
|
312
|
+
style:left="{targetDisplayX - diameterPx / 2}px"
|
|
313
|
+
style:top="{targetDisplayY - diameterPx / 2}px"
|
|
314
|
+
></div>
|
|
315
|
+
{:else}
|
|
316
|
+
<!-- Create handle (no arrow yet) - drag to create -->
|
|
317
|
+
<div
|
|
318
|
+
onmousedown={onTargetMousedown}
|
|
319
|
+
onfocus={() => onmouseover('create')}
|
|
320
|
+
onblur={onmouseout}
|
|
321
|
+
onmouseover={() => onmouseover('create')}
|
|
322
|
+
{onmouseout}
|
|
323
|
+
role="button"
|
|
324
|
+
tabindex="0"
|
|
325
|
+
aria-label="Drag to create arrow"
|
|
326
|
+
class:visible={isAnnotationHovered || isDragging}
|
|
327
|
+
class:dragging={draggingTarget}
|
|
328
|
+
class="arrow-zone create {side}"
|
|
329
|
+
style:left="{(draggingTarget ? dragX : sourceX) - diameterPx / 2}px"
|
|
330
|
+
style:top="{(draggingTarget ? dragY : sourceY) - diameterPx / 2}px"
|
|
331
|
+
></div>
|
|
332
|
+
{/if}
|
|
333
|
+
|
|
334
|
+
<svelte:window {onmouseup} {onmousemove} />
|
|
335
|
+
|
|
336
|
+
<style>
|
|
337
|
+
.arrow-zone {
|
|
338
|
+
--diameter: 15px;
|
|
339
|
+
position: absolute;
|
|
340
|
+
width: var(--diameter);
|
|
341
|
+
height: var(--diameter);
|
|
342
|
+
border-radius: 50%;
|
|
343
|
+
border: 1px dashed #333;
|
|
344
|
+
background: rgba(0, 0, 0, 0.1);
|
|
345
|
+
cursor: grab;
|
|
346
|
+
opacity: 0;
|
|
347
|
+
transition: opacity 250ms;
|
|
348
|
+
z-index: 10;
|
|
349
|
+
}
|
|
350
|
+
/* Larger hit area for easier hovering */
|
|
351
|
+
.arrow-zone:before {
|
|
352
|
+
content: '';
|
|
353
|
+
position: absolute;
|
|
354
|
+
width: 21px;
|
|
355
|
+
height: 23px;
|
|
356
|
+
top: -5px;
|
|
357
|
+
left: -3.5px;
|
|
358
|
+
}
|
|
359
|
+
.arrow-zone.visible,
|
|
360
|
+
.arrow-zone.dragging {
|
|
361
|
+
opacity: 1;
|
|
362
|
+
}
|
|
363
|
+
.arrow-zone.dragging {
|
|
364
|
+
cursor: grabbing;
|
|
365
|
+
}
|
|
366
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export default ArrowZone;
|
|
2
|
+
export type HoverState = import("../types.js").HoverState;
|
|
3
|
+
export type DragState = import("../types.js").DragState;
|
|
4
|
+
export type SetArrowFn = import("../types.js").SetArrowFn;
|
|
5
|
+
export type ModifyArrowFn = import("../types.js").ModifyArrowFn;
|
|
6
|
+
export type ModifyAnnotationFn = import("../types.js").ModifyAnnotationFn;
|
|
7
|
+
export type Ref<T> = import("../types.js").Ref<T>;
|
|
8
|
+
type ArrowZone = {
|
|
9
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
10
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Draggable zones for creating/editing arrows. Only supports "west" and "east" sides.
|
|
14
|
+
* - When no arrow exists: shows one handle at annotation edge to create arrow
|
|
15
|
+
* - When arrow exists: shows TWO handles - one at source, one at target
|
|
16
|
+
* Updates arrow position in real-time during drag.
|
|
17
|
+
*/
|
|
18
|
+
declare const ArrowZone: import("svelte").Component<{
|
|
19
|
+
d: any;
|
|
20
|
+
side: any;
|
|
21
|
+
noteDimensions: any;
|
|
22
|
+
}, {}, "">;
|
|
23
|
+
type $$ComponentProps = {
|
|
24
|
+
d: any;
|
|
25
|
+
side: any;
|
|
26
|
+
noteDimensions: any;
|
|
27
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Generates an SVG marker containing a marker for a triangle makes a nice arrowhead. Add it to the named slot called "defs" on the SVG layout component.
|
|
4
|
+
-->
|
|
5
|
+
<script>
|
|
6
|
+
/** @type {{ fill?: string, stroke?: string }} */
|
|
7
|
+
let { fill = '#000', stroke = '#000' } = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<marker
|
|
11
|
+
id="layercake-annotation-arrowhead"
|
|
12
|
+
viewBox="-10 -10 20 20"
|
|
13
|
+
markerWidth="17"
|
|
14
|
+
markerHeight="17"
|
|
15
|
+
orient="auto"
|
|
16
|
+
>
|
|
17
|
+
<path d="M-6,-6 L 0,0 L -6,6" {fill} {stroke} />
|
|
18
|
+
</marker>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export default ArrowheadMarker;
|
|
2
|
+
type ArrowheadMarker = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/** Generates an SVG marker containing a marker for a triangle makes a nice arrowhead. Add it to the named slot called "defs" on the SVG layout component. */
|
|
7
|
+
declare const ArrowheadMarker: import("svelte").Component<{
|
|
8
|
+
fill?: string;
|
|
9
|
+
stroke?: string;
|
|
10
|
+
}, {}, "">;
|
|
11
|
+
type $$ComponentProps = {
|
|
12
|
+
fill?: string;
|
|
13
|
+
stroke?: string;
|
|
14
|
+
};
|