svelte-tiler 0.0.1

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.
@@ -0,0 +1,22 @@
1
+ export function getEdgePart(a, b, x, ratio) {
2
+ if (x < a || x > b) {
3
+ return undefined;
4
+ }
5
+ x -= a;
6
+ const len = b - a;
7
+ const start = len * ratio;
8
+ if (x < start) {
9
+ return 'start';
10
+ }
11
+ const end = len * (1 - ratio);
12
+ if (x > end) {
13
+ return 'end';
14
+ }
15
+ return 'center';
16
+ }
17
+ export function getRectParts(rect, x, y, ratio) {
18
+ return {
19
+ hpart: getEdgePart(rect.left, rect.right, x, ratio),
20
+ vpart: getEdgePart(rect.top, rect.bottom, y, ratio),
21
+ };
22
+ }
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import {
3
+ setTilerContext,
4
+ TilerContext,
5
+ type TilerContextOptions,
6
+ } from './context.svelte.js';
7
+ import type { Tile } from './model.js';
8
+ import Panel from './panel.svelte';
9
+
10
+ let {
11
+ layout = $bindable(),
12
+ ...rest
13
+ }: { layout: Tile | undefined } & TilerContextOptions = $props();
14
+
15
+ setTilerContext(new TilerContext(rest));
16
+ </script>
17
+
18
+ <Panel bind:layout />
@@ -0,0 +1,8 @@
1
+ import { type TilerContextOptions } from './context.svelte.ts';
2
+ import type { Tile } from './model.js';
3
+ type $$ComponentProps = {
4
+ layout: Tile | undefined;
5
+ } & TilerContextOptions;
6
+ declare const Tiler: import("svelte").Component<$$ComponentProps, {}, "layout">;
7
+ type Tiler = ReturnType<typeof Tiler>;
8
+ export default Tiler;
@@ -0,0 +1,43 @@
1
+ <script lang="ts" module>
2
+ import { createContext, type Snippet } from 'svelte';
3
+
4
+ import type { Registry } from '../shared/registry.js';
5
+ import type { TileProps, Tiles } from '../model.js';
6
+
7
+ declare module '../model.js' {
8
+ interface TileRegistry {
9
+ leaf: {
10
+ name: string;
11
+ };
12
+ }
13
+ }
14
+
15
+ type LeafContext<N extends string = string> = Registry<
16
+ N,
17
+ Snippet<[Tiles['leaf']]> | undefined
18
+ >;
19
+
20
+ const [getContext, setContext] = createContext<LeafContext>();
21
+
22
+ export function setup<N extends string>(leafs: LeafContext<N>) {
23
+ setContext(leafs);
24
+ return (name: N): Tiles['leaf'] => ({
25
+ id: crypto.randomUUID(),
26
+ type: 'leaf',
27
+ name,
28
+ children: [],
29
+ });
30
+ }
31
+
32
+ export function onRemoveChild() {}
33
+
34
+ export function onClear() {}
35
+ </script>
36
+
37
+ <script lang="ts">
38
+ const leafCtx = getContext();
39
+
40
+ let { tile = $bindable() }: TileProps<'leaf'> = $props();
41
+ </script>
42
+
43
+ {@render leafCtx.get(tile.name)?.(tile)}
@@ -0,0 +1,17 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { Registry } from '../shared/registry.js';
3
+ import type { TileProps, Tiles } from '../model.js';
4
+ declare module '../model.js' {
5
+ interface TileRegistry {
6
+ leaf: {
7
+ name: string;
8
+ };
9
+ }
10
+ }
11
+ type LeafContext<N extends string = string> = Registry<N, Snippet<[Tiles['leaf']]> | undefined>;
12
+ export declare function setup<N extends string>(leafs: LeafContext<N>): (name: N) => Tiles["leaf"];
13
+ export declare function onRemoveChild(): void;
14
+ export declare function onClear(): void;
15
+ declare const Leaf: import("svelte").Component<TileProps<"leaf">, {}, "tile">;
16
+ type Leaf = ReturnType<typeof Leaf>;
17
+ export default Leaf;
@@ -0,0 +1,334 @@
1
+ <script lang="ts" module>
2
+ import { getContext, setContext, tick, type Snippet } from 'svelte';
3
+
4
+ import type { Registry } from '../shared/registry.js';
5
+ import { DndContext, Draggable } from '../shared/dnd.svelte.js';
6
+ import {
7
+ normalize,
8
+ type Constraint,
9
+ type NormalizedConstraints,
10
+ } from '../shared/constraints.js';
11
+ import type { Direction } from '../shared/spatial.js';
12
+ import { almostEqual } from '../shared/math.js';
13
+ import type { Tile, TileProps, Tiles } from '../model.js';
14
+ import type { TilerContext } from '../context.svelte.js';
15
+ import { TileDroppable } from '../dnd.js';
16
+
17
+ declare module '../model.js' {
18
+ interface TileRegistry {
19
+ split: {
20
+ constraints: Array<Constraint[]>;
21
+ weights: number[];
22
+ direction: Direction;
23
+ resizer?: string;
24
+ gapPx: number;
25
+ };
26
+ }
27
+ }
28
+
29
+ export interface SplitTileOptions {
30
+ constraints?: Constraint[];
31
+ weight?: number;
32
+ tile: Tile;
33
+ }
34
+
35
+ export interface SplitOptions<R extends string> {
36
+ children: SplitTileOptions[];
37
+ resizer?: R;
38
+ /** @default "row" */
39
+ direction?: Direction;
40
+ /** @default 0 */
41
+ gapPx?: number;
42
+ }
43
+
44
+ export function create<R extends string>(
45
+ options: SplitOptions<R>
46
+ ): Tiles['split'] {
47
+ const children: Tile[] = [];
48
+ const weights: number[] = [];
49
+ const constraints: Array<Constraint[]> = [];
50
+ for (const c of options.children) {
51
+ children.push(c.tile);
52
+ weights.push(c.weight ?? 1);
53
+ constraints.push(c.constraints ?? []);
54
+ }
55
+ return {
56
+ id: crypto.randomUUID(),
57
+ type: 'split',
58
+ children,
59
+ weights,
60
+ constraints,
61
+ direction: options.direction ?? 'row',
62
+ resizer: options.resizer,
63
+ gapPx: options.gapPx ?? 0,
64
+ };
65
+ }
66
+
67
+ const SPLIT_CONTEXT_KEY = Symbol('split-context-key');
68
+
69
+ type SplitContext<R extends string = string> = {
70
+ resizer?: Registry<
71
+ R,
72
+ Snippet<[Draggable, Tiles['split'], number]> | undefined
73
+ >;
74
+ };
75
+
76
+ export function setup<R extends string>(ctx: SplitContext<R>) {
77
+ setContext(SPLIT_CONTEXT_KEY, ctx);
78
+ return create<R>;
79
+ }
80
+
81
+ export function onRemoveChild(
82
+ ctx: TilerContext,
83
+ tile: Tiles['split'],
84
+ i: number
85
+ ) {
86
+ if (tile.children.length === 2) {
87
+ const droppable =
88
+ ctx.dnd.targetId && ctx.dnd.droppables.get(ctx.dnd.targetId);
89
+ if (
90
+ !droppable ||
91
+ (droppable instanceof TileDroppable &&
92
+ tile.children.every((c) => c.id !== droppable.targetTileId))
93
+ ) {
94
+ tick().then(() => {
95
+ ctx.replaceWith(tile, tile.children[1 - i]);
96
+ });
97
+ return;
98
+ }
99
+ }
100
+ if (tile.children.length > 1) {
101
+ tile.children.splice(i, 1);
102
+ tile.weights.splice(i, 1);
103
+ tile.constraints.splice(i, 1);
104
+ return;
105
+ }
106
+ ctx.destroy(tile);
107
+ }
108
+
109
+ export function onClear(ctx: TilerContext, tile: Tiles['split']) {
110
+ if (tile.children.length > 0) {
111
+ ctx.replaceWith(tile, tile.children[0]);
112
+ }
113
+ }
114
+
115
+ export function insertTile(
116
+ node: Tiles['split'],
117
+ index: number,
118
+ { tile, constraints = [], weight = 1 }: SplitTileOptions
119
+ ) {
120
+ node.children.splice(index, 0, tile);
121
+ node.constraints.splice(index, 0, constraints);
122
+ node.weights.splice(index, 0, weight);
123
+ }
124
+ </script>
125
+
126
+ <script lang="ts">
127
+ let { tile = $bindable(), child }: TileProps<'split'> = $props();
128
+
129
+ const splitCtx = getContext<SplitContext | undefined>(SPLIT_CONTEXT_KEY);
130
+ const dndCtx = new DndContext();
131
+
132
+ const resizerSnippet = $derived(
133
+ (tile.resizer !== undefined && splitCtx?.resizer?.get(tile.resizer)) ||
134
+ undefined
135
+ );
136
+
137
+ let splitEl: HTMLDivElement;
138
+ let resizerEl: HTMLElement;
139
+
140
+ const isRow = $derived(tile.direction === 'row');
141
+ let currentDir = 0;
142
+ let lastDir = 0;
143
+ let startPos = 0;
144
+ let previousPos = 0;
145
+ let containerSize = 0;
146
+ let remaining = 0;
147
+ let totalWeight = 0;
148
+ let len = 0;
149
+ let constraints: NormalizedConstraints[] = [];
150
+
151
+ let lastWeights: number[] = [];
152
+ let nextLayout: number[] = [];
153
+
154
+ class DraggableResizer extends Draggable {
155
+ #index = 0;
156
+
157
+ constructor(ctx: DndContext, index: number) {
158
+ super(ctx);
159
+ this.#index = index;
160
+ }
161
+
162
+ protected onStart(e: PointerEvent, el: HTMLElement): void {
163
+ resizerEl = el;
164
+ currentDir = 0;
165
+ lastDir = 0;
166
+ startPos = isRow ? e.pageX : e.pageY;
167
+ previousPos = startPos;
168
+ this.syncWeights();
169
+ remaining = 0;
170
+ totalWeight = tile.weights.reduce((a, b) => a + b);
171
+ len = tile.weights.length;
172
+
173
+ containerSize =
174
+ (isRow ? splitEl.clientWidth : splitEl.clientHeight) -
175
+ (len - 1) * tile.gapPx;
176
+ constraints = tile.constraints.map((constraints) =>
177
+ normalize({
178
+ constraints,
179
+ targetUnit: 'weight',
180
+ totalSizePercent: 100,
181
+ totalSizePx: containerSize,
182
+ totalWeight: totalWeight,
183
+ })
184
+ );
185
+ }
186
+
187
+ protected onMove(e: PointerEvent) {
188
+ const currentPos = isRow ? e.pageX : e.pageY;
189
+ currentDir = Math.sign(currentPos - previousPos);
190
+ if (currentDir === 0) {
191
+ return;
192
+ }
193
+ const resizerRect = resizerEl.getBoundingClientRect();
194
+ if (
195
+ isRow
196
+ ? currentDir < 0
197
+ ? currentPos < resizerRect.right
198
+ : currentPos > resizerRect.left
199
+ : currentDir < 0
200
+ ? currentPos < resizerRect.bottom
201
+ : currentPos > resizerRect.top
202
+ ) {
203
+ if (currentDir !== lastDir) {
204
+ startPos = previousPos;
205
+ this.syncWeights();
206
+ lastDir = currentDir;
207
+ }
208
+ const deltaWeight = Math.abs(
209
+ ((currentPos - startPos) * totalWeight) / containerSize
210
+ );
211
+ if (deltaWeight > 0) {
212
+ remaining = deltaWeight;
213
+ this.adjustBy('shrink');
214
+ remaining = deltaWeight - remaining;
215
+ if (remaining > 0) {
216
+ currentDir *= -1;
217
+ this.adjustBy('expand');
218
+ }
219
+ const total = nextLayout.reduce((a, b) => a + b);
220
+ if (almostEqual(totalWeight, total)) {
221
+ for (let j = 0; j < len; j++) {
222
+ tile.weights[j] = nextLayout[j];
223
+ }
224
+ }
225
+ }
226
+ }
227
+ previousPos = currentPos;
228
+ }
229
+
230
+ protected onStop() {
231
+ for (let j = 0; j < len; j++) {
232
+ tile.weights[j] = Number.parseFloat(tile.weights[j].toFixed(3));
233
+ }
234
+ }
235
+
236
+ private expand(j: number) {
237
+ const weight = lastWeights[j];
238
+ const maxWeight = constraints[j].maxSize;
239
+ if (weight < maxWeight) {
240
+ const available = maxWeight - weight;
241
+ if (available < remaining) {
242
+ nextLayout[j] = maxWeight;
243
+ remaining -= available;
244
+ } else {
245
+ nextLayout[j] = weight + remaining;
246
+ remaining = 0;
247
+ }
248
+ }
249
+ }
250
+
251
+ private shrink(j: number) {
252
+ const minWeight = constraints[j].minSize;
253
+ const weight = lastWeights[j];
254
+ if (weight > minWeight) {
255
+ const available = weight - minWeight;
256
+ if (available < remaining) {
257
+ nextLayout[j] = minWeight;
258
+ remaining -= available;
259
+ } else {
260
+ nextLayout[j] = weight - remaining;
261
+ remaining = 0;
262
+ }
263
+ }
264
+ }
265
+
266
+ private adjustBy(adjust: 'expand' | 'shrink') {
267
+ if (currentDir < 0) {
268
+ let j = this.#index - 1;
269
+ while (j >= 0 && remaining > 0) {
270
+ this[adjust](j--);
271
+ }
272
+ } else {
273
+ let j = this.#index;
274
+ while (j < len && remaining > 0) {
275
+ this[adjust](j++);
276
+ }
277
+ }
278
+ }
279
+
280
+ private syncWeights() {
281
+ lastWeights = $state.snapshot(tile.weights);
282
+ nextLayout = lastWeights.slice();
283
+ }
284
+ }
285
+ </script>
286
+
287
+ <div
288
+ bind:this={splitEl}
289
+ data-split
290
+ style="--gap: {tile.gapPx}px;"
291
+ data-dir={tile.direction}
292
+ >
293
+ {#each tile.children as t, i (t.id)}
294
+ {@const draggable = new DraggableResizer(dndCtx, i)}
295
+ <div data-split-item style="--grow: {tile.weights[i]}">
296
+ {#if i > 0}
297
+ <div
298
+ data-split-resizer
299
+ {@attach draggable.register}
300
+ data-dragged={draggable.isDragged}
301
+ >
302
+ {@render resizerSnippet?.(draggable, tile, i)}
303
+ </div>
304
+ {/if}
305
+ {@render child(i)}
306
+ </div>
307
+ {/each}
308
+ </div>
309
+
310
+ <style>
311
+ [data-split] {
312
+ display: flex;
313
+ overflow: hidden;
314
+ gap: var(--gap);
315
+
316
+ [data-split-item] {
317
+ position: relative;
318
+ flex: var(--grow) 1 0;
319
+ min-width: 0;
320
+ min-height: 0;
321
+ }
322
+
323
+ [data-split-resizer] {
324
+ position: absolute;
325
+ }
326
+
327
+ &[data-dir='row'] {
328
+ flex-direction: row;
329
+ }
330
+ &[data-dir='column'] {
331
+ flex-direction: column;
332
+ }
333
+ }
334
+ </style>
@@ -0,0 +1,42 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { Registry } from '../shared/registry.js';
3
+ import { Draggable } from '../shared/dnd.svelte.js';
4
+ import { type Constraint } from '../shared/constraints.js';
5
+ import type { Direction } from '../shared/spatial.js';
6
+ import type { Tile, TileProps, Tiles } from '../model.js';
7
+ import type { TilerContext } from '../context.svelte.js';
8
+ declare module '../model.js' {
9
+ interface TileRegistry {
10
+ split: {
11
+ constraints: Array<Constraint[]>;
12
+ weights: number[];
13
+ direction: Direction;
14
+ resizer?: string;
15
+ gapPx: number;
16
+ };
17
+ }
18
+ }
19
+ export interface SplitTileOptions {
20
+ constraints?: Constraint[];
21
+ weight?: number;
22
+ tile: Tile;
23
+ }
24
+ export interface SplitOptions<R extends string> {
25
+ children: SplitTileOptions[];
26
+ resizer?: R;
27
+ /** @default "row" */
28
+ direction?: Direction;
29
+ /** @default 0 */
30
+ gapPx?: number;
31
+ }
32
+ export declare function create<R extends string>(options: SplitOptions<R>): Tiles['split'];
33
+ type SplitContext<R extends string = string> = {
34
+ resizer?: Registry<R, Snippet<[Draggable, Tiles['split'], number]> | undefined>;
35
+ };
36
+ export declare function setup<R extends string>(ctx: SplitContext<R>): (options: SplitOptions<R>) => Tiles["split"];
37
+ export declare function onRemoveChild(ctx: TilerContext, tile: Tiles['split'], i: number): void;
38
+ export declare function onClear(ctx: TilerContext, tile: Tiles['split']): void;
39
+ export declare function insertTile(node: Tiles['split'], index: number, { tile, constraints, weight }: SplitTileOptions): void;
40
+ declare const Split: import("svelte").Component<TileProps<"split">, {}, "tile">;
41
+ type Split = ReturnType<typeof Split>;
42
+ export default Split;