diff --git a/.gitignore b/.gitignore index 39eb3cd..6db7081 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ dist/ dev-dist/ .env.local .env.*.local -.DS_Store diff --git a/bun.lockb b/bun.lockb index 6d0a7e4..cd4221a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 7eeeb9d..69b71d9 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,11 +74,5 @@ "workbox-core": "^7.3.0", "workbox-precaching": "^7.3.0" }, - "packageManager": "bun@1.1.34", - "trustedDependencies": [ - "edgedriver" - ], - "patchedDependencies": { - "@suid/vite-plugin@0.3.1": "patches/@suid%2Fvite-plugin@0.3.1.patch" - } + "packageManager": "bun@1.1.34" } diff --git a/patches/@suid%2Fvite-plugin@0.3.1.patch b/patches/@suid%2Fvite-plugin@0.3.1.patch deleted file mode 100644 index 1464609..0000000 --- a/patches/@suid%2Fvite-plugin@0.3.1.patch +++ /dev/null @@ -1,15 +0,0 @@ -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 9374b59..5e69ff3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,7 +33,6 @@ 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( @@ -158,7 +157,6 @@ const App: Component = () => { }} > - { - ); diff --git a/src/material/Scaffold.tsx b/src/material/Scaffold.tsx index 603c1e8..a2c7b69 100644 --- a/src/material/Scaffold.tsx +++ b/src/material/Scaffold.tsx @@ -1,4 +1,4 @@ -import { createElementSize } from "~platform/resize-observer"; +import { createElementSize } from "@solid-primitives/resize-observer"; import { JSX, Show, diff --git a/src/platform/Masonry.tsx b/src/platform/Masonry.tsx index 8176889..ab9f002 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 "~platform/resize-observer"; +import { createElementSize } from "@solid-primitives/resize-observer"; import "./Masonry.css"; type MasonryContainer = diff --git a/src/platform/MediaQuickview.css b/src/platform/MediaQuickview.css new file mode 100644 index 0000000..3ad798c --- /dev/null +++ b/src/platform/MediaQuickview.css @@ -0,0 +1,76 @@ +.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 new file mode 100644 index 0000000..c4b2cde --- /dev/null +++ b/src/platform/MediaQuickview.tsx @@ -0,0 +1,331 @@ +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 deleted file mode 100644 index fcc5808..0000000 --- a/src/platform/resize-observer.tsx +++ /dev/null @@ -1,158 +0,0 @@ -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 be5f7d8..77e7dda 100644 --- a/src/timelines/toots/MediaAttachmentGrid.tsx +++ b/src/timelines/toots/MediaAttachmentGrid.tsx @@ -5,14 +5,13 @@ import { Match, Switch, createMemo, - createRenderEffect, createSignal, - onCleanup, untrack, } from "solid-js"; -import MediaViewer from "../MediaViewer"; -import { render } from "solid-js/web"; -import { createElementSize, useWindowSize } from "~platform/resize-observer"; +import { + createElementSize, + useWindowSize, +} from "@solid-primitives/resize-observer"; import { useStore } from "@nanostores/solid"; import { $settings } from "../../settings/stores"; import { averageColorHex } from "~platform/blurhash"; @@ -21,6 +20,7 @@ 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,39 +55,23 @@ 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[]); - 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 openMediaQuickview = createMediaQuickview(); const openViewerFor = (index: number) => { - setViewerIndex(index); + openMediaQuickview( + props.attachments.map((item) => { + return { + cat: item.type, + src: item.url as string, + alt: item.description || undefined, + }; + }), + index, + ); }; const columnCount = () => {