diff --git a/src/utils/addImage.ts b/src/utils/addImage.ts index 84224035..2352b70f 100644 --- a/src/utils/addImage.ts +++ b/src/utils/addImage.ts @@ -16,13 +16,31 @@ export async function addImage( ) { let client = supabaseBrowserClient(); let cache = await caches.open("minilink-user-assets"); - let fileID = v7(); + let isAnimated = isAnimatedFormat(file.type); + let fileID = v7() + (isAnimated ? "." + file.name.split(".").pop() : ""); let url = client.storage.from("minilink-user-assets").getPublicUrl(fileID) .data.publicUrl; - // Re-encode through canvas to bake EXIF orientation into pixel data. - // iPhone photos have EXIF rotation metadata that browsers respect, but - // Supabase's image transformation pipeline strips without applying. - let { blob: uploadBlob, width, height } = await normalizeOrientation(file); + + let uploadBlob: Blob; + let width: number; + let height: number; + if (isAnimated) { + // Skip re-encoding for animated formats (GIF, APNG, animated WebP) + // to preserve animation frames + uploadBlob = file; + let bitmap = await createImageBitmap(file); + width = bitmap.width; + height = bitmap.height; + bitmap.close(); + } else { + // Re-encode through canvas to bake EXIF orientation into pixel data. + // iPhone photos have EXIF rotation metadata that browsers respect, but + // Supabase's image transformation pipeline strips without applying. + let normalized = await normalizeOrientation(file); + uploadBlob = normalized.blob; + width = normalized.width; + height = normalized.height; + } await cache.put( new URL(url + "?local"), @@ -99,6 +117,10 @@ async function getThumbHash(file: File) { return thumbHash; } +function isAnimatedFormat(mimeType: string): boolean { + return mimeType === "image/gif" || mimeType === "image/apng"; +} + async function normalizeOrientation( file: File, ): Promise<{ blob: Blob; width: number; height: number }> { -- 2.51.2 From aebe479d27fc6649242f392de9266ae80d4c54d9 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 8 Apr 2026 14:18:36 -0400 Subject: [PATCH 02/13] some tweaks to sidenotes --- app/globals.css | 5 +- .../Footnotes/FootnoteSideColumnLayout.tsx | 64 +++++++++++++------ 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/app/globals.css b/app/globals.css index de5e0ecd..80de871f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -620,6 +620,9 @@ pre.shiki { .footnote-side-item:hover, .footnote-side-item:focus-within, .footnote-side-item.footnote-side-focused { - max-height: 40em; + max-height: none; + overflow: visible; opacity: var(--bg-page-alpha); + z-index: 1; + background: rgba(var(--bg-page), 1); } diff --git a/components/Footnotes/FootnoteSideColumnLayout.tsx b/components/Footnotes/FootnoteSideColumnLayout.tsx index ae9007ba..71411589 100644 --- a/components/Footnotes/FootnoteSideColumnLayout.tsx +++ b/components/Footnotes/FootnoteSideColumnLayout.tsx @@ -20,7 +20,6 @@ export function FootnoteSideColumnLayout(props: { let containerRef = useRef(null); let innerRef = useRef(null); let [positions, setPositions] = useState<(T & { top: number })[]>([]); - let [scrollOffset, setScrollOffset] = useState(0); let calculatePositions = useCallback(() => { let container = containerRef.current; @@ -36,7 +35,9 @@ export function FootnoteSideColumnLayout(props: { let scrollTop = scrollWrapper.scrollTop; let scrollWrapperRect = scrollWrapper.getBoundingClientRect(); - setScrollOffset(scrollTop); + + // Sync scroll transform directly on the DOM (no React re-render) + inner.style.transform = `translateY(-${scrollTop}px)`; let measurements: (T & { anchorTop: number; height: number })[] = []; @@ -79,24 +80,60 @@ export function FootnoteSideColumnLayout(props: { ?.querySelector(".pageScrollWrapper") as HTMLElement | null; if (!scrollWrapper) return; + // On scroll, update the transform directly without React re-render let onScroll = () => { - setScrollOffset(scrollWrapper!.scrollTop); + let inner = innerRef.current; + if (inner) { + inner.style.transform = `translateY(-${scrollWrapper!.scrollTop}px)`; + } }; - scrollWrapper.addEventListener("scroll", onScroll); + scrollWrapper.addEventListener("scroll", onScroll, { passive: true }); + + // Forward wheel events from the side column to the scroll wrapper + let container = containerRef.current!; + let onWheel = (e: WheelEvent) => { + scrollWrapper!.scrollTop += e.deltaY; + }; + container.addEventListener("wheel", onWheel, { passive: true }); let resizeObserver = new ResizeObserver(calculatePositions); resizeObserver.observe(scrollWrapper); - let mutationObserver = new MutationObserver(calculatePositions); + // Observe all side items so positions recalculate when their heights change + let observeSideItems = () => { + let inner = innerRef.current; + if (!inner) return; + for (let el of inner.querySelectorAll("[data-footnote-side-id]")) { + resizeObserver.observe(el); + } + }; + observeSideItems(); + + let mutationObserver = new MutationObserver(() => { + calculatePositions(); + // Re-observe in case new items were added + observeSideItems(); + }); mutationObserver.observe(scrollWrapper, { childList: true, subtree: true, characterData: true, }); + // Also observe the inner container so we recalculate when side items + // are added/removed (they're siblings of scrollWrapper, not children) + let innerEl = innerRef.current; + if (innerEl) { + mutationObserver.observe(innerEl, { + childList: true, + subtree: true, + }); + } + return () => { scrollWrapper!.removeEventListener("scroll", onScroll); + container.removeEventListener("wheel", onWheel); resizeObserver.disconnect(); mutationObserver.disconnect(); }; @@ -107,7 +144,7 @@ export function FootnoteSideColumnLayout(props: { return (