Skip to content

Commit 2f586eb

Browse files
committed
add /image
1 parent 8049627 commit 2f586eb

15 files changed

Lines changed: 1358 additions & 7 deletions

File tree

.vscode/settings.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,11 @@
44
},
55
"typescript.tsdk": "node_modules/typescript/lib",
66
"editor.formatOnSave": true,
7-
"editor.defaultFormatter": "oxc.oxc-vscode"
7+
"editor.defaultFormatter": "oxc.oxc-vscode",
8+
"[typescriptreact]": {
9+
"editor.defaultFormatter": "oxc.oxc-vscode"
10+
},
11+
"[typescript]": {
12+
"editor.defaultFormatter": "oxc.oxc-vscode"
13+
}
814
}

packages/start/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"./client/spa": "./src/client/spa/index.tsx",
2020
"./middleware": "./src/middleware/index.ts",
2121
"./http": "./src/http/index.ts",
22-
"./env": "./env.d.ts"
22+
"./env": "./env.d.ts",
23+
"./image": "./src/image/index.tsx"
2324
},
2425
"publishConfig": {
2526
"access": "public",
@@ -33,7 +34,8 @@
3334
"./client/spa": "./dist/client/spa/index.jsx",
3435
"./middleware": "./dist/middleware/index.js",
3536
"./http": "./dist/http/index.js",
36-
"./env": "./env.d.ts"
37+
"./env": "./env.d.ts",
38+
"./image": "./dist/image/index.jsx"
3739
}
3840
},
3941
"dependencies": {
@@ -58,6 +60,7 @@
5860
"radix3": "^1.1.2",
5961
"seroval": "^1.4.1",
6062
"seroval-plugins": "^1.4.0",
63+
"sharp": "^0.34.5",
6164
"shiki": "^1.26.1",
6265
"solid-js": "^1.9.9",
6366
"source-map-js": "^1.2.1",

packages/start/src/config/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { extname, isAbsolute, join } from "node:path";
55
import { fileURLToPath } from "node:url";
66
import { normalizePath, type PluginOption } from "vite";
77
import solid, { type Options as SolidOptions } from "vite-plugin-solid";
8-
8+
import { imagePlugin, type StartImageOptions } from "../image/plugin/index.ts";
99
import { DEFAULT_EXTENSIONS, VIRTUAL_MODULES, VITE_ENVIRONMENTS } from "./constants.ts";
1010
import { devServer } from "./dev-server.ts";
1111
import { SolidStartClientFileRouter, SolidStartServerFileRouter } from "./fs-router.ts";
@@ -21,6 +21,8 @@ export interface SolidStartOptions {
2121
routeDir?: string;
2222
extensions?: string[];
2323
middleware?: string;
24+
25+
image?: StartImageOptions;
2426
}
2527

2628
const absolute = (path: string, root: string) =>
@@ -176,7 +178,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
176178
envName: VITE_ENVIRONMENTS.client,
177179
getRuntimeCode: () =>
178180
`import { createServerReference } from "${normalizePath(
179-
fileURLToPath(new URL("../server/server-runtime", import.meta.url))
181+
fileURLToPath(new URL("../server/server-runtime", import.meta.url)),
180182
)}"`,
181183
replacer: opts => `createServerReference('${opts.functionId}')`,
182184
},
@@ -185,7 +187,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
185187
envName: VITE_ENVIRONMENTS.server,
186188
getRuntimeCode: () =>
187189
`import { createServerReference } from '${normalizePath(
188-
fileURLToPath(new URL("../server/server-fns-runtime", import.meta.url))
190+
fileURLToPath(new URL("../server/server-fns-runtime", import.meta.url)),
189191
)}'`,
190192
replacer: opts => `createServerReference(${opts.fn}, '${opts.functionId}')`,
191193
},
@@ -194,11 +196,12 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
194196
envName: VITE_ENVIRONMENTS.server,
195197
getRuntimeCode: () =>
196198
`import { createServerReference } from '${normalizePath(
197-
fileURLToPath(new URL("../server/server-fns-runtime", import.meta.url))
199+
fileURLToPath(new URL("../server/server-fns-runtime", import.meta.url)),
198200
)}'`,
199201
replacer: opts => `createServerReference(${opts.fn}, '${opts.functionId}')`,
200202
},
201203
}),
204+
options?.image ? imagePlugin(options.image) : undefined,
202205
{
203206
name: "solid-start:virtual-modules",
204207
async resolveId(id) {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
function gcd(a: number, b: number): number {
2+
if (b === 0) {
3+
return a;
4+
}
5+
return gcd(b, a % b);
6+
}
7+
8+
export interface AspectRatio {
9+
width: number;
10+
height: number;
11+
}
12+
13+
const HORIZONTAL_ASPECT_RATIO = [
14+
{ width: 4, height: 4 }, // Square
15+
{ width: 4, height: 3 }, // Standard Fullscreen
16+
{ width: 16, height: 10 }, // Standard LCD
17+
{ width: 16, height: 9 }, // HD
18+
// { width: 37, height: 20 }, // Widescreen
19+
{ width: 6, height: 3 }, // Univisium
20+
{ width: 21, height: 9 }, // Anamorphic 2.35:1
21+
// { width: 64, height: 27 }, // Anamorphic 2.39:1 or 2.37:1
22+
{ width: 19, height: 16 }, // Movietone
23+
{ width: 5, height: 4 }, // 17' LCD CRT
24+
// { width: 48, height: 35 }, // 16mm and 35mm
25+
{ width: 11, height: 8 }, // 35mm full sound
26+
// { width: 143, height: 100 }, // IMAX
27+
{ width: 6, height: 4 }, // 35mm photo
28+
{ width: 14, height: 9 }, // commercials
29+
{ width: 5, height: 3 }, // Paramount
30+
{ width: 7, height: 4 }, // early 35mm
31+
{ width: 11, height: 5 }, // 70mm
32+
{ width: 12, height: 5 }, // Bluray
33+
{ width: 8, height: 3 }, // Super 16
34+
{ width: 18, height: 5 }, // IMAX
35+
{ width: 12, height: 3 }, // Polyvision
36+
];
37+
38+
const VERTICAL_ASPECT_RATIO = HORIZONTAL_ASPECT_RATIO.map(item => ({
39+
width: item.height,
40+
height: item.width,
41+
}));
42+
43+
const ASPECT_RATIO = [...HORIZONTAL_ASPECT_RATIO, ...VERTICAL_ASPECT_RATIO];
44+
45+
export function getAspectRatio({ width, height }: AspectRatio): AspectRatio {
46+
const denom = gcd(width, height);
47+
48+
return {
49+
width: width / denom,
50+
height: height / denom,
51+
};
52+
}
53+
54+
export function getNearestAspectRatio(ratio: AspectRatio): AspectRatio {
55+
let nearest = Number.MAX_VALUE;
56+
let id = 0;
57+
58+
const originalRatio = ratio.width / ratio.height;
59+
60+
for (let i = 0; i < ASPECT_RATIO.length; i += 1) {
61+
const target = ASPECT_RATIO[i]!;
62+
63+
const tRatio = target.width / target.height;
64+
const squared = tRatio - originalRatio;
65+
const distance = Math.sqrt(squared * squared);
66+
67+
if (i === 0) {
68+
nearest = distance;
69+
} else if (distance < nearest) {
70+
id = i;
71+
nearest = distance;
72+
}
73+
}
74+
75+
return ASPECT_RATIO[id]!;
76+
}
77+
78+
export function getScaledComponentRatio(ratio: AspectRatio): AspectRatio {
79+
const xScale = 9 / ratio.width;
80+
const yScale = 9 / ratio.height;
81+
const scale = Math.min(xScale, yScale);
82+
return {
83+
width: scale * ratio.width,
84+
height: scale * ratio.height,
85+
};
86+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { JSX } from "solid-js";
2+
import { createSignal, onMount, Show } from "solid-js";
3+
import { isServer } from "solid-js/web";
4+
5+
export const createClientSignal = isServer
6+
? (): (() => boolean) => () => false
7+
: (): (() => boolean) => {
8+
const [flag, setFlag] = createSignal(false);
9+
10+
onMount(() => {
11+
setFlag(true);
12+
});
13+
14+
return flag;
15+
};
16+
17+
export interface ClientOnlyProps {
18+
fallback?: JSX.Element;
19+
children?: JSX.Element;
20+
}
21+
22+
export const ClientOnly = (props: ClientOnlyProps): JSX.Element => {
23+
const isClient = createClientSignal();
24+
25+
return Show({
26+
keyed: false,
27+
get when() {
28+
return isClient();
29+
},
30+
get fallback() {
31+
return props.fallback;
32+
},
33+
get children() {
34+
return props.children;
35+
},
36+
});
37+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { createEffect, createSignal, onCleanup } from "solid-js";
2+
3+
export interface LazyRender<T extends HTMLElement> {
4+
ref: (value: T) => void;
5+
visible: boolean;
6+
}
7+
8+
export interface LazyRenderOptions {
9+
refresh?: boolean;
10+
}
11+
12+
export function createLazyRender<T extends HTMLElement>(
13+
options?: LazyRenderOptions,
14+
): LazyRender<T> {
15+
const [visible, setVisible] = createSignal(false);
16+
17+
// We use a reactive ref here so that the component
18+
// re-renders if the host element changes, therefore
19+
// re-evaluating our intersection logic
20+
const [ref, setRef] = createSignal<T | null>(null);
21+
22+
createEffect(() => {
23+
// If the host changed, make sure that
24+
// visibility is set to false
25+
setVisible(false);
26+
const shouldRefresh = options?.refresh;
27+
28+
const current = ref();
29+
if (!current) {
30+
return;
31+
}
32+
const observer = new IntersectionObserver(entries => {
33+
for (const entry of entries) {
34+
if (shouldRefresh) {
35+
setVisible(entry.isIntersecting);
36+
} else if (entry.isIntersecting) {
37+
// Host intersected, set visibility to true
38+
setVisible(true);
39+
40+
// Stop observing
41+
observer.disconnect();
42+
}
43+
}
44+
});
45+
46+
observer.observe(current);
47+
48+
onCleanup(() => {
49+
observer.unobserve(current);
50+
observer.disconnect();
51+
});
52+
});
53+
54+
return {
55+
ref(value) {
56+
return setRef(() => value);
57+
},
58+
get visible() {
59+
return visible();
60+
},
61+
};
62+
}

packages/start/src/image/index.tsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { JSX } from "solid-js";
2+
import { createMemo, createSignal, For, Show } from "solid-js";
3+
import { ClientOnly } from "./client-only.tsx";
4+
import { createLazyRender } from "./create-lazy-render.ts";
5+
import {
6+
createImageVariants,
7+
mergeImageVariantsByType,
8+
mergeImageVariantsToSrcSet,
9+
} from "./transformer.ts";
10+
import type { StartImageSource, StartImageTransformer, StartImageVariant } from "./types.ts";
11+
import { BLOCKER_STYLE, getAspectRatioBoxStyle, IMAGE_CONTAINER, IMAGE_STYLE } from "./utils.ts";
12+
13+
export interface StartImageProps<T> {
14+
src: StartImageSource<T>;
15+
alt: string;
16+
transformer?: StartImageTransformer<T>;
17+
18+
onLoad?: () => void;
19+
children: (visible: () => boolean, onLoad: () => void) => JSX.Element;
20+
21+
crossOrigin?: JSX.HTMLCrossorigin | undefined;
22+
fetchPriority?: "high" | "low" | "auto" | undefined;
23+
decoding?: "sync" | "async" | "auto" | undefined;
24+
}
25+
26+
interface StartImageSourcesProps<T> extends StartImageProps<T> {
27+
variants: StartImageVariant[];
28+
}
29+
30+
function StartImageSources<T>(props: StartImageSourcesProps<T>): JSX.Element {
31+
const mergedVariants = createMemo(() => {
32+
const types = mergeImageVariantsByType(props.variants);
33+
34+
const values: [type: string, srcset: string][] = [];
35+
36+
for (const [key, variants] of types) {
37+
values.push([key, mergeImageVariantsToSrcSet(variants)]);
38+
}
39+
40+
return values;
41+
});
42+
43+
return (
44+
<For each={mergedVariants()}>{([type, srcset]) => <source type={type} srcset={srcset} />}</For>
45+
);
46+
}
47+
48+
export function StartImage<T>(props: StartImageProps<T>): JSX.Element {
49+
const [showPlaceholder, setShowPlaceholder] = createSignal(true);
50+
const laze = createLazyRender<HTMLDivElement>();
51+
const [defer, setDefer] = createSignal(true);
52+
53+
function onPlaceholderLoad() {
54+
setDefer(false);
55+
}
56+
57+
const width = createMemo(() => props.src.width);
58+
const height = createMemo(() => props.src.height);
59+
60+
return (
61+
<div ref={laze.ref} data-start-image="image-container" style={IMAGE_CONTAINER}>
62+
<div
63+
data-start-image="aspect-ratio"
64+
style={getAspectRatioBoxStyle({
65+
width: width(),
66+
height: height(),
67+
})}
68+
>
69+
<picture style={IMAGE_STYLE}>
70+
<Show when={props.transformer} fallback={<source src={props.src.source} />}>
71+
{cb => <StartImageSources variants={createImageVariants(props.src, cb())} {...props} />}
72+
</Show>
73+
<ClientOnly
74+
fallback={
75+
<img
76+
data-start-image="image"
77+
alt={props.alt}
78+
style={IMAGE_STYLE}
79+
crossOrigin={props.crossOrigin}
80+
fetchpriority={props.fetchPriority}
81+
decoding={props.decoding}
82+
/>
83+
}
84+
>
85+
<Show when={laze.visible}>
86+
<img
87+
data-start-image="image"
88+
// src={getEmptyImageURL({
89+
// width: width(),
90+
// height: height(),
91+
// })}
92+
alt={props.alt}
93+
onLoad={() => {
94+
if (!defer()) {
95+
setShowPlaceholder(false);
96+
props.onLoad?.();
97+
}
98+
}}
99+
style={{
100+
opacity: showPlaceholder() ? 0 : 1,
101+
}}
102+
crossOrigin={props.crossOrigin}
103+
fetchpriority={props.fetchPriority}
104+
decoding={props.decoding}
105+
/>
106+
</Show>
107+
</ClientOnly>
108+
</picture>
109+
</div>
110+
<div style={BLOCKER_STYLE}>
111+
<ClientOnly>
112+
<Show when={laze.visible}>{props.children(showPlaceholder, onPlaceholderLoad)}</Show>
113+
</ClientOnly>
114+
</div>
115+
</div>
116+
);
117+
}

0 commit comments

Comments
 (0)