|
3 | 3 | import Spinner from "$lib/components/Spinner.svelte"; |
4 | 4 | import { getDimensions, type ImageDimensions } from "$lib/image"; |
5 | 5 | import AddedOrRemovedImageLabel from "$lib/components/diff/AddedOrRemovedImageLabel.svelte"; |
6 | | - import { on } from "svelte/events"; |
| 6 | + import { ElementSize } from "runed"; |
7 | 7 |
|
8 | 8 | interface Props { |
9 | 9 | fileA: string; |
|
16 | 16 | type DimensionData = { |
17 | 17 | a: ImageDimensions; |
18 | 18 | b: ImageDimensions; |
19 | | - widest: string; |
20 | | - narrowerMaxW: string; |
21 | 19 | }; |
22 | 20 |
|
23 | | - let dimensions: Promise<DimensionData> = $derived.by(async () => { |
| 21 | + let imageDimensions: Promise<DimensionData> = $derived.by(async () => { |
24 | 22 | const [a, b] = await Promise.all([getDimensions(fileA), getDimensions(fileB)]); |
25 | | - const widest = a.width > b.width ? "a" : "b"; |
26 | | - const widerDims = widest === "a" ? a : b; |
27 | | - const narrowerDims = widest === "a" ? b : a; |
28 | | - const narrowerMaxW = (narrowerDims.width / widerDims.width) * 100; |
29 | | - return { a, b, widest, narrowerMaxW: `max-width: ${narrowerMaxW}%;` }; |
| 23 | + return { a, b }; |
30 | 24 | }); |
31 | 25 | let mode: Mode = $state("side-by-side"); |
32 | | - let percentShown: number = $state(50); |
33 | | - let percentDragged: number = $state(50); |
34 | | - let dragging: boolean = $state(false); |
| 26 | + let slidePercent: number = $state(50); |
35 | 27 | let fadePercent: number = $state(50); |
36 | 28 |
|
37 | | - function getMaxW(img: string, dims: DimensionData): string { |
38 | | - if (img === "a") { |
39 | | - if (dims.widest === "b") { |
40 | | - return dims.narrowerMaxW; |
| 29 | + function getStyle(side: "a" | "b", dims: DimensionData): string { |
| 30 | + let style = ""; |
| 31 | + if (side === "a") { |
| 32 | + if (dims.b.width > dims.a.width) { |
| 33 | + const scale = dims.a.width / dims.b.width; |
| 34 | + style += `max-width: ${scale * 100}%;`; |
41 | 35 | } |
42 | | - } else if (img === "b") { |
43 | | - if (dims.widest === "a") { |
44 | | - return dims.narrowerMaxW; |
| 36 | + } else { |
| 37 | + if (dims.a.width > dims.b.width) { |
| 38 | + const scale = dims.b.width / dims.a.width; |
| 39 | + style += `max-width: ${scale * 100}%;`; |
45 | 40 | } |
46 | 41 | } |
47 | | - return ""; |
| 42 | + return style; |
48 | 43 | } |
49 | 44 |
|
50 | | - let overlayImgB: HTMLImageElement; |
51 | | -
|
52 | | - function dragSlider(node: HTMLElement) { |
53 | | - let containerWidth: number; |
54 | | - let containerLeft: number; |
55 | | - let imgBWidth: number; |
56 | | - let imgBLeft: number; |
57 | | -
|
58 | | - function handleMouseDown(event: MouseEvent) { |
59 | | - if (event.button !== 0) { |
60 | | - return; |
61 | | - } |
62 | | -
|
63 | | - if (node.parentElement && overlayImgB) { |
64 | | - const parentRect = node.parentElement.getBoundingClientRect(); |
65 | | - containerWidth = parentRect.width; |
66 | | - containerLeft = parentRect.left; |
67 | | -
|
68 | | - const imgBRect = overlayImgB.getBoundingClientRect(); |
69 | | - imgBWidth = imgBRect.width; |
70 | | - imgBLeft = imgBRect.left; |
71 | | -
|
72 | | - dragging = true; |
73 | | - } |
74 | | - } |
75 | | -
|
76 | | - function handleMouseMove(event: MouseEvent) { |
77 | | - if (dragging) { |
78 | | - percentDragged = Math.max(0, Math.min(100, ((event.clientX - containerLeft) / containerWidth) * 100)); |
79 | | - percentShown = Math.max(0, Math.min(100, ((event.clientX - imgBLeft) / imgBWidth) * 100)); |
80 | | - } |
81 | | - } |
82 | | -
|
83 | | - function handleMouseUp() { |
84 | | - dragging = false; |
| 45 | + let overlayImgA: HTMLDivElement | undefined = $state(); |
| 46 | + let overlayImgASize = new ElementSize(() => overlayImgA); |
| 47 | + let overlayImgB: HTMLImageElement | undefined = $state(); |
| 48 | + let overlayImgBSize = new ElementSize(() => overlayImgB); |
| 49 | + let overlayContainerStyle = $derived.by(() => { |
| 50 | + if (overlayImgASize.height < overlayImgBSize.height) { |
| 51 | + // pad image A's height to match B's height |
| 52 | + return `height: ${overlayImgBSize.height}px;`; |
85 | 53 | } |
86 | | -
|
87 | | - const removeMouseDown = on(node, "mousedown", handleMouseDown); |
88 | | - const removeMouseMove = on(window, "mousemove", handleMouseMove); |
89 | | - const removeMouseUp = on(window, "mouseup", handleMouseUp); |
90 | | -
|
91 | | - return { |
92 | | - destroy() { |
93 | | - removeMouseDown(); |
94 | | - removeMouseMove(); |
95 | | - removeMouseUp(); |
96 | | - }, |
97 | | - }; |
98 | | - } |
| 54 | + return ""; |
| 55 | + }); |
99 | 56 | </script> |
100 | 57 |
|
101 | 58 | {#snippet modeSelector()} |
|
119 | 76 | {#snippet sideBySide(dims: DimensionData)} |
120 | 77 | <div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2"> |
121 | 78 | <div class="flex flex-col items-center justify-center gap-4"> |
122 | | - <img src={fileA} alt="A" class="png-bg h-auto border-2 border-red-600 shadow-md" style={getMaxW("a", dims)} /> |
| 79 | + <img src={fileA} alt="A" class="png-bg h-auto border-2 border-red-600 shadow-md" style={getStyle("a", dims)} /> |
123 | 80 | <AddedOrRemovedImageLabel mode="remove" dims={dims.a} /> |
124 | 81 | </div> |
125 | 82 | <div class="flex flex-col items-center justify-center gap-4"> |
126 | | - <img src={fileB} alt="B" class="png-bg h-auto border-2 border-green-600 shadow-md" style={getMaxW("b", dims)} /> |
| 83 | + <img src={fileB} alt="B" class="png-bg h-auto border-2 border-green-600 shadow-md" style={getStyle("b", dims)} /> |
127 | 84 | <AddedOrRemovedImageLabel mode="add" dims={dims.b} /> |
128 | 85 | </div> |
129 | 86 | </div> |
|
132 | 89 | {#snippet slide(dims: DimensionData)} |
133 | 90 | <div class="flex flex-row items-center gap-4"> |
134 | 91 | <AddedOrRemovedImageLabel mode="remove" dims={dims.a} /> |
135 | | - <div class="relative grid grid-cols-1 gap-4"> |
136 | | - <img src={fileA} alt="A" class="png-bg h-auto w-full border-2 border-red-600 shadow-md" draggable="false" style={getMaxW("a", dims)} /> |
| 92 | + <div class="relative grid grid-cols-1 gap-4" style={overlayContainerStyle}> |
137 | 93 | <img |
138 | | - bind:this={overlayImgB} |
139 | | - src={fileB} |
140 | | - alt="B" |
141 | | - class="png-bg absolute h-auto max-w-full place-self-center border-2 border-green-600 shadow-md" |
| 94 | + bind:this={overlayImgA} |
| 95 | + src={fileA} |
| 96 | + alt="A" |
| 97 | + class="png-bg h-auto w-full place-self-center border-2 border-red-600 shadow-md" |
142 | 98 | draggable="false" |
143 | | - style="clip-path: inset(0 0 0 {percentShown}%); {getMaxW('b', dims)}" |
| 99 | + style={getStyle("a", dims)} |
144 | 100 | /> |
145 | | - <div class="absolute top-1/2 h-full w-0.5 -translate-x-1/2 -translate-y-1/2 bg-gray-600" style="left: calc({percentDragged}%);"></div> |
146 | | - <div |
147 | | - use:dragSlider |
148 | | - class="absolute top-1/2 flex -translate-x-1/2 -translate-y-1/2 cursor-col-resize items-center justify-center rounded-sm bg-neutral px-0.5 py-1 shadow-sm select-none" |
149 | | - style="left: calc({percentDragged}%);" |
150 | | - > |
151 | | - <span class="iconify size-4 octicon--grabber-16"></span> |
| 101 | + <div class="absolute flex size-full"> |
| 102 | + <Slider.Root |
| 103 | + type="single" |
| 104 | + thumbPositioning="exact" |
| 105 | + bind:value={slidePercent} |
| 106 | + class="relative m-auto flex size-fit touch-none select-none" |
| 107 | + style={getStyle("b", dims)} |
| 108 | + > |
| 109 | + <img |
| 110 | + bind:this={overlayImgB} |
| 111 | + src={fileB} |
| 112 | + alt="B" |
| 113 | + class="png-bg size-full border-2 border-green-600 shadow-md" |
| 114 | + draggable="false" |
| 115 | + style="clip-path: inset(0 0 0 {slidePercent}%);" |
| 116 | + /> |
| 117 | + <span class="absolute h-full w-0.5 -translate-x-1/2 bg-em-disabled/80" style="left: calc({slidePercent}%);"></span> |
| 118 | + <Slider.Thumb index={0} class="group absolute flex h-full cursor-col-resize select-none"> |
| 119 | + <div class="flex place-self-center rounded-sm bg-neutral px-0.5 py-1 shadow-sm group-data-active:scale-[0.95]"> |
| 120 | + <span class="iconify size-4 place-self-center octicon--grabber-16"></span> |
| 121 | + </div> |
| 122 | + </Slider.Thumb> |
| 123 | + </Slider.Root> |
152 | 124 | </div> |
153 | 125 | </div> |
154 | 126 | <AddedOrRemovedImageLabel mode="add" dims={dims.b} /> |
|
158 | 130 | {#snippet fade(dims: DimensionData)} |
159 | 131 | <div class="flex flex-row items-center gap-4"> |
160 | 132 | <AddedOrRemovedImageLabel mode="remove" dims={dims.a} /> |
161 | | - <div class="relative grid grid-cols-1 gap-4"> |
162 | | - <img src={fileA} alt="A" class="png-bg h-auto w-full border-2 border-red-600 shadow-md" draggable="false" style={getMaxW("a", dims)} /> |
| 133 | + <div class="relative grid grid-cols-1 gap-4" style={overlayContainerStyle}> |
| 134 | + <img |
| 135 | + bind:this={overlayImgA} |
| 136 | + src={fileA} |
| 137 | + alt="A" |
| 138 | + class="png-bg h-auto w-full place-self-center border-2 border-red-600 shadow-md" |
| 139 | + draggable="false" |
| 140 | + style={getStyle("a", dims)} |
| 141 | + /> |
163 | 142 | <img |
164 | 143 | bind:this={overlayImgB} |
165 | 144 | src={fileB} |
166 | 145 | alt="B" |
167 | 146 | class="png-bg absolute h-auto max-w-full place-self-center border-2 border-green-600 shadow-md" |
168 | 147 | draggable="false" |
169 | | - style="opacity: {fadePercent}%; {getMaxW('b', dims)}" |
| 148 | + style="opacity: {fadePercent}%; {getStyle('b', dims)}" |
170 | 149 | /> |
171 | 150 | </div> |
172 | 151 | <AddedOrRemovedImageLabel mode="add" dims={dims.b} /> |
|
183 | 162 |
|
184 | 163 | <div class="flex flex-col items-center justify-center bg-neutral-2 p-4"> |
185 | 164 | {@render modeSelector()} |
186 | | - {#await dimensions} |
| 165 | + {#await imageDimensions} |
187 | 166 | <Spinner /> |
188 | 167 | {:then dims} |
189 | 168 | {#if mode === "side-by-side"} |
|
0 commit comments