@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,175 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Renders SVG arrows for annotations. Source position is relative to annotation (pixel offsets),
|
|
4
|
+
target position is in data space with optional percentage offsets for ordinal scales.
|
|
5
|
+
During drag, uses live pixel coordinates from dragState.
|
|
6
|
+
-->
|
|
7
|
+
<script>
|
|
8
|
+
/** @typedef {import('../types.js').Annotation} Annotation */
|
|
9
|
+
/** @typedef {import('../types.js').DragState} DragState */
|
|
10
|
+
/** @typedef {import('../types.js').ModifyArrowFn} ModifyArrowFn */
|
|
11
|
+
/**
|
|
12
|
+
* @template T
|
|
13
|
+
* @typedef {import('../types.js').Ref<T>} Ref
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { getContext } from 'svelte';
|
|
17
|
+
|
|
18
|
+
import { createArrowPath } from '../modules/arrowUtils.js';
|
|
19
|
+
import { getArrowSource, getArrowTarget } from '../modules/coordinates.js';
|
|
20
|
+
|
|
21
|
+
/** @type {{ annotations?: Annotation[] }} */
|
|
22
|
+
let { annotations = [] } = $props();
|
|
23
|
+
|
|
24
|
+
const { xScale, yScale, x, y, width, height } = getContext('LayerCake');
|
|
25
|
+
|
|
26
|
+
/** @type {Ref<DragState | null> | undefined} - Only available in Editor mode */
|
|
27
|
+
const dragStateRef = getContext('previewArrow');
|
|
28
|
+
|
|
29
|
+
/** @type {ModifyArrowFn | undefined} - Only available in Editor mode */
|
|
30
|
+
const modifyArrow = getContext('modifyArrow');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build scales object for coordinate utilities
|
|
34
|
+
*/
|
|
35
|
+
function getScales() {
|
|
36
|
+
return {
|
|
37
|
+
xScale: $xScale,
|
|
38
|
+
yScale: $yScale,
|
|
39
|
+
x: $x,
|
|
40
|
+
y: $y,
|
|
41
|
+
width: $width,
|
|
42
|
+
height: $height
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute the SVG path for a saved arrow
|
|
48
|
+
*/
|
|
49
|
+
function getStaticPath(anno, arrow) {
|
|
50
|
+
const scales = getScales();
|
|
51
|
+
const source = getArrowSource(anno, arrow, scales);
|
|
52
|
+
const target = getArrowTarget(arrow, scales);
|
|
53
|
+
const clockwise = arrow.clockwise !== undefined ? arrow.clockwise : true;
|
|
54
|
+
|
|
55
|
+
return createArrowPath(source, target, clockwise);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Toggle clockwise on cmd+click - cycle order depends on side
|
|
60
|
+
*/
|
|
61
|
+
function handleArrowClick(e, anno, arrow) {
|
|
62
|
+
if (!e.metaKey || !modifyArrow) return;
|
|
63
|
+
|
|
64
|
+
const side = arrow.side;
|
|
65
|
+
const clockwise =
|
|
66
|
+
arrow.clockwise !== undefined ? arrow.clockwise : side === 'west' ? false : true;
|
|
67
|
+
|
|
68
|
+
let newClockwise;
|
|
69
|
+
if (side === 'east') {
|
|
70
|
+
// East: clockwise → straight → counter-clockwise → clockwise
|
|
71
|
+
if (clockwise === true) {
|
|
72
|
+
newClockwise = null;
|
|
73
|
+
} else if (clockwise === null) {
|
|
74
|
+
newClockwise = false;
|
|
75
|
+
} else {
|
|
76
|
+
newClockwise = true;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
// West: counter-clockwise → straight → clockwise → counter-clockwise
|
|
80
|
+
if (clockwise === false) {
|
|
81
|
+
newClockwise = null;
|
|
82
|
+
} else if (clockwise === null) {
|
|
83
|
+
newClockwise = true;
|
|
84
|
+
} else {
|
|
85
|
+
newClockwise = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
modifyArrow(anno.id, side, { clockwise: newClockwise });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if a specific arrow is currently being dragged
|
|
94
|
+
*/
|
|
95
|
+
let draggingArrowKey = $derived.by(() => {
|
|
96
|
+
if (!dragStateRef) return null;
|
|
97
|
+
const ds = dragStateRef.value;
|
|
98
|
+
if (!ds) return null;
|
|
99
|
+
return `${ds.annotationId}_${ds.side}`;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reactive drag path - renders arrow being dragged (new or existing)
|
|
104
|
+
*/
|
|
105
|
+
let dragPath = $derived.by(() => {
|
|
106
|
+
if (!dragStateRef) return '';
|
|
107
|
+
const ds = dragStateRef.value;
|
|
108
|
+
if (!ds || ds.annotationId === null || ds.annotationId === undefined) return '';
|
|
109
|
+
if (ds.sourceX == null || ds.targetX == null) return '';
|
|
110
|
+
|
|
111
|
+
const clockwise = ds.clockwise !== undefined ? ds.clockwise : true;
|
|
112
|
+
return createArrowPath(
|
|
113
|
+
{ x: ds.sourceX, y: ds.sourceY },
|
|
114
|
+
{ x: ds.targetX, y: ds.targetY },
|
|
115
|
+
clockwise
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<g class="swoops">
|
|
121
|
+
<!-- Render saved arrows (hide if this specific arrow is being dragged) -->
|
|
122
|
+
{#each annotations as anno}
|
|
123
|
+
{#if anno.arrows}
|
|
124
|
+
{#each anno.arrows as arrow}
|
|
125
|
+
{@const arrowKey = `${anno.id}_${arrow.side}`}
|
|
126
|
+
{@const isBeingDragged = draggingArrowKey === arrowKey}
|
|
127
|
+
{#if !isBeingDragged}
|
|
128
|
+
{@const pathD = getStaticPath(anno, arrow)}
|
|
129
|
+
<!-- Visible arrow -->
|
|
130
|
+
<path class="arrow-visible" marker-end="url(#layercake-annotation-arrowhead)" d={pathD}
|
|
131
|
+
></path>
|
|
132
|
+
<!-- Invisible hit area for clicking (edit mode only) -->
|
|
133
|
+
{#if modifyArrow}
|
|
134
|
+
<path
|
|
135
|
+
class="arrow-hitarea"
|
|
136
|
+
d={pathD}
|
|
137
|
+
onclick={(e) => handleArrowClick(e, anno, arrow)}
|
|
138
|
+
onkeydown={(e) => e.key === 'Enter' && handleArrowClick(e, anno, arrow)}
|
|
139
|
+
role="button"
|
|
140
|
+
tabindex="0"
|
|
141
|
+
aria-label="Arrow - Cmd+Enter to toggle curve direction"
|
|
142
|
+
></path>
|
|
143
|
+
{/if}
|
|
144
|
+
{/if}
|
|
145
|
+
{/each}
|
|
146
|
+
{/if}
|
|
147
|
+
{/each}
|
|
148
|
+
|
|
149
|
+
<!-- Arrow being dragged (new or existing) - rendered with live coordinates -->
|
|
150
|
+
{#if dragPath}
|
|
151
|
+
<path class="arrow-visible" marker-end="url(#layercake-annotation-arrowhead)" d={dragPath}
|
|
152
|
+
></path>
|
|
153
|
+
{/if}
|
|
154
|
+
</g>
|
|
155
|
+
|
|
156
|
+
<style>
|
|
157
|
+
.swoops {
|
|
158
|
+
position: absolute;
|
|
159
|
+
max-width: 200px;
|
|
160
|
+
line-height: 14px;
|
|
161
|
+
}
|
|
162
|
+
.arrow-visible {
|
|
163
|
+
fill: none;
|
|
164
|
+
stroke: #000;
|
|
165
|
+
stroke-width: 1;
|
|
166
|
+
pointer-events: none;
|
|
167
|
+
}
|
|
168
|
+
.arrow-hitarea {
|
|
169
|
+
fill: none;
|
|
170
|
+
stroke: transparent;
|
|
171
|
+
stroke-width: 12;
|
|
172
|
+
cursor: pointer;
|
|
173
|
+
pointer-events: stroke;
|
|
174
|
+
}
|
|
175
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export default Arrows;
|
|
2
|
+
export type Annotation = import("../types.js").Annotation;
|
|
3
|
+
export type DragState = import("../types.js").DragState;
|
|
4
|
+
export type ModifyArrowFn = import("../types.js").ModifyArrowFn;
|
|
5
|
+
export type Ref<T> = import("../types.js").Ref<T>;
|
|
6
|
+
type Arrows = {
|
|
7
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
8
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Renders SVG arrows for annotations. Source position is relative to annotation (pixel offsets),
|
|
12
|
+
* target position is in data space with optional percentage offsets for ordinal scales.
|
|
13
|
+
* During drag, uses live pixel coordinates from dragState.
|
|
14
|
+
*/
|
|
15
|
+
declare const Arrows: import("svelte").Component<{
|
|
16
|
+
annotations?: Annotation[];
|
|
17
|
+
}, {}, "">;
|
|
18
|
+
type $$ComponentProps = {
|
|
19
|
+
annotations?: Annotation[];
|
|
20
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/** @typedef {import('../types.js').HoverState} HoverState */
|
|
3
|
+
/**
|
|
4
|
+
* @template T
|
|
5
|
+
* @typedef {import('../types.js').Ref<T>} Ref
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getContext } from 'svelte';
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
id,
|
|
12
|
+
left,
|
|
13
|
+
top,
|
|
14
|
+
ondrag,
|
|
15
|
+
canDrag = true,
|
|
16
|
+
bannedTargets = [],
|
|
17
|
+
noteDimensions = $bindable(),
|
|
18
|
+
containerClass = '.chart-container',
|
|
19
|
+
width,
|
|
20
|
+
onclick,
|
|
21
|
+
children
|
|
22
|
+
} = $props();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* State vars
|
|
26
|
+
*/
|
|
27
|
+
let el = $state();
|
|
28
|
+
let isBanned = $state(false);
|
|
29
|
+
let thisMoving = $state(false);
|
|
30
|
+
|
|
31
|
+
/** @type {Ref<HoverState | null>} */
|
|
32
|
+
const hovering = getContext('hovering');
|
|
33
|
+
/** @type {Ref<boolean>} */
|
|
34
|
+
const moving = getContext('moving');
|
|
35
|
+
const { padding } = getContext('LayerCake');
|
|
36
|
+
|
|
37
|
+
function onmousedown(e) {
|
|
38
|
+
moving.value = true;
|
|
39
|
+
thisMoving = true;
|
|
40
|
+
isBanned = [...e.target.classList].some((c) => bannedTargets.includes(c));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Broadcast the elements movements on drag
|
|
45
|
+
*/
|
|
46
|
+
function onmousemove(e) {
|
|
47
|
+
if (thisMoving && canDrag && !isBanned) {
|
|
48
|
+
const { left, top } = el.getBoundingClientRect();
|
|
49
|
+
|
|
50
|
+
const parent = el.closest(containerClass).getBoundingClientRect();
|
|
51
|
+
|
|
52
|
+
ondrag([
|
|
53
|
+
left - parent.left - $padding.left + e.movementX,
|
|
54
|
+
top - parent.top - $padding.top - 0 + e.movementY
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onmouseup() {
|
|
60
|
+
moving.value = false;
|
|
61
|
+
thisMoving = false;
|
|
62
|
+
}
|
|
63
|
+
function onmouseover() {
|
|
64
|
+
if (moving.value) return;
|
|
65
|
+
hovering.value = { annotationId: id, type: 'body' };
|
|
66
|
+
}
|
|
67
|
+
function onmouseout() {
|
|
68
|
+
if (moving.value) return;
|
|
69
|
+
hovering.value = null;
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<div
|
|
74
|
+
bind:this={el}
|
|
75
|
+
style:left
|
|
76
|
+
style:top
|
|
77
|
+
style:width
|
|
78
|
+
class="draggable"
|
|
79
|
+
class:canDrag
|
|
80
|
+
class:hovering={hovering.value?.annotationId === id}
|
|
81
|
+
{onclick}
|
|
82
|
+
{onmousedown}
|
|
83
|
+
{onmouseover}
|
|
84
|
+
{onmouseout}
|
|
85
|
+
onfocus={onmouseover}
|
|
86
|
+
onblur={onmouseout}
|
|
87
|
+
onkeydown={(e) => e.key === 'Delete' && onclick(e)}
|
|
88
|
+
role="button"
|
|
89
|
+
tabindex="0"
|
|
90
|
+
aria-label="Annotation - drag to move, press Delete to remove"
|
|
91
|
+
bind:clientWidth={noteDimensions[0]}
|
|
92
|
+
bind:clientHeight={noteDimensions[1]}
|
|
93
|
+
>
|
|
94
|
+
{@render children()}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<svelte:window {onmouseup} {onmousemove} />
|
|
98
|
+
|
|
99
|
+
<style>
|
|
100
|
+
.draggable {
|
|
101
|
+
position: absolute;
|
|
102
|
+
display: inline-block;
|
|
103
|
+
box-sizing: border-box;
|
|
104
|
+
transition: border-color 250ms;
|
|
105
|
+
border-radius: 2px;
|
|
106
|
+
padding: 3px;
|
|
107
|
+
border: 1px solid transparent;
|
|
108
|
+
}
|
|
109
|
+
.draggable.hovering {
|
|
110
|
+
border-color: red;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.draggable.canDrag {
|
|
114
|
+
user-select: none;
|
|
115
|
+
cursor: move;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.draggable.hovering :global(.grabber) {
|
|
119
|
+
opacity: 1;
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export default Draggable;
|
|
2
|
+
export type HoverState = import("../types.js").HoverState;
|
|
3
|
+
export type Ref<T> = import("../types.js").Ref<T>;
|
|
4
|
+
type Draggable = {
|
|
5
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
6
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
7
|
+
};
|
|
8
|
+
declare const Draggable: import("svelte").Component<{
|
|
9
|
+
id: any;
|
|
10
|
+
left: any;
|
|
11
|
+
top: any;
|
|
12
|
+
ondrag: any;
|
|
13
|
+
canDrag?: boolean;
|
|
14
|
+
bannedTargets?: any[];
|
|
15
|
+
noteDimensions?: any;
|
|
16
|
+
containerClass?: string;
|
|
17
|
+
width: any;
|
|
18
|
+
onclick: any;
|
|
19
|
+
children: any;
|
|
20
|
+
}, {}, "noteDimensions">;
|
|
21
|
+
type $$ComponentProps = {
|
|
22
|
+
id: any;
|
|
23
|
+
left: any;
|
|
24
|
+
top: any;
|
|
25
|
+
ondrag: any;
|
|
26
|
+
canDrag?: boolean;
|
|
27
|
+
bannedTargets?: any[];
|
|
28
|
+
noteDimensions?: any;
|
|
29
|
+
containerClass?: string;
|
|
30
|
+
width: any;
|
|
31
|
+
onclick: any;
|
|
32
|
+
children: any;
|
|
33
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* @template T
|
|
4
|
+
* @typedef {import('../types.js').Ref<T>} Ref
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getContext } from 'svelte';
|
|
8
|
+
|
|
9
|
+
/** @type {Ref<boolean>} */
|
|
10
|
+
const isEditing = getContext('isEditing');
|
|
11
|
+
|
|
12
|
+
let { text = $bindable(), isEditable = $bindable(false), alignment } = $props();
|
|
13
|
+
|
|
14
|
+
let textarea = $state(null);
|
|
15
|
+
|
|
16
|
+
function selectAllTextInContentEditable(element) {
|
|
17
|
+
const selection = window.getSelection();
|
|
18
|
+
const range = document.createRange();
|
|
19
|
+
range.selectNodeContents(element);
|
|
20
|
+
selection.removeAllRanges();
|
|
21
|
+
selection.addRange(range);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cancelEdit() {
|
|
25
|
+
isEditable = false;
|
|
26
|
+
text = text.trim();
|
|
27
|
+
window.removeEventListener('keydown', handleKeydown);
|
|
28
|
+
document.removeEventListener('click', handleClickOutside);
|
|
29
|
+
|
|
30
|
+
// Wait for the click event to propagate before setting isEditing to false
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
isEditing.value = false;
|
|
33
|
+
}, 200);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleClickOutside(event) {
|
|
37
|
+
if (isEditable && textarea && !textarea.contains(event.target)) {
|
|
38
|
+
textarea.blur();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function handleKeydown(e) {
|
|
43
|
+
if (e.key === 'Escape' || e.key === 'Tab') {
|
|
44
|
+
textarea.blur();
|
|
45
|
+
}
|
|
46
|
+
// Enter without shift saves, shift+enter allows line break
|
|
47
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
textarea.blur();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (textarea && isEditable) {
|
|
55
|
+
textarea.focus();
|
|
56
|
+
selectAllTextInContentEditable(textarea);
|
|
57
|
+
window.addEventListener('keydown', handleKeydown);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function handleDoubleClick(e) {
|
|
62
|
+
// Don't enter edit mode if Cmd is held (used for alignment cycling)
|
|
63
|
+
if (e.metaKey) return;
|
|
64
|
+
isEditable = true;
|
|
65
|
+
isEditing.value = true;
|
|
66
|
+
document.addEventListener('click', handleClickOutside);
|
|
67
|
+
}
|
|
68
|
+
function onclick(e) {
|
|
69
|
+
if (isEditable) {
|
|
70
|
+
e.stopPropagation();
|
|
71
|
+
// If we are inside a contenteditable element, don't propagate the click event
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
{#if isEditable}
|
|
79
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
80
|
+
<div
|
|
81
|
+
class="textarea"
|
|
82
|
+
role="textbox"
|
|
83
|
+
aria-multiline="true"
|
|
84
|
+
tabindex="0"
|
|
85
|
+
bind:this={textarea}
|
|
86
|
+
onblur={cancelEdit}
|
|
87
|
+
{onclick}
|
|
88
|
+
ondblclick={handleDoubleClick}
|
|
89
|
+
contenteditable
|
|
90
|
+
bind:innerText={text}
|
|
91
|
+
style:text-align={alignment}
|
|
92
|
+
></div>
|
|
93
|
+
{:else}
|
|
94
|
+
<div
|
|
95
|
+
class="text-display"
|
|
96
|
+
ondblclick={handleDoubleClick}
|
|
97
|
+
onkeydown={(e) => e.key === 'Enter' && handleDoubleClick()}
|
|
98
|
+
role="button"
|
|
99
|
+
tabindex="0"
|
|
100
|
+
aria-label="Double-click or press Enter to edit"
|
|
101
|
+
style:text-align={alignment}
|
|
102
|
+
>
|
|
103
|
+
<pre>{text}</pre>
|
|
104
|
+
</div>
|
|
105
|
+
{/if}
|
|
106
|
+
|
|
107
|
+
<style>
|
|
108
|
+
.textarea[contenteditable] {
|
|
109
|
+
outline: none;
|
|
110
|
+
position: relative;
|
|
111
|
+
white-space: pre-wrap;
|
|
112
|
+
}
|
|
113
|
+
.textarea[contenteditable]:after {
|
|
114
|
+
position: absolute;
|
|
115
|
+
content: '';
|
|
116
|
+
top: -2px;
|
|
117
|
+
right: -4px;
|
|
118
|
+
bottom: -2px;
|
|
119
|
+
left: -4px;
|
|
120
|
+
pointer-events: none;
|
|
121
|
+
border-radius: 3px;
|
|
122
|
+
border: 2px solid #007bff;
|
|
123
|
+
box-shadow: 0 0 5px #007bff50;
|
|
124
|
+
}
|
|
125
|
+
pre {
|
|
126
|
+
margin: 0;
|
|
127
|
+
font-family: inherit;
|
|
128
|
+
white-space: pre-wrap;
|
|
129
|
+
word-wrap: break-word;
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export default EditableText;
|
|
2
|
+
export type Ref<T> = import("../types.js").Ref<T>;
|
|
3
|
+
type EditableText = {
|
|
4
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
5
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
6
|
+
};
|
|
7
|
+
declare const EditableText: import("svelte").Component<{
|
|
8
|
+
text?: any;
|
|
9
|
+
isEditable?: boolean;
|
|
10
|
+
alignment: any;
|
|
11
|
+
}, {}, "text" | "isEditable">;
|
|
12
|
+
type $$ComponentProps = {
|
|
13
|
+
text?: any;
|
|
14
|
+
isEditable?: boolean;
|
|
15
|
+
alignment: any;
|
|
16
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Horizontal resize handles for annotation text boxes.
|
|
4
|
+
Supports west (left) and east (right) resizing only.
|
|
5
|
+
-->
|
|
6
|
+
<script>
|
|
7
|
+
import { getContext } from 'svelte';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
/** Which handles to show: 'west', 'east', or both */
|
|
11
|
+
grabbers = ['west', 'east'],
|
|
12
|
+
/** Current width in pixels (bound) - number or "Npx" string */
|
|
13
|
+
width = $bindable(),
|
|
14
|
+
/** Callback when resizing */
|
|
15
|
+
ondrag,
|
|
16
|
+
/** Container selector for position calculations */
|
|
17
|
+
containerClass = '.chart-container'
|
|
18
|
+
} = $props();
|
|
19
|
+
|
|
20
|
+
/** Parse width to number */
|
|
21
|
+
function parseWidth(w) {
|
|
22
|
+
if (typeof w === 'number') return w;
|
|
23
|
+
if (typeof w === 'string') return parseInt(w) || 0;
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { padding } = getContext('LayerCake');
|
|
28
|
+
|
|
29
|
+
let active = $state(null);
|
|
30
|
+
let initialRect = $state(null);
|
|
31
|
+
let initialPos = $state(null);
|
|
32
|
+
|
|
33
|
+
function onmousedown(event) {
|
|
34
|
+
event.stopPropagation();
|
|
35
|
+
active = event.target;
|
|
36
|
+
const rect = active.parentElement.getBoundingClientRect();
|
|
37
|
+
initialRect = {
|
|
38
|
+
width: rect.width,
|
|
39
|
+
left: rect.left,
|
|
40
|
+
top: rect.top
|
|
41
|
+
};
|
|
42
|
+
initialPos = { x: event.pageX };
|
|
43
|
+
active.classList.add('selected');
|
|
44
|
+
|
|
45
|
+
window.addEventListener('mousemove', onmousemove);
|
|
46
|
+
window.addEventListener('mouseup', onmouseup);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onmouseup() {
|
|
50
|
+
if (!active) return;
|
|
51
|
+
|
|
52
|
+
active.classList.remove('selected');
|
|
53
|
+
active = null;
|
|
54
|
+
initialRect = null;
|
|
55
|
+
initialPos = null;
|
|
56
|
+
|
|
57
|
+
window.removeEventListener('mousemove', onmousemove);
|
|
58
|
+
window.removeEventListener('mouseup', onmouseup);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function onmousemove(event) {
|
|
62
|
+
if (!active) return;
|
|
63
|
+
|
|
64
|
+
const isEast = active.classList.contains('east');
|
|
65
|
+
const isWest = active.classList.contains('west');
|
|
66
|
+
|
|
67
|
+
if (isEast) {
|
|
68
|
+
const delta = event.pageX - initialPos.x;
|
|
69
|
+
const newWidth = Math.round(initialRect.width + delta);
|
|
70
|
+
if (newWidth < 50) return;
|
|
71
|
+
width = `${newWidth}px`;
|
|
72
|
+
ondrag();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (isWest) {
|
|
76
|
+
const delta = initialPos.x - event.pageX;
|
|
77
|
+
const newWidth = Math.round(initialRect.width + delta);
|
|
78
|
+
if (newWidth < 50) return;
|
|
79
|
+
|
|
80
|
+
width = `${newWidth}px`;
|
|
81
|
+
|
|
82
|
+
// Calculate new position - the left edge moves with the mouse
|
|
83
|
+
const parent = active.parentElement.closest(containerClass)?.getBoundingClientRect();
|
|
84
|
+
if (parent) {
|
|
85
|
+
const newLeft = event.pageX - parent.left - $padding.left;
|
|
86
|
+
const newTop = initialRect.top - parent.top - $padding.top;
|
|
87
|
+
ondrag([newLeft, newTop]);
|
|
88
|
+
} else {
|
|
89
|
+
ondrag();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Keyboard resize handler */
|
|
95
|
+
function onResize(delta) {
|
|
96
|
+
const currentWidth = parseWidth(width);
|
|
97
|
+
const newWidth = Math.max(50, currentWidth + delta);
|
|
98
|
+
width = `${newWidth}px`;
|
|
99
|
+
ondrag();
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
{#each grabbers as grabber}
|
|
104
|
+
<div
|
|
105
|
+
class="grabber {grabber}"
|
|
106
|
+
{onmousedown}
|
|
107
|
+
onkeydown={(e) => {
|
|
108
|
+
if (e.key === 'ArrowLeft') { onResize(-10); e.preventDefault(); }
|
|
109
|
+
if (e.key === 'ArrowRight') { onResize(10); e.preventDefault(); }
|
|
110
|
+
}}
|
|
111
|
+
role="slider"
|
|
112
|
+
tabindex="0"
|
|
113
|
+
aria-label="Resize handle - use arrow keys to adjust width"
|
|
114
|
+
aria-valuenow={width}
|
|
115
|
+
></div>
|
|
116
|
+
{/each}
|
|
117
|
+
|
|
118
|
+
<style>
|
|
119
|
+
.grabber {
|
|
120
|
+
position: absolute;
|
|
121
|
+
box-sizing: border-box;
|
|
122
|
+
transition: opacity 250ms;
|
|
123
|
+
opacity: 0;
|
|
124
|
+
z-index: 9999;
|
|
125
|
+
width: 3px;
|
|
126
|
+
height: 70%;
|
|
127
|
+
top: 50%;
|
|
128
|
+
background: red;
|
|
129
|
+
border-radius: 2px;
|
|
130
|
+
cursor: col-resize;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.grabber.west {
|
|
134
|
+
left: -0.5px;
|
|
135
|
+
transform: translateX(-50%) translateY(-50%);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.grabber.east {
|
|
139
|
+
right: -0.5px;
|
|
140
|
+
transform: translateX(50%) translateY(-50%);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.grabber.selected {
|
|
144
|
+
opacity: 1;
|
|
145
|
+
}
|
|
146
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export default ResizeHandles;
|
|
2
|
+
type ResizeHandles = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Horizontal resize handles for annotation text boxes.
|
|
8
|
+
* Supports west (left) and east (right) resizing only.
|
|
9
|
+
*/
|
|
10
|
+
declare const ResizeHandles: import("svelte").Component<{
|
|
11
|
+
grabbers?: any[];
|
|
12
|
+
width?: any;
|
|
13
|
+
ondrag: any;
|
|
14
|
+
containerClass?: string;
|
|
15
|
+
}, {}, "width">;
|
|
16
|
+
type $$ComponentProps = {
|
|
17
|
+
grabbers?: any[];
|
|
18
|
+
width?: any;
|
|
19
|
+
ondrag: any;
|
|
20
|
+
containerClass?: string;
|
|
21
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { default as Annotations } from "./Annotations.svelte";
|
|
2
|
+
export { default as AnnotationsEditor } from "./Editor.svelte";
|
|
3
|
+
export { default as AnnotationsStatic } from "./Static.svelte";
|
|
4
|
+
export type Annotation = import("./types.js").Annotation;
|
|
5
|
+
export type Arrow = import("./types.js").Arrow;
|
|
6
|
+
export type ArrowSource = import("./types.js").ArrowSource;
|
|
7
|
+
export type ArrowTarget = import("./types.js").ArrowTarget;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { default as Annotations } from './Annotations.svelte';
|
|
2
|
+
export { default as AnnotationsEditor } from './Editor.svelte';
|
|
3
|
+
export { default as AnnotationsStatic } from './Static.svelte';
|
|
4
|
+
|
|
5
|
+
// Re-export types for consumers
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import('./types.js').Annotation} Annotation
|
|
8
|
+
* @typedef {import('./types.js').Arrow} Arrow
|
|
9
|
+
* @typedef {import('./types.js').ArrowSource} ArrowSource
|
|
10
|
+
* @typedef {import('./types.js').ArrowTarget} ArrowTarget
|
|
11
|
+
*/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create an SVG arc path between two points.
|
|
3
|
+
* Adapted from bizweekgraphics/swoopyarrows
|
|
4
|
+
*
|
|
5
|
+
* @param {{ x: number, y: number }} source - Start point
|
|
6
|
+
* @param {{ x: number, y: number }} target - End point
|
|
7
|
+
* @param {boolean|null} clockwise - Arc direction (null for straight line)
|
|
8
|
+
* @param {number} [angle=Math.PI/2] - Arc angle in radians
|
|
9
|
+
* @returns {string} SVG path string
|
|
10
|
+
*/
|
|
11
|
+
export function createArrowPath(source: {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}, target: {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
}, clockwise: boolean | null, angle?: number): string;
|