@r2digisolutions/components 0.14.22 → 0.15.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/dist/components/molecules/OrgChart/OrgChart.svelte +35 -97
- package/dist/components/molecules/OrgChart/OrgChart.svelte.d.ts +6 -0
- package/dist/components/molecules/OrgChart/OrgChartNode.svelte +130 -0
- package/dist/components/molecules/OrgChart/OrgChartNode.svelte.d.ts +10 -0
- package/dist/components/molecules/OrgChart/OrgChartStory.svelte +3 -3
- package/dist/components/molecules/OrgChart/OrgChartTree.svelte +16 -0
- package/dist/components/molecules/OrgChart/OrgChartTree.svelte.d.ts +8 -0
- package/dist/components/molecules/PanZoomViewport/PanZoomViewport.stories.js +8 -0
- package/dist/components/molecules/PanZoomViewport/PanZoomViewport.svelte +231 -0
- package/dist/components/molecules/PanZoomViewport/PanZoomViewport.svelte.d.ts +17 -0
- package/dist/components/molecules/PanZoomViewport/PanZoomViewportStory.svelte +20 -0
- package/dist/components/molecules/PanZoomViewport/PanZoomViewportStory.svelte.d.ts +18 -0
- package/dist/components/molecules/PanZoomViewport/panZoomContext.d.ts +5 -0
- package/dist/components/molecules/PanZoomViewport/panZoomContext.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import PanZoomViewport from '../PanZoomViewport/PanZoomViewport.svelte';
|
|
3
|
+
import OrgChartTree from './OrgChartTree.svelte';
|
|
4
|
+
|
|
2
5
|
export interface OrgNode {
|
|
3
6
|
id: string;
|
|
4
7
|
name: string;
|
|
@@ -12,106 +15,41 @@
|
|
|
12
15
|
root?: OrgNode | null;
|
|
13
16
|
class?: string;
|
|
14
17
|
onselect?: (node: OrgNode) => void;
|
|
18
|
+
/** Pan + wheel zoom viewport */
|
|
19
|
+
interactive?: boolean;
|
|
20
|
+
viewportHeight?: string;
|
|
21
|
+
showControls?: boolean;
|
|
22
|
+
/** Refit viewport when filter / data changes */
|
|
23
|
+
contentKey?: string | number | null;
|
|
15
24
|
}
|
|
16
25
|
|
|
17
|
-
let {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.join('');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function primaryAvatar(node: OrgNode) {
|
|
32
|
-
return node.avatarUrl ?? node.assignees?.[0]?.avatarUrl ?? null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function primaryLabel(node: OrgNode) {
|
|
36
|
-
if (node.assignees?.length === 1) return node.assignees[0].name;
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
26
|
+
let {
|
|
27
|
+
root = null,
|
|
28
|
+
class: className = '',
|
|
29
|
+
onselect,
|
|
30
|
+
interactive = true,
|
|
31
|
+
viewportHeight = 'min(70vh, 640px)',
|
|
32
|
+
showControls = true,
|
|
33
|
+
contentKey = null
|
|
34
|
+
}: OrgChartProps = $props();
|
|
35
|
+
|
|
36
|
+
const viewportKey = $derived(contentKey ?? root?.id ?? 'empty');
|
|
39
37
|
</script>
|
|
40
38
|
|
|
41
|
-
{#
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
: 'border-border hover:border-brand-400'
|
|
49
|
-
]}
|
|
50
|
-
onclick={() => {
|
|
51
|
-
selected = node.id;
|
|
52
|
-
onselect?.(node);
|
|
53
|
-
}}
|
|
39
|
+
{#if interactive && root}
|
|
40
|
+
<PanZoomViewport
|
|
41
|
+
class={className}
|
|
42
|
+
height={viewportHeight}
|
|
43
|
+
{showControls}
|
|
44
|
+
contentKey={viewportKey}
|
|
45
|
+
initialFit
|
|
54
46
|
>
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
>
|
|
61
|
-
{initials(node.name)}
|
|
62
|
-
</span>
|
|
63
|
-
{/if}
|
|
64
|
-
<span class="line-clamp-2 text-sm leading-snug font-semibold text-primary">{node.name}</span>
|
|
65
|
-
{#if primaryLabel(node)}
|
|
66
|
-
<span class="truncate text-xs text-secondary">{primaryLabel(node)}</span>
|
|
67
|
-
{/if}
|
|
68
|
-
{#if node.role}
|
|
69
|
-
<span class="truncate text-[11px] text-muted">{node.role}</span>
|
|
70
|
-
{/if}
|
|
71
|
-
{#if node.assignees && node.assignees.length > 1}
|
|
72
|
-
<span class="text-[10px] text-muted">+{node.assignees.length - 1} más</span>
|
|
73
|
-
{/if}
|
|
74
|
-
</button>
|
|
75
|
-
{/snippet}
|
|
76
|
-
|
|
77
|
-
{#snippet tree(node: OrgNode)}
|
|
78
|
-
<div class="flex flex-col items-center">
|
|
79
|
-
<div class="shrink-0" style:width="{CARD_W}px">
|
|
80
|
-
{@render nodeCard(node)}
|
|
81
|
-
</div>
|
|
82
|
-
{#if node.children?.length}
|
|
83
|
-
<div class="h-6 w-0.5 shrink-0 bg-border-strong" aria-hidden="true"></div>
|
|
84
|
-
{#if node.children.length === 1}
|
|
85
|
-
{@render tree(node.children[0])}
|
|
86
|
-
{:else}
|
|
87
|
-
<div
|
|
88
|
-
class="relative inline-flex items-start justify-center gap-4"
|
|
89
|
-
style:--siblings={node.children.length}
|
|
90
|
-
>
|
|
91
|
-
<div
|
|
92
|
-
class="pointer-events-none absolute top-0 h-0.5 bg-border-strong"
|
|
93
|
-
style:left="calc(50% / var(--siblings))"
|
|
94
|
-
style:right="calc(50% / var(--siblings))"
|
|
95
|
-
aria-hidden="true"
|
|
96
|
-
></div>
|
|
97
|
-
{#each node.children as child (child.id)}
|
|
98
|
-
<div class="flex shrink-0 flex-col items-center" style:min-width="{CARD_W}px">
|
|
99
|
-
<div class="h-4 w-0.5 shrink-0 bg-border-strong" aria-hidden="true"></div>
|
|
100
|
-
{@render tree(child)}
|
|
101
|
-
</div>
|
|
102
|
-
{/each}
|
|
103
|
-
</div>
|
|
104
|
-
{/if}
|
|
105
|
-
{/if}
|
|
47
|
+
<OrgChartTree {root} {onselect} />
|
|
48
|
+
</PanZoomViewport>
|
|
49
|
+
{:else if root}
|
|
50
|
+
<div class={['overflow-x-auto py-4', className]} role="tree" aria-label="Organization chart">
|
|
51
|
+
<OrgChartTree {root} {onselect} />
|
|
106
52
|
</div>
|
|
107
|
-
{
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
{#if root}
|
|
111
|
-
<div class="flex min-w-max justify-center px-4 pb-2">
|
|
112
|
-
{@render tree(root)}
|
|
113
|
-
</div>
|
|
114
|
-
{:else}
|
|
115
|
-
<p class="py-8 text-center text-sm text-muted">No organization data</p>
|
|
116
|
-
{/if}
|
|
117
|
-
</div>
|
|
53
|
+
{:else}
|
|
54
|
+
<p class={['py-8 text-center text-sm text-muted', className]}>No organization data</p>
|
|
55
|
+
{/if}
|
|
@@ -14,6 +14,12 @@ interface OrgChartProps {
|
|
|
14
14
|
root?: OrgNode | null;
|
|
15
15
|
class?: string;
|
|
16
16
|
onselect?: (node: OrgNode) => void;
|
|
17
|
+
/** Pan + wheel zoom viewport */
|
|
18
|
+
interactive?: boolean;
|
|
19
|
+
viewportHeight?: string;
|
|
20
|
+
showControls?: boolean;
|
|
21
|
+
/** Refit viewport when filter / data changes */
|
|
22
|
+
contentKey?: string | number | null;
|
|
17
23
|
}
|
|
18
24
|
declare const OrgChart: import("svelte").Component<OrgChartProps, {}, "">;
|
|
19
25
|
type OrgChart = ReturnType<typeof OrgChart>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import OrgChartNode from './OrgChartNode.svelte';
|
|
3
|
+
import { getPanZoomContext } from '../PanZoomViewport/panZoomContext.js';
|
|
4
|
+
import type { OrgNode } from './OrgChart.svelte';
|
|
5
|
+
|
|
6
|
+
interface OrgChartNodeProps {
|
|
7
|
+
node: OrgNode;
|
|
8
|
+
onselect?: (node: OrgNode) => void;
|
|
9
|
+
selectedId?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { node, onselect, selectedId = $bindable(null) }: OrgChartNodeProps = $props();
|
|
13
|
+
|
|
14
|
+
const panZoom = getPanZoomContext();
|
|
15
|
+
const CARD_W = 176;
|
|
16
|
+
|
|
17
|
+
let rowEl = $state<HTMLDivElement | null>(null);
|
|
18
|
+
let line = $state<{ left: number; width: number } | null>(null);
|
|
19
|
+
|
|
20
|
+
function initials(name: string) {
|
|
21
|
+
return name
|
|
22
|
+
.split(/\s+/)
|
|
23
|
+
.slice(0, 2)
|
|
24
|
+
.map((p) => p[0]?.toUpperCase() ?? '')
|
|
25
|
+
.join('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function primaryAvatar(n: OrgNode) {
|
|
29
|
+
return n.avatarUrl ?? n.assignees?.[0]?.avatarUrl ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function primaryLabel(n: OrgNode) {
|
|
33
|
+
if (n.assignees?.length === 1) return n.assignees[0].name;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function selectNode(n: OrgNode) {
|
|
38
|
+
if (panZoom?.consumeClick()) return;
|
|
39
|
+
selectedId = n.id;
|
|
40
|
+
onselect?.(n);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function measureLine() {
|
|
44
|
+
const children = node.children ?? [];
|
|
45
|
+
if (!rowEl || children.length < 2) {
|
|
46
|
+
line = null;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const cols = [...rowEl.querySelectorAll<HTMLElement>('[data-org-col]')];
|
|
50
|
+
if (cols.length < 2) {
|
|
51
|
+
line = null;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const rowRect = rowEl.getBoundingClientRect();
|
|
55
|
+
const first = cols[0].getBoundingClientRect();
|
|
56
|
+
const last = cols[cols.length - 1].getBoundingClientRect();
|
|
57
|
+
const left = first.left + first.width / 2 - rowRect.left;
|
|
58
|
+
const right = last.left + last.width / 2 - rowRect.left;
|
|
59
|
+
line = { left, width: Math.max(0, right - left) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
$effect(() => {
|
|
63
|
+
node.children?.length;
|
|
64
|
+
if (!rowEl) return;
|
|
65
|
+
measureLine();
|
|
66
|
+
const ro = new ResizeObserver(() => measureLine());
|
|
67
|
+
ro.observe(rowEl);
|
|
68
|
+
for (const col of rowEl.querySelectorAll('[data-org-col]')) ro.observe(col);
|
|
69
|
+
return () => ro.disconnect();
|
|
70
|
+
});
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<div class="flex flex-col items-center">
|
|
74
|
+
<div class="shrink-0" style:width="{CARD_W}px">
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
class={[
|
|
78
|
+
'flex w-full flex-col items-center gap-1.5 rounded-xl border bg-surface-elevated px-3 py-2.5 text-center shadow-sm transition-all',
|
|
79
|
+
selectedId === node.id
|
|
80
|
+
? 'border-brand-500 ring-2 ring-brand-500/30'
|
|
81
|
+
: 'border-border hover:border-brand-400'
|
|
82
|
+
]}
|
|
83
|
+
onclick={() => selectNode(node)}
|
|
84
|
+
>
|
|
85
|
+
{#if primaryAvatar(node)}
|
|
86
|
+
<img src={primaryAvatar(node)!} alt="" class="h-9 w-9 rounded-full object-cover" />
|
|
87
|
+
{:else}
|
|
88
|
+
<span
|
|
89
|
+
class="flex h-9 w-9 items-center justify-center rounded-full bg-brand-500/15 text-xs font-semibold text-brand-600 dark:text-brand-300"
|
|
90
|
+
>
|
|
91
|
+
{initials(node.name)}
|
|
92
|
+
</span>
|
|
93
|
+
{/if}
|
|
94
|
+
<span class="line-clamp-2 text-sm leading-snug font-semibold text-primary">{node.name}</span>
|
|
95
|
+
{#if primaryLabel(node)}
|
|
96
|
+
<span class="truncate text-xs text-secondary">{primaryLabel(node)}</span>
|
|
97
|
+
{/if}
|
|
98
|
+
{#if node.role}
|
|
99
|
+
<span class="truncate text-[11px] text-muted">{node.role}</span>
|
|
100
|
+
{/if}
|
|
101
|
+
{#if node.assignees && node.assignees.length > 1}
|
|
102
|
+
<span class="text-[10px] text-muted">+{node.assignees.length - 1} más</span>
|
|
103
|
+
{/if}
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{#if node.children?.length}
|
|
108
|
+
<div class="h-6 w-0.5 shrink-0 bg-border-strong" aria-hidden="true"></div>
|
|
109
|
+
{#if node.children.length === 1}
|
|
110
|
+
<OrgChartNode node={node.children[0]} {onselect} bind:selectedId />
|
|
111
|
+
{:else}
|
|
112
|
+
<div bind:this={rowEl} class="relative inline-flex items-start justify-center gap-4">
|
|
113
|
+
{#if line}
|
|
114
|
+
<div
|
|
115
|
+
class="pointer-events-none absolute top-0 h-0.5 bg-border-strong"
|
|
116
|
+
style:left="{line.left}px"
|
|
117
|
+
style:width="{line.width}px"
|
|
118
|
+
aria-hidden="true"
|
|
119
|
+
></div>
|
|
120
|
+
{/if}
|
|
121
|
+
{#each node.children as child (child.id)}
|
|
122
|
+
<div data-org-col class="flex shrink-0 flex-col items-center" style:min-width="{CARD_W}px">
|
|
123
|
+
<div class="h-4 w-0.5 shrink-0 bg-border-strong" aria-hidden="true"></div>
|
|
124
|
+
<OrgChartNode node={child} {onselect} bind:selectedId />
|
|
125
|
+
</div>
|
|
126
|
+
{/each}
|
|
127
|
+
</div>
|
|
128
|
+
{/if}
|
|
129
|
+
{/if}
|
|
130
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import OrgChartNode from './OrgChartNode.svelte';
|
|
2
|
+
import type { OrgNode } from './OrgChart.svelte';
|
|
3
|
+
interface OrgChartNodeProps {
|
|
4
|
+
node: OrgNode;
|
|
5
|
+
onselect?: (node: OrgNode) => void;
|
|
6
|
+
selectedId?: string | null;
|
|
7
|
+
}
|
|
8
|
+
declare const OrgChartNode: import("svelte").Component<OrgChartNodeProps, {}, "selectedId">;
|
|
9
|
+
type OrgChartNode = ReturnType<typeof OrgChartNode>;
|
|
10
|
+
export default OrgChartNode;
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
};
|
|
30
30
|
</script>
|
|
31
31
|
|
|
32
|
-
<div class="rounded-2xl border border-border bg-surface-elevated p-
|
|
33
|
-
<p class="mb-
|
|
34
|
-
<OrgChart {root} />
|
|
32
|
+
<div class="rounded-2xl border border-border bg-surface-elevated p-2">
|
|
33
|
+
<p class="mb-2 px-2 text-sm font-semibold text-primary">Organization</p>
|
|
34
|
+
<OrgChart {root} viewportHeight="480px" />
|
|
35
35
|
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import OrgChartNode from './OrgChartNode.svelte';
|
|
3
|
+
import type { OrgNode } from './OrgChart.svelte';
|
|
4
|
+
|
|
5
|
+
interface OrgChartTreeProps {
|
|
6
|
+
root: OrgNode;
|
|
7
|
+
onselect?: (node: OrgNode) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { root, onselect }: OrgChartTreeProps = $props();
|
|
11
|
+
let selectedId = $state<string | null>(null);
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<div class="flex min-w-max justify-center px-4 py-4" role="tree" aria-label="Organization chart">
|
|
15
|
+
<OrgChartNode node={root} {onselect} bind:selectedId />
|
|
16
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { OrgNode } from './OrgChart.svelte';
|
|
2
|
+
interface OrgChartTreeProps {
|
|
3
|
+
root: OrgNode;
|
|
4
|
+
onselect?: (node: OrgNode) => void;
|
|
5
|
+
}
|
|
6
|
+
declare const OrgChartTree: import("svelte").Component<OrgChartTreeProps, {}, "">;
|
|
7
|
+
type OrgChartTree = ReturnType<typeof OrgChartTree>;
|
|
8
|
+
export default OrgChartTree;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { setContext } from 'svelte';
|
|
4
|
+
import IconButton from '../../atoms/IconButton/IconButton.svelte';
|
|
5
|
+
import FullscreenToggle from '../FullscreenToggle/FullscreenToggle.svelte';
|
|
6
|
+
import { PAN_ZOOM_CTX, type PanZoomContext } from './panZoomContext.js';
|
|
7
|
+
|
|
8
|
+
interface PanZoomViewportProps {
|
|
9
|
+
minZoom?: number;
|
|
10
|
+
maxZoom?: number;
|
|
11
|
+
initialFit?: boolean;
|
|
12
|
+
showControls?: boolean;
|
|
13
|
+
showFullscreen?: boolean;
|
|
14
|
+
height?: string;
|
|
15
|
+
contentKey?: string | number | null;
|
|
16
|
+
class?: string;
|
|
17
|
+
children?: Snippet;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
minZoom = 0.25,
|
|
22
|
+
maxZoom = 2,
|
|
23
|
+
initialFit = true,
|
|
24
|
+
showControls = true,
|
|
25
|
+
showFullscreen = true,
|
|
26
|
+
height = 'min(70vh, 640px)',
|
|
27
|
+
contentKey = null,
|
|
28
|
+
class: className = '',
|
|
29
|
+
children
|
|
30
|
+
}: PanZoomViewportProps = $props();
|
|
31
|
+
|
|
32
|
+
let scale = $state(1);
|
|
33
|
+
let panX = $state(0);
|
|
34
|
+
let panY = $state(0);
|
|
35
|
+
let dragging = $state(false);
|
|
36
|
+
let moved = $state(false);
|
|
37
|
+
let suppressClick = $state(false);
|
|
38
|
+
let pointerId = $state<number | null>(null);
|
|
39
|
+
let startX = 0;
|
|
40
|
+
let startY = 0;
|
|
41
|
+
let originPanX = 0;
|
|
42
|
+
let originPanY = 0;
|
|
43
|
+
let fullscreen = $state(false);
|
|
44
|
+
|
|
45
|
+
let shellEl = $state<HTMLDivElement | null>(null);
|
|
46
|
+
let viewportEl = $state<HTMLDivElement | null>(null);
|
|
47
|
+
let contentEl = $state<HTMLDivElement | null>(null);
|
|
48
|
+
|
|
49
|
+
const MOVE_THRESHOLD = 6;
|
|
50
|
+
const FIT_PADDING = 32;
|
|
51
|
+
|
|
52
|
+
const shellHeight = $derived(fullscreen ? '100%' : height);
|
|
53
|
+
|
|
54
|
+
function clampZoom(z: number) {
|
|
55
|
+
return Math.min(maxZoom, Math.max(minZoom, z));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function fitToContent() {
|
|
59
|
+
if (!viewportEl || !contentEl) return;
|
|
60
|
+
const vw = viewportEl.clientWidth;
|
|
61
|
+
const vh = viewportEl.clientHeight;
|
|
62
|
+
const cw = contentEl.scrollWidth;
|
|
63
|
+
const ch = contentEl.scrollHeight;
|
|
64
|
+
if (!vw || !vh || !cw || !ch) return;
|
|
65
|
+
|
|
66
|
+
const next = clampZoom(Math.min((vw - FIT_PADDING) / cw, (vh - FIT_PADDING) / ch, 1));
|
|
67
|
+
scale = next;
|
|
68
|
+
panX = (vw - cw * next) / 2;
|
|
69
|
+
panY = (vh - ch * next) / 2;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function zoomAt(nextScale: number, clientX?: number, clientY?: number) {
|
|
73
|
+
if (!viewportEl) return;
|
|
74
|
+
const prev = scale;
|
|
75
|
+
const next = clampZoom(nextScale);
|
|
76
|
+
if (next === prev) return;
|
|
77
|
+
|
|
78
|
+
const rect = viewportEl.getBoundingClientRect();
|
|
79
|
+
const px = clientX != null ? clientX - rect.left : rect.width / 2;
|
|
80
|
+
const py = clientY != null ? clientY - rect.top : rect.height / 2;
|
|
81
|
+
|
|
82
|
+
panX = px - ((px - panX) * next) / prev;
|
|
83
|
+
panY = py - ((py - panY) * next) / prev;
|
|
84
|
+
scale = next;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function zoomIn() {
|
|
88
|
+
zoomAt(scale * 1.2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function zoomOut() {
|
|
92
|
+
zoomAt(scale / 1.2);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onWheel(e: WheelEvent) {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
const delta = e.deltaY > 0 ? -0.12 : 0.12;
|
|
98
|
+
zoomAt(scale + delta * scale, e.clientX, e.clientY);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onPointerDown(e: PointerEvent) {
|
|
102
|
+
if (e.button !== 0) return;
|
|
103
|
+
if ((e.target as HTMLElement).closest('[data-panzoom-chrome]')) return;
|
|
104
|
+
|
|
105
|
+
dragging = true;
|
|
106
|
+
moved = false;
|
|
107
|
+
pointerId = e.pointerId;
|
|
108
|
+
startX = e.clientX;
|
|
109
|
+
startY = e.clientY;
|
|
110
|
+
originPanX = panX;
|
|
111
|
+
originPanY = panY;
|
|
112
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function onPointerMove(e: PointerEvent) {
|
|
116
|
+
if (!dragging || pointerId !== e.pointerId) return;
|
|
117
|
+
const dx = e.clientX - startX;
|
|
118
|
+
const dy = e.clientY - startY;
|
|
119
|
+
if (!moved && Math.hypot(dx, dy) >= MOVE_THRESHOLD) moved = true;
|
|
120
|
+
panX = originPanX + dx;
|
|
121
|
+
panY = originPanY + dy;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onPointerUp(e: PointerEvent) {
|
|
125
|
+
if (!dragging || pointerId !== e.pointerId) return;
|
|
126
|
+
dragging = false;
|
|
127
|
+
pointerId = null;
|
|
128
|
+
try {
|
|
129
|
+
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
|
130
|
+
} catch {
|
|
131
|
+
/* already released */
|
|
132
|
+
}
|
|
133
|
+
if (moved) suppressClick = true;
|
|
134
|
+
moved = false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onDoubleClick(e: MouseEvent) {
|
|
138
|
+
if ((e.target as HTMLElement).closest('[data-panzoom-chrome]')) return;
|
|
139
|
+
fitToContent();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function onFullscreenChange(active: boolean) {
|
|
143
|
+
fullscreen = active;
|
|
144
|
+
requestAnimationFrame(() => fitToContent());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ctx: PanZoomContext = {
|
|
148
|
+
consumeClick: () => {
|
|
149
|
+
if (!suppressClick) return false;
|
|
150
|
+
suppressClick = false;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
setContext(PAN_ZOOM_CTX, ctx);
|
|
156
|
+
|
|
157
|
+
$effect(() => {
|
|
158
|
+
contentKey;
|
|
159
|
+
if (!initialFit) return;
|
|
160
|
+
const id = requestAnimationFrame(() => {
|
|
161
|
+
requestAnimationFrame(() => fitToContent());
|
|
162
|
+
});
|
|
163
|
+
return () => cancelAnimationFrame(id);
|
|
164
|
+
});
|
|
165
|
+
</script>
|
|
166
|
+
|
|
167
|
+
<div
|
|
168
|
+
bind:this={shellEl}
|
|
169
|
+
class={[
|
|
170
|
+
'relative overflow-hidden rounded-xl border border-border bg-surface-base/40',
|
|
171
|
+
fullscreen && 'rounded-none border-0',
|
|
172
|
+
className
|
|
173
|
+
]}
|
|
174
|
+
style:height={shellHeight}
|
|
175
|
+
>
|
|
176
|
+
{#if showControls}
|
|
177
|
+
<div
|
|
178
|
+
class="absolute top-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-border bg-surface-elevated/95 p-1 shadow-sm backdrop-blur-sm"
|
|
179
|
+
data-panzoom-chrome
|
|
180
|
+
>
|
|
181
|
+
<IconButton label="Alejar" size="sm" variant="ghost" onclick={zoomOut}>
|
|
182
|
+
<span class="text-base leading-none font-medium">−</span>
|
|
183
|
+
</IconButton>
|
|
184
|
+
<span class="min-w-[3rem] px-1 text-center text-xs tabular-nums text-secondary">
|
|
185
|
+
{Math.round(scale * 100)}%
|
|
186
|
+
</span>
|
|
187
|
+
<IconButton label="Acercar" size="sm" variant="ghost" onclick={zoomIn}>
|
|
188
|
+
<span class="text-base leading-none font-medium">+</span>
|
|
189
|
+
</IconButton>
|
|
190
|
+
<IconButton label="Ajustar a vista" size="sm" variant="ghost" onclick={fitToContent}>
|
|
191
|
+
<span class="text-xs font-semibold">⊡</span>
|
|
192
|
+
</IconButton>
|
|
193
|
+
{#if showFullscreen}
|
|
194
|
+
<FullscreenToggle
|
|
195
|
+
target={shellEl}
|
|
196
|
+
size="sm"
|
|
197
|
+
bind:active={fullscreen}
|
|
198
|
+
enterLabel="Pantalla completa"
|
|
199
|
+
exitLabel="Salir de pantalla completa"
|
|
200
|
+
onchange={onFullscreenChange}
|
|
201
|
+
/>
|
|
202
|
+
{/if}
|
|
203
|
+
</div>
|
|
204
|
+
{/if}
|
|
205
|
+
|
|
206
|
+
<div
|
|
207
|
+
bind:this={viewportEl}
|
|
208
|
+
class={[
|
|
209
|
+
'h-full w-full touch-none select-none',
|
|
210
|
+
dragging ? 'cursor-grabbing' : 'cursor-grab'
|
|
211
|
+
]}
|
|
212
|
+
onwheel={onWheel}
|
|
213
|
+
onpointerdown={onPointerDown}
|
|
214
|
+
onpointermove={onPointerMove}
|
|
215
|
+
onpointerup={onPointerUp}
|
|
216
|
+
onpointercancel={onPointerUp}
|
|
217
|
+
ondblclick={onDoubleClick}
|
|
218
|
+
role="presentation"
|
|
219
|
+
>
|
|
220
|
+
<div
|
|
221
|
+
bind:this={contentEl}
|
|
222
|
+
class="inline-block will-change-transform"
|
|
223
|
+
style:transform="translate({panX}px, {panY}px) scale({scale})"
|
|
224
|
+
style:transform-origin="0 0"
|
|
225
|
+
>
|
|
226
|
+
{#if children}
|
|
227
|
+
{@render children()}
|
|
228
|
+
{/if}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface PanZoomViewportProps {
|
|
3
|
+
minZoom?: number;
|
|
4
|
+
maxZoom?: number;
|
|
5
|
+
initialFit?: boolean;
|
|
6
|
+
showControls?: boolean;
|
|
7
|
+
showFullscreen?: boolean;
|
|
8
|
+
height?: string;
|
|
9
|
+
contentKey?: string | number | null;
|
|
10
|
+
class?: string;
|
|
11
|
+
children?: Snippet;
|
|
12
|
+
}
|
|
13
|
+
declare const PanZoomViewport: import("svelte").Component<PanZoomViewportProps, {
|
|
14
|
+
fitToContent: () => void;
|
|
15
|
+
}, "">;
|
|
16
|
+
type PanZoomViewport = ReturnType<typeof PanZoomViewport>;
|
|
17
|
+
export default PanZoomViewport;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import PanZoomViewport from './PanZoomViewport.svelte';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<PanZoomViewport height="420px">
|
|
6
|
+
<div class="flex min-w-max flex-col items-center gap-8 p-12">
|
|
7
|
+
<div class="rounded-xl border border-border bg-surface-elevated px-6 py-4 text-sm font-semibold">
|
|
8
|
+
Arrastra para mover · rueda para zoom · doble clic para ajustar
|
|
9
|
+
</div>
|
|
10
|
+
<div class="flex gap-16">
|
|
11
|
+
{#each Array.from({ length: 8 }) as _, i (i)}
|
|
12
|
+
<div
|
|
13
|
+
class="flex h-24 w-40 items-center justify-center rounded-xl border border-border bg-surface-elevated text-sm"
|
|
14
|
+
>
|
|
15
|
+
Bloque {i + 1}
|
|
16
|
+
</div>
|
|
17
|
+
{/each}
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</PanZoomViewport>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
2
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
3
|
+
$$bindings?: Bindings;
|
|
4
|
+
} & Exports;
|
|
5
|
+
(internal: unknown, props: {
|
|
6
|
+
$$events?: Events;
|
|
7
|
+
$$slots?: Slots;
|
|
8
|
+
}): Exports & {
|
|
9
|
+
$set?: any;
|
|
10
|
+
$on?: any;
|
|
11
|
+
};
|
|
12
|
+
z_$$bindings?: Bindings;
|
|
13
|
+
}
|
|
14
|
+
declare const PanZoomViewportStory: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type PanZoomViewportStory = InstanceType<typeof PanZoomViewportStory>;
|
|
18
|
+
export default PanZoomViewportStory;
|
package/dist/index.d.ts
CHANGED
|
@@ -252,6 +252,8 @@ export { default as VirtualList } from './components/molecules/VirtualList/Virtu
|
|
|
252
252
|
export { default as MasonryGrid } from './components/molecules/MasonryGrid/MasonryGrid.svelte';
|
|
253
253
|
export { default as OrgChart } from './components/molecules/OrgChart/OrgChart.svelte';
|
|
254
254
|
export type { OrgNode } from './components/molecules/OrgChart/OrgChart.svelte';
|
|
255
|
+
export { default as PanZoomViewport } from './components/molecules/PanZoomViewport/PanZoomViewport.svelte';
|
|
256
|
+
export { getPanZoomContext, PAN_ZOOM_CTX, type PanZoomContext } from './components/molecules/PanZoomViewport/panZoomContext.js';
|
|
255
257
|
export { default as TableToolbar } from './components/molecules/TableToolbar/TableToolbar.svelte';
|
|
256
258
|
export type { TableColumnOption, TableDensity } from './components/molecules/TableToolbar/TableToolbar.svelte';
|
|
257
259
|
export { default as ButtonGroup } from './components/molecules/ButtonGroup/ButtonGroup.svelte';
|
package/dist/index.js
CHANGED
|
@@ -194,6 +194,8 @@ export { default as MessageList } from './components/molecules/MessageList/Messa
|
|
|
194
194
|
export { default as VirtualList } from './components/molecules/VirtualList/VirtualList.svelte';
|
|
195
195
|
export { default as MasonryGrid } from './components/molecules/MasonryGrid/MasonryGrid.svelte';
|
|
196
196
|
export { default as OrgChart } from './components/molecules/OrgChart/OrgChart.svelte';
|
|
197
|
+
export { default as PanZoomViewport } from './components/molecules/PanZoomViewport/PanZoomViewport.svelte';
|
|
198
|
+
export { getPanZoomContext, PAN_ZOOM_CTX } from './components/molecules/PanZoomViewport/panZoomContext.js';
|
|
197
199
|
export { default as TableToolbar } from './components/molecules/TableToolbar/TableToolbar.svelte';
|
|
198
200
|
export { default as ButtonGroup } from './components/molecules/ButtonGroup/ButtonGroup.svelte';
|
|
199
201
|
export { default as ThemeToggle } from './components/molecules/ThemeToggle/ThemeToggle.svelte';
|