diff --git a/.gitignore b/.gitignore index 6db7081..39eb3cd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ dev-dist/ .env.local .env.*.local +.DS_Store diff --git a/bun.lockb b/bun.lockb index cd4221a..6d0a7e4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 69b71d9..7eeeb9d 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "@solid-primitives/map": "^0.4.13", "@solid-primitives/page-visibility": "^2.0.17", "@solid-primitives/resize-observer": "^2.0.26", + "@solid-primitives/static-store": "^0.1.0", "@solidjs/router": "^0.15.2", - "@solid-primitives/rootless": "^1.4.5", "@suid/icons-material": "^0.8.1", "@suid/material": "^0.18.0", "blurhash": "^2.0.5", @@ -74,5 +74,11 @@ "workbox-core": "^7.3.0", "workbox-precaching": "^7.3.0" }, - "packageManager": "bun@1.1.34" + "packageManager": "bun@1.1.34", + "trustedDependencies": [ + "edgedriver" + ], + "patchedDependencies": { + "@suid/vite-plugin@0.3.1": "patches/@suid%2Fvite-plugin@0.3.1.patch" + } } diff --git a/patches/@suid%2Fvite-plugin@0.3.1.patch b/patches/@suid%2Fvite-plugin@0.3.1.patch new file mode 100644 index 0000000..1464609 --- /dev/null +++ b/patches/@suid%2Fvite-plugin@0.3.1.patch @@ -0,0 +1,15 @@ +diff --git a/index.mjs b/index.mjs +index 2cf42b9506e130fcc9c1ca166d03d3d8b9b5781c..7dc283602ff09b94c6300f45eac223125eebeb46 100644 +--- a/index.mjs ++++ b/index.mjs +@@ -2,8 +2,8 @@ import $generate from "@babel/generator"; + import { parse } from "@babel/parser"; + import $traverse from "@babel/traverse"; + import * as types from "@babel/types"; +-const traverse = $traverse.default; +-const generate = $generate.default; ++const traverse = $traverse.default ?? $traverse; ++const generate = $generate.default ?? $generate; + const defaultOptions = { + disableOptimizeDeps: [ + "@suid/base", diff --git a/src/App.tsx b/src/App.tsx index 5e69ff3..9374b59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { Service } from "./serviceworker/services.js"; import { makeEventListener } from "@solid-primitives/event-listener"; import { ServiceWorkerProvider } from "./platform/host.js"; import StackedRouter from "./platform/StackedRouter.js"; +import {ResizeObserverBoundary} from "~platform/resize-observer.jsx"; const AccountSignIn = lazy(() => import("./accounts/SignIn.js")); const AccountMastodonOAuth2Callback = lazy( @@ -157,6 +158,7 @@ const App: Component = () => { }} > + { + ); diff --git a/src/material/Scaffold.tsx b/src/material/Scaffold.tsx index a2c7b69..603c1e8 100644 --- a/src/material/Scaffold.tsx +++ b/src/material/Scaffold.tsx @@ -1,4 +1,4 @@ -import { createElementSize } from "@solid-primitives/resize-observer"; +import { createElementSize } from "~platform/resize-observer"; import { JSX, Show, diff --git a/src/platform/Masonry.tsx b/src/platform/Masonry.tsx index ab9f002..8176889 100644 --- a/src/platform/Masonry.tsx +++ b/src/platform/Masonry.tsx @@ -10,7 +10,7 @@ import { } from "solid-js"; import { Dynamic, type DynamicProps } from "solid-js/web"; import MasonryLayout from "masonry-layout"; -import { createElementSize } from "@solid-primitives/resize-observer"; +import { createElementSize } from "~platform/resize-observer"; import "./Masonry.css"; type MasonryContainer = diff --git a/src/platform/MediaQuickview.css b/src/platform/MediaQuickview.css deleted file mode 100644 index 3ad798c..0000000 --- a/src/platform/MediaQuickview.css +++ /dev/null @@ -1,76 +0,0 @@ -.MediaQuickview__root { - display: contents; -} - -.MediaQuickview { - border: none; - position: fixed; - width: 100vw; - width: 100dvw; - height: 100vh; - height: 100dvh; - max-width: 100vw; - max-height: 100vh; - contain: content; - padding: 0; - - &::backdrop { - background: none; - } - - >.Scaffold>.topbar { - position: fixed; - left: 0; - right: 0; - - >* { - background-color: var(--tutu-color-surface); - color: var(--tutu-color-on-surface); - } - } - - >.Scaffold>.pages { - display: grid; - grid-auto-flow: column; - grid-auto-columns: 100%; - height: 100%; - width: 100%; - overflow: auto hidden; - scroll-snap-type: x mandatory; - scroll-snap-align: center; - scroll-snap-stop: always; - - - >.page { - width: 100%; - height: 100%; - max-width: 100vw; - max-height: 100vh; - contain: strict; - - cursor: grab; - - >* { - display: block; - - object-fit: contain; - object-position: center; - transform-origin: 0 0; - } - } - } - - &.lightout { - >.Scaffold { - >.topbar { - visibility: hidden; - } - } - } - - &.moving { - >.Scaffold>.pages>.page { - cursor: grabbing; - } - } -} \ No newline at end of file diff --git a/src/platform/MediaQuickview.tsx b/src/platform/MediaQuickview.tsx deleted file mode 100644 index c4b2cde..0000000 --- a/src/platform/MediaQuickview.tsx +++ /dev/null @@ -1,331 +0,0 @@ -import { createCallback, createSubRoot } from "@solid-primitives/rootless"; -import { - batch, - createMemo, - createRenderEffect, - createSignal, - Index, - Match, - onCleanup, - onMount, - Switch, - type Component, -} from "solid-js"; -import { render } from "solid-js/web"; -import Scaffold from "~material/Scaffold"; -import { isPointNotInRect } from "./dom"; -import "./MediaQuickview.css"; -import AppTopBar from "~material/AppTopBar"; -import { IconButton } from "@suid/material"; -import { Close } from "@suid/icons-material"; -import { createStore, unwrap } from "solid-js/store"; - -function renderIsolateMediaQuickview( - each: QuickviewMedia[], - index: number, - transitionFrom?: Element, -) { - createSubRoot((disposeAll) => { - let container: HTMLDivElement; - - createRenderEffect(() => { - container = document.createElement("div"); - container.setAttribute("role", "presentation"); - container.classList.add("MediaQuickview__root"); - document.querySelector("body")!.appendChild(container); - - onCleanup(() => container.remove()); - - const dispose = render(() => { - return ( - - ); - }, container); - - onCleanup(dispose); - }); - }); -} - -export function createMediaQuickview() { - return createCallback(renderIsolateMediaQuickview); -} - -type VisualMediaMeta = { - width: number; - height: number; -}; - -function ImagePage(props: { - src: string; - alt?: string; - scale: number; - offsetX: number; - offsetY: number; - - onMetadata?(metadata: VisualMediaMeta): void; -}) { - const [loaded, setLoaded] = createSignal(false); - - return ( - { - const { naturalHeight, naturalWidth } = currentTarget; - - props.onMetadata?.({ - width: naturalWidth, - height: naturalHeight, - }); - - setLoaded(true); - }} - src={props.src} - alt={props.alt} - style={{ - transform: `scale(${props.scale}) translateX(${-props.offsetX}px) translateY(${-props.offsetY}px)`, - opacity: loaded() ? 1 : 0, - }} - > - ); -} - -export type QuickviewMedia = { - cat: "image" | "video" | "gifv" | "audio" | "unknown"; - src: string; - alt?: string; -}; - -export type MediaQuickviewProps = { - each: QuickviewMedia[]; - defaultIndex: number; - transitonFrom?: Element; - - onClose?(): void; -}; - -const MediaQuickview: Component = (props) => { - let root: HTMLDialogElement; - - const [lightOut, setLightOut] = createSignal(false); - - function onDialogClick( - event: MouseEvent & { currentTarget: HTMLDialogElement }, - ) { - event.stopPropagation(); - - if ( - isPointNotInRect( - event.currentTarget.getBoundingClientRect(), - event.clientX, - event.clientY, - ) - ) { - event.currentTarget.close(); - } else { - setLightOut((x) => !x); - } - } - - const [transformations, setTransformations] = createStore( - [] as { - scale: number; - /** - * positive = the left edge move towards left - */ - offsetX: number; - /** - * positive = the top edge move towards top - */ - offsetY: number; - - size?: { - width: number; - height: number; - }; - }[], - ); - - const transformationGetOrSetDefault = (index: number) => { - if (transformations.length <= index) { - setTransformations(index, { - scale: 1, - offsetX: 0, - offsetY: 0, - }); - } - return transformations[index]; - }; - - const onWheel = ( - index: number, - event: WheelEvent & { currentTarget: HTMLElement }, - ) => { - // This is a de-facto standard for scaling: - // Browsers will simulate ctrl + wheel for two point scaling gesture on trackpad. - if (event.ctrlKey) { - event.preventDefault(); - event.stopPropagation(); - - const { clientX, clientY, currentTarget, deltaY } = event; - - const offset = -deltaY; // not reversed wheel: wheel up = scale +, wheel down = scale - - const scaleOffset = offset / currentTarget.clientHeight; - - // Map the screen scale to the image viewport scale - const userOriginX = clientX - currentTarget.clientLeft; - const userOriginY = clientY - currentTarget.clientTop; - setTransformations(index, ({ scale, offsetX, offsetY }) => { - const nscale = scale + scaleOffset; - return { - offsetX: offsetX + (userOriginX * nscale - userOriginX), - offsetY: offsetY + (userOriginY * nscale - userOriginX), - scale: nscale, - }; - }); - } - }; - - const [isMoving, setIsMoving] = createSignal(false); - let movOriginX: number = 0, - movOriginY: number = 0; - - const onMouseDown = (event: MouseEvent) => { - event.preventDefault(); - setIsMoving(true); - movOriginX = event.clientX; - movOriginY = event.clientY; - }; - - const onMouseMove = (index: number, event: MouseEvent) => { - if (!isMoving()) { - return; - } - const dx = movOriginX - event.clientX; - const dy = movOriginY - event.clientY; - - setTransformations(index, ({ offsetX, offsetY }) => { - return { - offsetX: offsetX + dx, - offsetY: offsetY + dy, - }; - }); - - movOriginX = event.clientX; - movOriginY = event.clientY; - }; - - const onMouseUp = (event: MouseEvent) => { - setIsMoving(false); - }; - - const recenter = (index: number) => { - const sz = transformations[index].size; - if (!sz) return; - const { width, height } = sz; - - const xscale = Math.min(window.innerWidth / width, 1); - const yscale = Math.min(window.innerHeight / height, 1); - - const finalScale = Math.min(xscale, yscale); - - const nheight = height * finalScale; - const top = (window.innerHeight - nheight) / 2; - - const nwidth = width * finalScale; - const left = (window.innerWidth - nwidth) / 2; - - setTransformations(index, { - scale: finalScale, - offsetX: -left, - offsetY: -top, - }); - }; - - const onMetadata = (index: number, { width, height }: VisualMediaMeta) => { - setTransformations(index, { - size: { - width, - height, - }, - }); - - recenter(index); - }; - - return ( - { - root = e; - onMount(() => { - e.showModal(); - }); - }} - class="MediaQuickview" - classList={{ lightout: lightOut(), moving: isMoving() }} - onClose={props.onClose} - onCancel={props.onClose} - onClick={onDialogClick} - > - - root.close()} - disableFocusRipple - > - - - - } - > -
{ - onMount(() => { - e.children.item(props.defaultIndex)!.scrollIntoView({ - behavior: "instant", - inline: "center", - }); - }); - }} - class="pages" - > - - {(item, index) => { - return ( -
- - - onMetadata(index, m)} - src={item().src} - alt={item().alt} - scale={transformationGetOrSetDefault(index).scale} - offsetX={transformationGetOrSetDefault(index).offsetX} - offsetY={transformationGetOrSetDefault(index).offsetY} - /> - - -
- ); - }} -
-
-
-
- ); -}; - -export default MediaQuickview; diff --git a/src/platform/resize-observer.tsx b/src/platform/resize-observer.tsx new file mode 100644 index 0000000..fcc5808 --- /dev/null +++ b/src/platform/resize-observer.tsx @@ -0,0 +1,158 @@ +import { + createContext, + createEffect, + onCleanup, + sharedConfig, + useContext, + type JSX, +} from "solid-js"; +import { isDev, isServer } from "solid-js/web"; +import { createStaticStore } from "@solid-primitives/static-store"; + +export type ObserveCallback = ( + entry: ResizeObserverEntry & { readonly target: E }, +) => void; +export type ObserveElement = ( + element: E, + callback: ObserveCallback, +) => () => void; + +const ResizeObserverContext = /* @__PURE__ */ createContext(); + +export function useResizeObserver() { + const observe = useContext(ResizeObserverContext); + + if (isDev && !observe) { + throw new TypeError( + "ObserverElement is not found, this function must be called in ", + ); + } + + return observe!; +} + +function ResizeObserverBoundaryClient(props: { children: JSX.Element }) { + const map = new Map< + Element, + ObserveCallback | ObserveCallback[] + >(); + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const callback = map.get(entry.target); + if (!callback) return; + if (Array.isArray(callback)) { + for (const f of callback) { + f(entry); + } + } else { + callback(entry); + } + } + }); + + onCleanup(() => { + observer.disconnect(); + }); + + const observe: ObserveElement = ( + element: Element, + callback: ObserveCallback, + ) => { + const callbacks = map.get(element); + if (!callbacks) { + map.set(element, callback); + observer.observe(element); + } else if (Array.isArray(callbacks)) { + callbacks.push(callback); + } else { + map.set(element, [callbacks, callback]); + } + + return () => { + const callbacks = map.get(element); + if (callbacks === null) { + observer.unobserve(element); + } else if (Array.isArray(callbacks)) { + const idx = callbacks.indexOf(callback); + if (idx !== -1) { + callbacks.splice(idx, 1); + } + + if (callbacks.length === 0) { + observer.unobserve(element); + map.delete(element); + } + } else { + observer.unobserve(element); + map.delete(element); + } + }; + }; + + return ( + + {props.children} + + ); +} + +function ResizeObserverBoundaryServer(props: { children: JSX.Element }) { + return ( + {}) as unknown as ObserveElement} + > + {props.children} + + ); +} + +export const ResizeObserverBoundary = isServer + ? ResizeObserverBoundaryServer + : ResizeObserverBoundaryClient; + +const ELEMENT_SIZE_FALLBACK = { width: null, height: null }; + +function getElementSize(target: Element) { + if (isServer || !target) { + return { ...ELEMENT_SIZE_FALLBACK }; + } + const { width, height } = target.getBoundingClientRect(); + return { width, height }; +} + +export type NullableSize = {width: number | null, height: number | null} + +export function createElementSize( + target: Element | (() => Element | undefined | null | false), +): Readonly { + if (isServer) { + return ELEMENT_SIZE_FALLBACK; + } + const isFn = typeof target === "function"; + const [size, setSize] = createStaticStore( + sharedConfig.context || isFn + ? ELEMENT_SIZE_FALLBACK + : getElementSize(target), + ); + const callback: ObserveCallback = (entry) => { + setSize(getElementSize(entry.target)); + }; + const observe = useResizeObserver(); + if (isFn) { + createEffect(() => { + const el = target(); + if (el) { + setSize(getElementSize(el)); + const unobserve = observe(el, callback); + onCleanup(unobserve); + } + }); + } else { + const unobserve = observe(target, callback); + onCleanup(unobserve); + } + return size; +} + +export { useWindowSize } from "@solid-primitives/resize-observer"; diff --git a/src/timelines/toots/MediaAttachmentGrid.tsx b/src/timelines/toots/MediaAttachmentGrid.tsx index 77e7dda..be5f7d8 100644 --- a/src/timelines/toots/MediaAttachmentGrid.tsx +++ b/src/timelines/toots/MediaAttachmentGrid.tsx @@ -5,13 +5,14 @@ import { Match, Switch, createMemo, + createRenderEffect, createSignal, + onCleanup, untrack, } from "solid-js"; -import { - createElementSize, - useWindowSize, -} from "@solid-primitives/resize-observer"; +import MediaViewer from "../MediaViewer"; +import { render } from "solid-js/web"; +import { createElementSize, useWindowSize } from "~platform/resize-observer"; import { useStore } from "@nanostores/solid"; import { $settings } from "../../settings/stores"; import { averageColorHex } from "~platform/blurhash"; @@ -20,7 +21,6 @@ import "~material/cards.css"; import { Preview } from "@suid/icons-material"; import { IconButton } from "@suid/material"; import Masonry from "~platform/Masonry"; -import { createMediaQuickview } from "~platform/MediaQuickview"; type ElementSize = { width: number; height: number }; @@ -55,23 +55,39 @@ const MediaAttachmentGrid: Component<{ sensitive?: boolean; }> = (props) => { const [rootRef, setRootRef] = createSignal(); + const [viewerIndex, setViewerIndex] = createSignal(); + const viewerOpened = () => typeof viewerIndex() !== "undefined"; const settings = useStore($settings); const windowSize = useWindowSize(); const [reveal, setReveal] = createSignal([] as number[]); - const openMediaQuickview = createMediaQuickview(); + createRenderEffect(() => { + const vidx = viewerIndex(); + if (typeof vidx === "undefined") return; + const container = document.createElement("div"); + container.setAttribute("role", "presentation"); + document.body.appendChild(container); + const dispose = render(() => { + onCleanup(() => { + document.body.removeChild(container); + }); + + return ( + setViewerIndex()} + /> + ); + }, container); + + onCleanup(dispose); + }); const openViewerFor = (index: number) => { - openMediaQuickview( - props.attachments.map((item) => { - return { - cat: item.type, - src: item.url as string, - alt: item.description || undefined, - }; - }), - index, - ); + setViewerIndex(index); }; const columnCount = () => {