diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownImage.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownImage.tsx index 210b3bf406..2a0866ed1a 100644 --- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownImage.tsx +++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownImage.tsx @@ -2,6 +2,7 @@ import { internalDrag } from '@core/directive/internalDragState'; false && internalDrag; +import { Lightbox } from '@core/component/Lightbox'; import { toast } from '@core/component/Toast/Toast'; import { debouncedDependent } from '@core/util/debounce'; @@ -11,9 +12,8 @@ import { $isImageNode, type ImageDecoratorProps } from '@lexical-core'; import { calculateEffectiveDimensions } from '@lexical-core/utils/media'; import ImageIcon from '@phosphor/image-broken.svg'; import LoadingSpinner from '@phosphor/spinner.svg'; -import XIcon from '@phosphor/x.svg'; import { debounce } from '@solid-primitives/scheduled'; -import { Button, cn, Layer } from '@ui'; +import { cn, Layer } from '@ui'; import { $createNodeSelection, $getNodeByKey, @@ -386,23 +386,8 @@ export function MarkdownImage(props: ImageDecoratorProps) { - -
- -
- - - -
- -
-
+ + props.id} navigationHidden />
); diff --git a/js/app/packages/core/component/Lightbox.tsx b/js/app/packages/core/component/Lightbox.tsx index fe3c4fc51a..77cba62c9c 100644 --- a/js/app/packages/core/component/Lightbox.tsx +++ b/js/app/packages/core/component/Lightbox.tsx @@ -1,5 +1,6 @@ import * as stackingContext from '@core/constant/stackingContext'; import { isMobile } from '@core/mobile/isMobile'; +import { isTouchDevice } from '@core/mobile/isTouchDevice'; import { Dialog, useDialogContext } from '@kobalte/core/dialog'; import ChevronLeftIcon from '@phosphor/caret-left.svg'; import ChevronRightIcon from '@phosphor/caret-right.svg'; @@ -110,25 +111,205 @@ export function Lightbox(props: LightboxProps) { // Reactive cursor state — drives the cursor style on the Zoompinch wrapper. const [isDragging, setIsDragging] = createSignal(false); const [currentScale, setCurrentScale] = createSignal(1); + + // Zoom is split into card growth × engine scale. Each card axis grows with + // the zoom level independently until it hits the available area, so a + // capped axis starts cropping while the other keeps growing. The canvas + // keeps the image's aspect ratio, and the engine's model is "scale 1 = + // canvas contain-fits the wrapper", so the displayed image size is always + // base × zoom and exactly fills the card on every uncapped axis. + let containerEl: HTMLDivElement | undefined; + const [baseSize, setBaseSize] = createSignal<{ w: number; h: number }>(); + const [zoom, setZoom] = createSignal(1); + let inApplyZoom = false; + + const availableArea = () => { + if (!containerEl) return undefined; + return { + w: containerEl.clientWidth, + // Mirror the img's sm:max-h-[80vh] cap on desktop + h: isMobile() + ? containerEl.clientHeight + : Math.min(containerEl.clientHeight, window.innerHeight * 0.8), + }; + }; + + const cardSizeFor = (base: { w: number; h: number }, z: number) => { + const avail = availableArea(); + if (!avail) return { w: base.w * z, h: base.h * z }; + return { + w: Math.min(base.w * z, avail.w), + h: Math.min(base.h * z, avail.h), + }; + }; + + const cardSize = createMemo(() => { + const base = baseSize(); + return base ? cardSizeFor(base, zoom()) : undefined; + }); + + // The engine's naturalScale: how far the base-aspect canvas is scaled to + // contain-fit the card. Engine scale carries the rest of the total zoom. + const containFactor = ( + base: { w: number; h: number }, + card: { w: number; h: number } + ) => Math.min(card.w / base.w, card.h / base.h); + + const totalZoom = createMemo(() => { + const base = baseSize(); + const card = cardSize(); + return base && card ? currentScale() * containFactor(base, card) : 1; + }); + + // The unzoomed card size: contain-fit into the available area without + // upscaling, with the img's sm:min-w-50 floor on desktop. + const measureBase = (img: HTMLImageElement) => { + const avail = availableArea(); + const nw = img.naturalWidth; + const nh = img.naturalHeight; + if (!avail || !nw || !nh) return undefined; + const fit = Math.min(avail.w / nw, avail.h / nh, 1); + return { w: Math.max(nw * fit, isMobile() ? 0 : 200), h: nh * fit }; + }; + + // The engine caches wrapper/canvas bounds via ResizeObservers, which fire + // after we resize the card. Refresh them synchronously so transforms applied + // in the same tick use the new geometry. + const syncEngineBounds = (handle: ZoompinchHandle) => { + const { engine, wrapperElement } = handle; + engine.wrapperBounds = wrapperElement.getBoundingClientRect(); + const canvas = wrapperElement.querySelector( + '.canvas' + ) as HTMLElement | null; + if (canvas) { + // offsetWidth/Height: layout size, unaffected by the engine's transform + engine.canvasBounds = { + ...engine.canvasBounds, + width: canvas.offsetWidth, + height: canvas.offsetHeight, + }; + } + }; + + // The canvas-relative (0-1) content point currently under the wrapper center. + const centerCanvasRel = ( + engine: ZoompinchHandle['engine'] + ): [number, number] => { + const [cx, cy] = engine.normalizeClientCoords( + engine.wrapperInnerX + engine.wrapperInnerWidth / 2, + engine.wrapperInnerY + engine.wrapperInnerHeight / 2 + ); + return [cx / engine.canvasBounds.width, cy / engine.canvasBounds.height]; + }; + + const applyZoom = ( + handle: ZoompinchHandle, + zoomLevel: number, + anchor?: [number, number] + ) => { + const { engine } = handle; + const base = baseSize(); + const z = Math.max(1, zoomLevel); + const f = base ? containFactor(base, cardSizeFor(base, z)) : 1; + // Without an explicit anchor, keep whatever content point sits at the card + // center fixed across the resize — read it from the old geometry first. + const wrapperAnchor = anchor ?? ([0.5, 0.5] as [number, number]); + const canvasAnchor = anchor ?? centerCanvasRel(engine); + inApplyZoom = true; + setZoom(z); + syncEngineBounds(handle); + engine.applyTransform(z / f, wrapperAnchor, canvasAnchor); + inApplyZoom = false; + }; + + // Continuous wheel / trackpad zoom. The engine computes the new scale (so the + // zoom feel is unchanged), but we discard its cursor-anchored translate and + // re-apply the zoom anchored at the *pre-gesture* view center via applyZoom. + // Center-anchoring is what keeps the image continuous across the point where + // an axis stops growing and starts cropping: while an axis is still growing + // there is no overflow on it, so the engine force-centers it and an + // off-center anchor's offset is suppressed — it would otherwise snap free the + // instant the axis caps, producing a visible jump. Non-zoom wheel events pan. + const handleWheel = (e: WheelEvent, engine: ZoompinchHandle['engine']) => { + const handle = zoompinchHandle(); + const base = baseSize(); + const card = cardSize(); + if (e.ctrlKey && handle && base && card) { + e.preventDefault(); + const anchor = centerCanvasRel(engine); // capture before the engine moves + // Let the engine derive the new scale, but suppress the rebalance pass — + // we re-apply the zoom ourselves below with the correct anchor. + inApplyZoom = true; + engine.handleWheel(e); + inApplyZoom = false; + applyZoom(handle, engine.scale * containFactor(base, card), anchor); + return; + } + // Pan. The engine's own wheel-pan multiplies the delta by 25 and only + // normalizes when |delta| >= 100, so trackpads and hi-res mice — whose + // deltas are small (often fractional) — lurch hundreds of px per event, + // snapping straight to the clamp edge. Pan 1:1 with the real pixel delta + // instead. (deltaMode: 0 = px, 1 = lines, 2 = pages.) + e.preventDefault(); + const unit = + e.deltaMode === 1 + ? 16 + : e.deltaMode === 2 + ? engine.wrapperBounds.height + : 1; + engine.setTranslateFromUserGesture( + engine.translateX - e.deltaX * unit, + engine.translateY - e.deltaY * unit + ); + engine.update(); + }; + + // Rebalance after engine-driven zoom (wheel/pinch): move as much of the + // total zoom as possible into card growth, leaving the rest on the engine. + // Runs synchronously inside the engine's update event, before paint. + const rebalanceZoom = (engine: ZoompinchHandle['engine']) => { + if (inApplyZoom) return; + const handle = zoompinchHandle(); + const base = baseSize(); + const card = cardSize(); + if (!handle || !base || !card) return; + const z = Math.max(1, engine.scale * containFactor(base, card)); + const target = cardSizeFor(base, z); + if ( + Math.abs(z / containFactor(base, target) - engine.scale) > 0.001 || + Math.abs(target.w - card.w) > 0.5 || + Math.abs(target.h - card.h) > 0.5 + ) { + applyZoom(handle, z); + } + }; + const cursor = createMemo(() => { if (isDragging() && currentScale() > 1.01) return 'grab'; - if (currentScale() > 1.01) return 'zoom-out'; + if (totalZoom() > 1.01) return 'zoom-out'; return 'zoom-in'; }); - // Swipe-to-navigate state (used inside the touch override callbacks below) - let swipeTouchStartX = 0; - let swipeTouchEndX = 0; + // Single-finger swipe state. On touch devices, when fully zoomed out, a + // single-finger drag is a swipe gesture: horizontal navigates the gallery, + // a downward swipe dismisses the lightbox. The axis is locked on the first + // clearly-directional movement so the two don't fight. + const SWIPE_DISMISS_DISTANCE = 100; // px of downward travel to dismiss + let swipeStartX = 0; + let swipeStartY = 0; + let swipeEndX = 0; + let swipeEndY = 0; + let swipeAxis: 'x' | 'y' | null = null; let isSwiping = false; let zoompinchHandlingTouch = false; - // Touch override handlers for swipe-to-navigate const touchOnStart = (e: TouchEvent, engine: ZoompinchHandle['engine']) => { - const hasNav = props.onPrevious != null || props.onNext != null; const doSwipeDetection = - isMobile() && hasNav && e.touches.length === 1 && engine.scale <= 1.01; + isTouchDevice() && e.touches.length === 1 && totalZoom() <= 1.01; if (doSwipeDetection) { - swipeTouchStartX = e.touches[0].clientX; + swipeStartX = swipeEndX = e.touches[0].clientX; + swipeStartY = swipeEndY = e.touches[0].clientY; + swipeAxis = null; isSwiping = false; zoompinchHandlingTouch = false; } else { @@ -152,9 +333,21 @@ export function Lightbox(props: LightboxProps) { isSwiping = false; return; } - swipeTouchEndX = e.touches[0].clientX; - if (Math.abs(swipeTouchStartX - e.touches[0].clientX) > 30) + swipeEndX = e.touches[0].clientX; + swipeEndY = e.touches[0].clientY; + const dx = swipeEndX - swipeStartX; + const dy = swipeEndY - swipeStartY; + // Lock to the dominant axis once the gesture is clearly directional. + if (!swipeAxis && Math.hypot(dx, dy) > 10) { + swipeAxis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'; + } + // Downward-only on the y axis — an upward drag is left alone. + if ( + (swipeAxis === 'x' && Math.abs(dx) > 30) || + (swipeAxis === 'y' && dy > 30) + ) { isSwiping = true; + } if (isSwiping) e.preventDefault(); }; @@ -167,16 +360,23 @@ export function Lightbox(props: LightboxProps) { zoompinchHandlingTouch = false; return; } - if (isSwiping && engine.scale <= 1.01) { - const diff = swipeTouchStartX - swipeTouchEndX; - if (Math.abs(diff) > 50) { - if (diff > 0) props.onNext?.(); - else props.onPrevious?.(); + if (isSwiping && totalZoom() <= 1.01) { + if (swipeAxis === 'x') { + const diff = swipeStartX - swipeEndX; + if (Math.abs(diff) > 50) { + if (diff > 0) props.onNext?.(); + else props.onPrevious?.(); + } + } else if ( + swipeAxis === 'y' && + swipeEndY - swipeStartY > SWIPE_DISMISS_DISTANCE + ) { + dialogContext.close(); } } + swipeAxis = null; isSwiping = false; - swipeTouchStartX = 0; - swipeTouchEndX = 0; + swipeStartX = swipeStartY = swipeEndX = swipeEndY = 0; zoompinchHandlingTouch = false; }; @@ -230,10 +430,10 @@ export function Lightbox(props: LightboxProps) { const b = engine.wrapperBounds; const relX = (e.clientX - b.x) / b.width; const relY = (e.clientY - b.y) / b.height; - if (engine.scale <= 1.01) { - engine.applyTransform(2.5, [relX, relY], [relX, relY]); + if (totalZoom() <= 1.01) { + applyZoom(handle, 2.5, [relX, relY]); } else { - engine.applyTransform(1, [0.5, 0.5], [0.5, 0.5]); + applyZoom(handle, 1); } }; @@ -259,11 +459,11 @@ export function Lightbox(props: LightboxProps) { // Reset zoom when navigating to a different image. createEffect(() => { props.src(); - untrack(() => zoompinchHandle())?.engine.applyTransform( - 1, - [0.5, 0.5], - [0.5, 0.5] - ); + untrack(() => { + const handle = zoompinchHandle(); + if (handle) applyZoom(handle, 1); + else setZoom(1); + }); }); const navButtonClass = @@ -273,6 +473,7 @@ export function Lightbox(props: LightboxProps) { return (
- + {/* Toolbar */}
); }