diff --git a/.env b/.env index ec2fda9..b5ff8ea 100644 --- a/.env +++ b/.env @@ -1,4 +1,5 @@ DEV_SERVER_HTTPS_CERT_BASE= DEV_SERVER_HTTPS_CERT_PASS= DEV_LOCATOR_EDITOR=vscode -VITE_DEVTOOLS_OVERLAY=true \ No newline at end of file +VITE_DEVTOOLS_OVERLAY=true +VITE_PLATFROM_MASONRY_ALWAYS_COMPAT= \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 0536eb9..0645c92 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 4d9e37a..219b2e0 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@solid-devtools/overlay": "^0.30.1", "@suid/vite-plugin": "^0.3.1", "@types/hammerjs": "^2.0.46", + "@types/masonry-layout": "^4.2.8", "@vite-pwa/assets-generator": "^0.2.6", "postcss": "^8.4.49", "prettier": "^3.3.3", @@ -49,6 +50,7 @@ "fast-average-color": "^9.4.0", "hammerjs": "^2.0.8", "iso-639-1": "^3.1.3", + "masonry-layout": "^4.2.2", "masto": "^6.10.1", "nanostores": "^0.11.3", "normalize.css": "^8.0.1", diff --git a/src/overrides.d.ts b/src/overrides.d.ts index 870c3fa..e8db98c 100644 --- a/src/overrides.d.ts +++ b/src/overrides.d.ts @@ -11,6 +11,10 @@ interface ImportMetaEnv { * Attach the overlay (in the dev mode) if it's `"true"`. */ readonly VITE_DEVTOOLS_OVERLAY?: string; + /** + * Always use compatible version of Masonry. + */ + readonly VITE_PLATFROM_MASONRY_ALWAYS_COMPAT?: string } interface ImportMeta { diff --git a/src/platform/Masonry.css b/src/platform/Masonry.css new file mode 100644 index 0000000..ed6264f --- /dev/null +++ b/src/platform/Masonry.css @@ -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)); + } + } +} + diff --git a/src/platform/Masonry.tsx b/src/platform/Masonry.tsx new file mode 100644 index 0000000..7071cd8 --- /dev/null +++ b/src/platform/Masonry.tsx @@ -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 + | Component<{ + ref?: Ref; + class?: string; + children?: JSX.Element; + }>; + +type ElementOf = + T extends Exclude + ? JSX.IntrinsicElements[T] extends { ref?: Ref } + ? E + : never + : T extends Component<{ ref?: Ref }> + ? E + : never; + +function forwardRef(value: T, ref?: Ref) { + 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( + oprops: DynamicProps & { class?: string }, +) { + const [props, rest] = splitProps(oprops, ["ref", "children", "class"]); + + const childrenComponents = children(() => props.children); + + return ( + ) => { + forwardRef(element, props.ref as Ref | undefined); + + const [columnGap, setColumnGap] = createSignal(); + + 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( + oprops: DynamicProps & { class?: string }, +) { + const [props, rest] = splitProps(oprops, ["class"]); + return ( + + ); +} + +/** + * 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; diff --git a/src/timelines/toots/MediaAttachmentGrid.css b/src/timelines/toots/MediaAttachmentGrid.css index 34b22c9..8fd667a 100644 --- a/src/timelines/toots/MediaAttachmentGrid.css +++ b/src/timelines/toots/MediaAttachmentGrid.css @@ -3,10 +3,10 @@ margin-top: 1em; margin-left: var(--card-pad, 0); margin-right: var(--card-pad, 0); - gap: 4px; contain: layout style; + gap: 4px; - > :where(img, video, .sensitive-placeholder) { + > * { max-height: 35vh; min-height: 40px; min-width: 40px; @@ -22,12 +22,20 @@ &:focus-visible { outline: 8px solid 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; } - >.sensitive-placeholder { + > * >.sensitive-placeholder { display: inline-flex; display: inline flex; 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); } \ No newline at end of file diff --git a/src/timelines/toots/MediaAttachmentGrid.tsx b/src/timelines/toots/MediaAttachmentGrid.tsx index 41c740e..c9d72fc 100644 --- a/src/timelines/toots/MediaAttachmentGrid.tsx +++ b/src/timelines/toots/MediaAttachmentGrid.tsx @@ -23,6 +23,7 @@ import "./MediaAttachmentGrid.css"; import cardStyle from "~material/cards.module.css"; import { Preview } from "@suid/icons-material"; import { IconButton } from "@suid/material"; +import Masonry from "~platform/Masonry"; type ElementSize = { width: number; height: number }; @@ -149,13 +150,13 @@ const MediaAttachmentGrid: Component<{ } }; return ( -
@@ -164,81 +165,79 @@ const MediaAttachmentGrid: Component<{ const style = createMemo(() => itemStyle(item())); return ( - - -
- + + +
- - -
-
- - {item().description - - - - - - - - -
+ + + +
+
+ + {item().description + + + + + + + + +
+ ); }}
-
+ ); };