UNPKG

@sanity/visual-editing

Version:

[![npm stat](https://img.shields.io/npm/dm/@sanity/visual-editing.svg?style=flat-square)](https://npm-stat.com/charts.html?package=@sanity/visual-editing) [![npm version](https://img.shields.io/npm/v/@sanity/visual-editing.svg?style=flat-square)](https://

687 lines (553 loc) 17.6 kB
import type { DragInsertPosition, DragInsertPositionRects, ElementNode, OverlayElement, OverlayEventHandler, OverlayRect, Point2D, SanityNode, } from '../types' import { findClosestIntersection, getRect, getRectGroupXExtent, getRectGroupYExtent, pointDist, rectEqual, scaleRect, } from './geometry' function calcTargetFlow(targets: OverlayRect[]) { if ( targets.some((t1) => { const others = targets.filter((t2) => !rectEqual(t1, t2)) return others.some((t2) => { return t1.y === t2.y }) }) ) { return 'horizontal' } else { return 'vertical' } } function calcInsertPosition(origin: Point2D, targets: OverlayRect[], flow: string) { if (flow === 'horizontal') { const rayLeft = { x1: origin.x, y1: origin.y, x2: origin.x - 100_000_000, y2: origin.y, } const rayRight = { x1: origin.x, y1: origin.y, x2: origin.x + 100_000_000, y2: origin.y, } return { left: findClosestIntersection(rayLeft, targets, flow), right: findClosestIntersection(rayRight, targets, flow), } } else { const rayTop = { x1: origin.x, y1: origin.y, x2: origin.x, y2: origin.y - 100_000_000, } const rayBottom = { x1: origin.x, y1: origin.y, x2: origin.x, y2: origin.y + 100_000_000, } return { top: findClosestIntersection(rayTop, targets, flow), bottom: findClosestIntersection(rayBottom, targets, flow), } } } function findRectSanityData(rect: OverlayRect, overlayGroup: OverlayElement[]) { return overlayGroup.find((e) => rectEqual(getRect(e.elements.element), rect)) ?.sanity as SanityNode } function resolveInsertPosition( overlayGroup: OverlayElement[], insertPosition: DragInsertPositionRects, flow: string, ): DragInsertPosition { if (Object.values(insertPosition).every((v) => v === null)) return null if (flow === 'horizontal') { return { left: insertPosition.left ? { rect: insertPosition.left, sanity: findRectSanityData(insertPosition.left, overlayGroup), } : null, right: insertPosition.right ? { rect: insertPosition.right, sanity: findRectSanityData(insertPosition.right, overlayGroup), } : null, } } else { return { top: insertPosition.top ? { rect: insertPosition.top, sanity: findRectSanityData(insertPosition.top, overlayGroup), } : null, bottom: insertPosition.bottom ? { rect: insertPosition.bottom, sanity: findRectSanityData(insertPosition.bottom, overlayGroup), } : null, } } } function calcMousePos(e: MouseEvent) { const bodyBounds = document.body.getBoundingClientRect() return { x: Math.max(bodyBounds.x, Math.min(e.clientX, bodyBounds.x + bodyBounds.width)), y: e.clientY + window.scrollY, } } function calcMousePosInverseTransform(mousePos: Point2D) { const body = document.body const computedStyle = window.getComputedStyle(body) const transform = computedStyle.transform if (transform === 'none') { return { x: mousePos.x, y: mousePos.y, } } const matrix = new DOMMatrix(transform) const inverseMatrix = matrix.inverse() const point = new DOMPoint(mousePos.x, mousePos.y) const transformedPoint = point.matrixTransform(inverseMatrix) return { x: transformedPoint.x, y: transformedPoint.y, } } function buildPreviewSkeleton(mousePos: Point2D, element: ElementNode, scaleFactor: number) { const bounds = getRect(element) const children = [ ...element.querySelectorAll(':where(h1, h2, h3, h4, p, a, img, span, button):not(:has(*))'), ] if (mousePos.x <= bounds.x) mousePos.x = bounds.x if (mousePos.x >= bounds.x + bounds.w) mousePos.x = bounds.x + bounds.w if (mousePos.y >= bounds.y + bounds.h) mousePos.y = bounds.y + bounds.h if (mousePos.y <= bounds.y) mousePos.y = bounds.y const childRects = children.map((child: Element) => { // offset to account for stroke in rendered rects const rect = scaleRect(getRect(child), scaleFactor, { x: bounds.x, y: bounds.y, }) return { x: rect.x - bounds.x, y: rect.y - bounds.y, w: rect.w, h: rect.h, tagName: child.tagName, } }) return { offsetX: (bounds.x - mousePos.x) * scaleFactor, offsetY: (bounds.y - mousePos.y) * scaleFactor, w: bounds.w * scaleFactor, h: bounds.h * scaleFactor, maxWidth: bounds.w * scaleFactor * 0.75, childRects, } } const minDragDelta = 4 async function applyMinimapWrapperTransform( target: HTMLElement, scaleFactor: number, minYScaled: number, handler: OverlayEventHandler, rectUpdateFrequency: number, ): Promise<void> { return new Promise((resolve) => { target.addEventListener( 'transitionend', () => { setTimeout(() => { handler({ type: 'overlay/dragEndMinimapTransition', }) }, rectUpdateFrequency * 2) resolve() }, {once: true}, ) handler({ type: 'overlay/dragStartMinimapTransition', }) handler({ type: 'overlay/dragToggleMinimap', display: true, }) document.body.style.overflow = 'hidden' document.body.style.height = '100%' document.documentElement.style.overflow = 'initial' document.documentElement.style.height = '100%' // ensure overflow hidden has applied and scrolling stopped before applying transform, prevent minor y-position transform issues setTimeout(() => { target.style.transformOrigin = '50% 0px' target.style.transition = 'transform 150ms ease' target.style.transform = `translate3d(0px, ${-minYScaled + scrollY}px, 0px) scale(${scaleFactor})` }, 25) }) } function calcMinimapTransformValues(rects: OverlayRect[], groupHeightOverride: number | null) { let groupHeight = groupHeightOverride || getRectGroupYExtent(rects).height const padding = 100 // px groupHeight += padding * 2 const scaleFactor = groupHeight > window.innerHeight ? window.innerHeight / groupHeight : 1 const scaledRects = rects.map((r) => scaleRect(r, scaleFactor, {x: window.innerWidth / 2, y: 0})) const {min: minYScaled} = getRectGroupYExtent(scaledRects) return { scaleFactor, minYScaled: minYScaled - padding * scaleFactor, } } function calcGroupBoundsPreview(rects: OverlayRect[]) { const groupBoundsX = getRectGroupXExtent(rects) const groupBoundsY = getRectGroupYExtent(rects) const offsetDist = 8 const canOffsetX = groupBoundsX.min > offsetDist && groupBoundsX.min + groupBoundsX.width <= window.innerWidth - offsetDist const canOffsetY = groupBoundsY.min > offsetDist && groupBoundsY.min + groupBoundsY.height <= document.body.scrollHeight - offsetDist const canOffset = canOffsetX && canOffsetY const groupRect = { x: canOffset ? groupBoundsX.min - offsetDist : groupBoundsX.min, y: canOffset ? groupBoundsY.min - offsetDist : groupBoundsY.min, w: canOffset ? groupBoundsX.width + offsetDist * 2 : groupBoundsX.width, h: canOffset ? groupBoundsY.height + offsetDist * 2 : groupBoundsY.height, } return groupRect } async function resetMinimapWrapperTransform( endYOrigin: number, target: HTMLElement, prescaleHeight: number, handler: OverlayEventHandler, rectUpdateFrequency: number, previousRootStyleValues: PreviousRootStyleValues | null, ): Promise<void> { return new Promise((resolve) => { const computedStyle = window.getComputedStyle(target) const transform = computedStyle.transform const matrix = new DOMMatrix(transform) const scale = matrix.a if (scale === 1) return const maxScroll = prescaleHeight - window.innerHeight const prevScrollY = scrollY endYOrigin -= window.innerHeight / 2 if (endYOrigin < 0) endYOrigin = 0 target.addEventListener( 'transitionend', () => { target.style.transition = `none` target.style.transform = `none` scrollTo({ top: endYOrigin, behavior: 'instant', }) setTimeout(() => { handler({ type: 'overlay/dragEndMinimapTransition', }) handler({ type: 'overlay/dragToggleMinimap', display: false, }) }, rectUpdateFrequency * 2) resolve() }, {once: true}, ) handler({ type: 'overlay/dragStartMinimapTransition', }) target.style.transform = `translateY(${Math.max(prevScrollY - endYOrigin, -maxScroll + prevScrollY)}px) scale(${1})` if (!previousRootStyleValues) return document.body.style.overflow = previousRootStyleValues.body.overflow document.body.style.height = previousRootStyleValues.body.height document.documentElement.style.overflow = previousRootStyleValues.documentElement.overflow document.documentElement.style.height = previousRootStyleValues.documentElement.height }) } interface PreviousRootStyleValues { body: { overflow: string height: string } documentElement: { overflow: string height: string } } interface HandleOverlayDragOpts { mouseEvent: MouseEvent element: ElementNode overlayGroup: OverlayElement[] handler: OverlayEventHandler target: SanityNode onSequenceStart: () => void onSequenceEnd: () => void } let minimapScaleApplied = false let mousePosInverseTransform = {x: 0, y: 0} let mousePos = {x: 0, y: 0} let prescaleHeight = typeof document === 'undefined' ? 0 : document.documentElement.scrollHeight let previousRootStyleValues: PreviousRootStyleValues | null = null export function handleOverlayDrag(opts: HandleOverlayDragOpts): void { const {mouseEvent, element, overlayGroup, handler, target, onSequenceStart, onSequenceEnd} = opts // do not trigger drag sequence on anything other than "main" (0) click, ignore right click, etc if (mouseEvent.button !== 0) return // ensure keyboard events fire within frame context window.focus() const rectUpdateFrequency = 150 let rects = overlayGroup.map((e) => getRect(e.elements.element)) const flow = (element.getAttribute('data-sanity-drag-flow') || calcTargetFlow(rects)) as | 'horizontal' | 'vertical' const dragGroup = element.getAttribute('data-sanity-drag-group') const disableMinimap = !!element.getAttribute('data-sanity-drag-minimap-disable') const preventInsertDefault = !!element.getAttribute('data-sanity-drag-prevent-default') const documentHeightOverride = element.getAttribute('data-unstable_sanity-drag-document-height') const groupHeightOverride = element.getAttribute('data-unstable_sanity-drag-group-height') let insertPosition: DragInsertPositionRects | null = null const initialMousePos = calcMousePos(mouseEvent) const scaleTarget = document.body const {minYScaled, scaleFactor} = calcMinimapTransformValues( rects, groupHeightOverride ? ~~groupHeightOverride : null, ) let sequenceStarted = false let minimapPromptShown = false let mousedown = true if (!minimapScaleApplied) { previousRootStyleValues = { body: { overflow: window.getComputedStyle(document.body).overflow, height: window.getComputedStyle(document.body).height, }, documentElement: { overflow: window.getComputedStyle(document.documentElement).overflow, height: window.getComputedStyle(document.documentElement).height, }, } prescaleHeight = documentHeightOverride ? ~~documentHeightOverride : document.documentElement.scrollHeight } const rectsInterval = setInterval(() => { rects = overlayGroup.map((e) => getRect(e.elements.element)) }, rectUpdateFrequency) const applyMinimap = (): void => { if (scaleFactor >= 1) return const skeleton = buildPreviewSkeleton(mousePos, element, scaleFactor) handler({ type: 'overlay/dragUpdateSkeleton', skeleton, }) handler({ type: 'overlay/dragToggleMinimapPrompt', display: false, }) applyMinimapWrapperTransform( scaleTarget, scaleFactor, minYScaled, handler, rectUpdateFrequency, ).then(() => { setTimeout(() => { handler({ type: 'overlay/dragUpdateGroupRect', groupRect: calcGroupBoundsPreview(rects), }) }, rectUpdateFrequency * 2) }) } const handleScroll = (e: WheelEvent) => { if ( Math.abs(e.deltaY) >= 10 && scaleFactor < 1 && !minimapScaleApplied && !minimapPromptShown && !disableMinimap && mousedown ) { handler({ type: 'overlay/dragToggleMinimapPrompt', display: true, }) minimapPromptShown = true } if (e.shiftKey && !minimapScaleApplied && !disableMinimap) { window.dispatchEvent(new CustomEvent('unstable_sanity/dragApplyMinimap')) minimapScaleApplied = true setTimeout(() => { applyMinimap() }, 50) } } const handleMouseMove = (e: MouseEvent): void => { e.preventDefault() mousePos = calcMousePos(e) mousePosInverseTransform = calcMousePosInverseTransform(mousePos) if (Math.abs(pointDist(mousePos, initialMousePos)) < minDragDelta) return if (!sequenceStarted) { const groupRect = calcGroupBoundsPreview(rects) const skeleton = buildPreviewSkeleton(mousePos, element, 1) handler({ type: 'overlay/dragStart', flow, }) handler({ type: 'overlay/dragUpdateSkeleton', skeleton, }) handler({ type: 'overlay/dragUpdateGroupRect', groupRect, }) sequenceStarted = true onSequenceStart() } handler({ type: 'overlay/dragUpdateCursorPosition', x: mousePos.x, y: mousePos.y, }) if (e.shiftKey && !minimapScaleApplied && !disableMinimap) { window.dispatchEvent(new CustomEvent('unstable_sanity/dragApplyMinimap')) minimapScaleApplied = true setTimeout(() => { applyMinimap() }, 50) } const newInsertPosition = calcInsertPosition(mousePos, rects, flow) if (JSON.stringify(insertPosition) !== JSON.stringify(newInsertPosition)) { insertPosition = newInsertPosition handler({ type: 'overlay/dragUpdateInsertPosition', insertPosition: resolveInsertPosition(overlayGroup, insertPosition, flow), }) } } const handleMouseUp = (): void => { mousedown = false handler({ type: 'overlay/dragEnd', target, insertPosition: insertPosition ? resolveInsertPosition(overlayGroup, insertPosition, flow) : null, dragGroup, flow, preventInsertDefault, }) if (minimapPromptShown) { handler({ type: 'overlay/dragToggleMinimapPrompt', display: false, }) } if (!minimapScaleApplied) { clearInterval(rectsInterval) onSequenceEnd() removeFrameListeners() removeKeyListeners() } removeMouseListeners() } const handleKeyup = (e: KeyboardEvent) => { if (e.key === 'Shift' && minimapScaleApplied) { minimapScaleApplied = false const skeleton = buildPreviewSkeleton(mousePos, element, 1 / scaleFactor) handler({ type: 'overlay/dragUpdateSkeleton', skeleton, }) window.dispatchEvent(new CustomEvent('unstable_sanity/dragResetMinimap')) setTimeout(() => { resetMinimapWrapperTransform( mousePosInverseTransform.y, scaleTarget, prescaleHeight, handler, rectUpdateFrequency, previousRootStyleValues, ) }, 50) handler({ type: 'overlay/dragUpdateGroupRect', groupRect: null, }) // cleanup keyup after drag sequence is complete if (!mousedown) { clearInterval(rectsInterval) removeMouseListeners() removeFrameListeners() removeKeyListeners() onSequenceEnd() } } } const handleBlur = () => { handler({ type: 'overlay/dragUpdateGroupRect', groupRect: null, }) window.dispatchEvent(new CustomEvent('unstable_sanity/dragResetMinimap')) setTimeout(() => { resetMinimapWrapperTransform( mousePosInverseTransform.y, scaleTarget, prescaleHeight, handler, rectUpdateFrequency, previousRootStyleValues, ).then(() => { minimapScaleApplied = false }) }, 50) clearInterval(rectsInterval) removeMouseListeners() removeFrameListeners() removeKeyListeners() onSequenceEnd() } const removeMouseListeners = () => { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('wheel', handleScroll) window.removeEventListener('mouseup', handleMouseUp) } const removeKeyListeners = () => { window.removeEventListener('keyup', handleKeyup) } const removeFrameListeners = () => { window.removeEventListener('blur', handleBlur) } window.addEventListener('blur', handleBlur) window.addEventListener('keyup', handleKeyup) window.addEventListener('wheel', handleScroll) window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) }