MediaAttachmentGrid: use new ~platform/Masonry
All checks were successful
/ depoly (push) Successful in 1m30s
All checks were successful
/ depoly (push) Successful in 1m30s
* Masonry is a component provides limited masonry layout support
This commit is contained in:
parent
3a3fa40437
commit
18fa2810c4
8 changed files with 266 additions and 78 deletions
3
.env
3
.env
|
@ -1,4 +1,5 @@
|
||||||
DEV_SERVER_HTTPS_CERT_BASE=
|
DEV_SERVER_HTTPS_CERT_BASE=
|
||||||
DEV_SERVER_HTTPS_CERT_PASS=
|
DEV_SERVER_HTTPS_CERT_PASS=
|
||||||
DEV_LOCATOR_EDITOR=vscode
|
DEV_LOCATOR_EDITOR=vscode
|
||||||
VITE_DEVTOOLS_OVERLAY=true
|
VITE_DEVTOOLS_OVERLAY=true
|
||||||
|
VITE_PLATFROM_MASONRY_ALWAYS_COMPAT=
|
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -18,6 +18,7 @@
|
||||||
"@solid-devtools/overlay": "^0.30.1",
|
"@solid-devtools/overlay": "^0.30.1",
|
||||||
"@suid/vite-plugin": "^0.3.1",
|
"@suid/vite-plugin": "^0.3.1",
|
||||||
"@types/hammerjs": "^2.0.46",
|
"@types/hammerjs": "^2.0.46",
|
||||||
|
"@types/masonry-layout": "^4.2.8",
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
"@vite-pwa/assets-generator": "^0.2.6",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
@ -49,6 +50,7 @@
|
||||||
"fast-average-color": "^9.4.0",
|
"fast-average-color": "^9.4.0",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
"iso-639-1": "^3.1.3",
|
"iso-639-1": "^3.1.3",
|
||||||
|
"masonry-layout": "^4.2.2",
|
||||||
"masto": "^6.10.1",
|
"masto": "^6.10.1",
|
||||||
"nanostores": "^0.11.3",
|
"nanostores": "^0.11.3",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
|
|
4
src/overrides.d.ts
vendored
4
src/overrides.d.ts
vendored
|
@ -11,6 +11,10 @@ interface ImportMetaEnv {
|
||||||
* Attach the overlay (in the dev mode) if it's `"true"`.
|
* Attach the overlay (in the dev mode) if it's `"true"`.
|
||||||
*/
|
*/
|
||||||
readonly VITE_DEVTOOLS_OVERLAY?: string;
|
readonly VITE_DEVTOOLS_OVERLAY?: string;
|
||||||
|
/**
|
||||||
|
* Always use compatible version of Masonry.
|
||||||
|
*/
|
||||||
|
readonly VITE_PLATFROM_MASONRY_ALWAYS_COMPAT?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
16
src/platform/Masonry.css
Normal file
16
src/platform/Masonry.css
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
.CompatMasonry>* {
|
||||||
|
margin-bottom: var(--Masonry-row-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (grid-template-rows: masonry) {
|
||||||
|
.NativeMasonry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(44px, min-content));
|
||||||
|
grid-template-rows: masonry;
|
||||||
|
|
||||||
|
&:has(> :last-child:nth-child(2n)) {
|
||||||
|
grid-template-columns: repeat(2, minmax(auto, min-content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
158
src/platform/Masonry.tsx
Normal file
158
src/platform/Masonry.tsx
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
import {
|
||||||
|
type Component,
|
||||||
|
type JSX,
|
||||||
|
splitProps,
|
||||||
|
type Ref,
|
||||||
|
createRenderEffect,
|
||||||
|
onCleanup,
|
||||||
|
children,
|
||||||
|
createEffect,
|
||||||
|
createSignal,
|
||||||
|
onMount,
|
||||||
|
} from "solid-js";
|
||||||
|
import { Dynamic, type DynamicProps } from "solid-js/web";
|
||||||
|
import MasonryLayout from "masonry-layout";
|
||||||
|
import { createElementSize } from "@solid-primitives/resize-observer";
|
||||||
|
import "./Masonry.css";
|
||||||
|
|
||||||
|
type MasonryContainer =
|
||||||
|
| Exclude<keyof JSX.IntrinsicElements, keyof JSX.SVGElementTags>
|
||||||
|
| Component<{
|
||||||
|
ref?: Ref<Element>;
|
||||||
|
class?: string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type ElementOf<T extends MasonryContainer> =
|
||||||
|
T extends Exclude<keyof JSX.IntrinsicElements, keyof JSX.SVGElementTags>
|
||||||
|
? JSX.IntrinsicElements[T] extends { ref?: Ref<infer E> }
|
||||||
|
? E
|
||||||
|
: never
|
||||||
|
: T extends Component<{ ref?: Ref<infer E> }>
|
||||||
|
? E
|
||||||
|
: never;
|
||||||
|
|
||||||
|
function forwardRef<T>(value: T, ref?: Ref<T>) {
|
||||||
|
if (!ref) return;
|
||||||
|
(ref as (value: T) => void)(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMasonry(element: Element, options: () => MasonryLayout.Options) {
|
||||||
|
const layout = new MasonryLayout(element, {
|
||||||
|
initLayout: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => layout.destroy?.());
|
||||||
|
|
||||||
|
const size = createElementSize(element);
|
||||||
|
|
||||||
|
createRenderEffect(() => {
|
||||||
|
const opts = options();
|
||||||
|
layout.option?.(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
createRenderEffect(() => {
|
||||||
|
const width = size.width; // only tracking width
|
||||||
|
layout.layout?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.on("vite:afterUpdate", () => {
|
||||||
|
layout.layout?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportsCSSMasonryLayout = /* @__PURE__ */ CSS.supports(
|
||||||
|
"grid-template-rows",
|
||||||
|
"masonry",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.debug("supports css masonry layout", supportsCSSMasonryLayout);
|
||||||
|
|
||||||
|
const useNativeImpl = import.meta.env.VITE_PLATFROM_MASONRY_ALWAYS_COMPAT
|
||||||
|
? false
|
||||||
|
: supportsCSSMasonryLayout;
|
||||||
|
|
||||||
|
if (import.meta.env.VITE_PLATFROM_MASONRY_ALWAYS_COMPAT) {
|
||||||
|
console.warn(
|
||||||
|
"Masonry is in compat mode because VITE_PLATFORM_MASONRY_ALWAYS_COMPAT is enabled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MasonryCompat<T extends MasonryContainer>(
|
||||||
|
oprops: DynamicProps<T> & { class?: string },
|
||||||
|
) {
|
||||||
|
const [props, rest] = splitProps(oprops, ["ref", "children", "class"]);
|
||||||
|
|
||||||
|
const childrenComponents = children(() => props.children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dynamic
|
||||||
|
ref={(element: ElementOf<T>) => {
|
||||||
|
forwardRef(element, props.ref as Ref<typeof element> | undefined);
|
||||||
|
|
||||||
|
const [columnGap, setColumnGap] = createSignal<number>();
|
||||||
|
|
||||||
|
const layout = createMasonry(element, () => {
|
||||||
|
return {
|
||||||
|
gutter: columnGap(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const computedStyle = window.getComputedStyle(element);
|
||||||
|
|
||||||
|
const rowGap = computedStyle.rowGap;
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
element.style.setProperty("--Masonry-row-gap", rowGap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colGap = computedStyle.columnGap;
|
||||||
|
if (colGap) {
|
||||||
|
setColumnGap(Number(colGap.slice(0, colGap.length - 2)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createRenderEffect(() => {
|
||||||
|
childrenComponents(); // just tracks
|
||||||
|
setTimeout(() => {
|
||||||
|
layout.reloadItems?.();
|
||||||
|
layout.layout?.();
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
class={`Masonry CompatMasonry ${props.class || ""}`}
|
||||||
|
{...rest}
|
||||||
|
children={childrenComponents}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MasonryNative<T extends MasonryContainer>(
|
||||||
|
oprops: DynamicProps<T> & { class?: string },
|
||||||
|
) {
|
||||||
|
const [props, rest] = splitProps(oprops, ["class"]);
|
||||||
|
return (
|
||||||
|
<Dynamic class={`Masonry NativeMasonry ${props.class || ""}`} {...rest} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masonry Layout Container.
|
||||||
|
*
|
||||||
|
* **Native if possible** This component uses css masonry layout
|
||||||
|
* and fallback to masonry-layout if not supported. The children
|
||||||
|
* must have specified width and height.
|
||||||
|
*
|
||||||
|
* **Children Changes** As the children changed, reflow will be triggered,
|
||||||
|
* and there is might be a blink (or transition) for user. If it's not your
|
||||||
|
* intention, don't remove/add the direct children. Instead wraps them under
|
||||||
|
* containers and set the width and height on the container.
|
||||||
|
*
|
||||||
|
* **CSS compatibility** This component compatible to "gap" "row-gap"
|
||||||
|
* "column-gap" property. But they are read only once after the element mounted.
|
||||||
|
*/
|
||||||
|
export default useNativeImpl ? MasonryNative : MasonryCompat;
|
|
@ -3,10 +3,10 @@
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
margin-left: var(--card-pad, 0);
|
margin-left: var(--card-pad, 0);
|
||||||
margin-right: var(--card-pad, 0);
|
margin-right: var(--card-pad, 0);
|
||||||
gap: 4px;
|
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
> :where(img, video, .sensitive-placeholder) {
|
> * {
|
||||||
max-height: 35vh;
|
max-height: 35vh;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
|
@ -22,12 +22,20 @@
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d));
|
outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d));
|
||||||
border-color: var(--media-color-accent, var(--tutu-color-surface-d));
|
border-color: var(--media-color-accent, var(--tutu-color-surface-d));
|
||||||
|
z-index: calc(var(--tutu-zidx-nav) - 1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> * > * {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> * > :where(img, video) {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
>.sensitive-placeholder {
|
> * >.sensitive-placeholder {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
display: inline flex;
|
display: inline flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -35,6 +43,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(.thread-top, .thread-mid) > .MediaAttachmentGrid {
|
:where(.thread-top, .thread-mid)>.MediaAttachmentGrid {
|
||||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||||
}
|
}
|
|
@ -23,6 +23,7 @@ import "./MediaAttachmentGrid.css";
|
||||||
import cardStyle from "~material/cards.module.css";
|
import cardStyle from "~material/cards.module.css";
|
||||||
import { Preview } from "@suid/icons-material";
|
import { Preview } from "@suid/icons-material";
|
||||||
import { IconButton } from "@suid/material";
|
import { IconButton } from "@suid/material";
|
||||||
|
import Masonry from "~platform/Masonry";
|
||||||
|
|
||||||
type ElementSize = { width: number; height: number };
|
type ElementSize = { width: number; height: number };
|
||||||
|
|
||||||
|
@ -149,13 +150,13 @@ const MediaAttachmentGrid: Component<{
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<section
|
<Masonry
|
||||||
|
component="section"
|
||||||
ref={setRootRef}
|
ref={setRootRef}
|
||||||
class={`MediaAttachmentGrid ${cardStyle.cardNoPad}`}
|
class={`MediaAttachmentGrid ${cardStyle.cardNoPad}`}
|
||||||
classList={{
|
classList={{
|
||||||
sensitive: props.sensitive,
|
sensitive: props.sensitive,
|
||||||
}}
|
}}
|
||||||
style={{ "column-count": columnCount() }}
|
|
||||||
onClick={isolateCallback}
|
onClick={isolateCallback}
|
||||||
>
|
>
|
||||||
<Index each={props.attachments}>
|
<Index each={props.attachments}>
|
||||||
|
@ -164,81 +165,79 @@ const MediaAttachmentGrid: Component<{
|
||||||
|
|
||||||
const style = createMemo(() => itemStyle(item()));
|
const style = createMemo(() => itemStyle(item()));
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<div style={style()} role="presentation">
|
||||||
<Match when={props.sensitive && !isReveal(index)}>
|
<Switch>
|
||||||
<div
|
<Match when={props.sensitive && !isReveal(index)}>
|
||||||
class="sensitive-placeholder"
|
<div
|
||||||
style={style()}
|
class="sensitive-placeholder"
|
||||||
data-sort={index}
|
data-sort={index}
|
||||||
data-media-type={item().type}
|
data-media-type={item().type}
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
size="large"
|
|
||||||
onClick={[addReveal, index]}
|
|
||||||
aria-label="Reveal this media"
|
|
||||||
>
|
>
|
||||||
<Preview />
|
<IconButton
|
||||||
</IconButton>
|
color="inherit"
|
||||||
</div>
|
size="large"
|
||||||
</Match>
|
onClick={[addReveal, index]}
|
||||||
<Match when={itemType() === "image"}>
|
aria-label="Reveal this media"
|
||||||
<img
|
>
|
||||||
src={item().previewUrl}
|
<Preview />
|
||||||
width={item().meta?.small?.width}
|
</IconButton>
|
||||||
height={item().meta?.small?.height}
|
</div>
|
||||||
alt={item().description || undefined}
|
</Match>
|
||||||
onClick={[openViewerFor, index]}
|
<Match when={itemType() === "image"}>
|
||||||
loading="lazy"
|
<img
|
||||||
style={style()}
|
src={item().previewUrl}
|
||||||
data-sort={index}
|
width={item().meta?.small?.width}
|
||||||
data-media-type={item().type}
|
height={item().meta?.small?.height}
|
||||||
></img>
|
alt={item().description || undefined}
|
||||||
</Match>
|
onClick={[openViewerFor, index]}
|
||||||
<Match when={itemType() === "video"}>
|
loading="lazy"
|
||||||
<video
|
data-sort={index}
|
||||||
src={item().url || undefined}
|
data-media-type={item().type}
|
||||||
autoplay={!props.sensitive && settings().autoPlayVideos}
|
></img>
|
||||||
playsinline={settings().autoPlayVideos ? true : undefined}
|
</Match>
|
||||||
controls
|
<Match when={itemType() === "video"}>
|
||||||
poster={item().previewUrl}
|
<video
|
||||||
width={item().meta?.small?.width}
|
src={item().url || undefined}
|
||||||
height={item().meta?.small?.height}
|
autoplay={!props.sensitive && settings().autoPlayVideos}
|
||||||
style={style()}
|
playsinline={settings().autoPlayVideos ? true : undefined}
|
||||||
data-sort={index}
|
controls
|
||||||
data-media-type={item().type}
|
poster={item().previewUrl}
|
||||||
preload="metadata"
|
width={item().meta?.small?.width}
|
||||||
/>
|
height={item().meta?.small?.height}
|
||||||
</Match>
|
data-sort={index}
|
||||||
<Match when={itemType() === "gifv"}>
|
data-media-type={item().type}
|
||||||
<video
|
preload="metadata"
|
||||||
src={item().url || undefined}
|
/>
|
||||||
autoplay={!props.sensitive && settings().autoPlayGIFs}
|
</Match>
|
||||||
controls
|
<Match when={itemType() === "gifv"}>
|
||||||
playsinline /* or safari on iOS will play in full-screen */
|
<video
|
||||||
loop
|
src={item().url || undefined}
|
||||||
poster={item().previewUrl}
|
autoplay={!props.sensitive && settings().autoPlayGIFs}
|
||||||
width={item().meta?.small?.width}
|
controls
|
||||||
height={item().meta?.small?.height}
|
playsinline /* or safari on iOS will play in full-screen */
|
||||||
style={style()}
|
loop
|
||||||
data-sort={index}
|
poster={item().previewUrl}
|
||||||
data-media-type={item().type}
|
width={item().meta?.small?.width}
|
||||||
preload="metadata"
|
height={item().meta?.small?.height}
|
||||||
/>
|
data-sort={index}
|
||||||
</Match>
|
data-media-type={item().type}
|
||||||
<Match when={itemType() === "audio"}>
|
preload="metadata"
|
||||||
<audio
|
/>
|
||||||
src={item().url || undefined}
|
</Match>
|
||||||
controls
|
<Match when={itemType() === "audio"}>
|
||||||
data-sort={index}
|
<audio
|
||||||
data-media-type={item().type}
|
src={item().url || undefined}
|
||||||
></audio>
|
controls
|
||||||
</Match>
|
data-sort={index}
|
||||||
</Switch>
|
data-media-type={item().type}
|
||||||
|
></audio>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Index>
|
</Index>
|
||||||
</section>
|
</Masonry>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue