MediaAttachmentGrid: use new ~platform/Masonry
All checks were successful
/ depoly (push) Successful in 1m30s

* Masonry is a component provides limited masonry
  layout support
This commit is contained in:
thislight 2024-11-23 00:01:45 +08:00
parent 3a3fa40437
commit 18fa2810c4
No known key found for this signature in database
GPG key ID: FCFE5192241CCD4E
8 changed files with 266 additions and 78 deletions

1
.env
View file

@ -2,3 +2,4 @@ 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

Binary file not shown.

View file

@ -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
View file

@ -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
View 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
View 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;

View file

@ -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);
} }

View file

@ -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>
); );
}; };