UNPKG

artboard-deluxe

Version:
1,676 lines (1,662 loc) 95 kB
'use strict'; function adjustScaleForPrecision(size, currentScale, precision) { const scaledSize = size * currentScale; const targetSize = Math.round(scaledSize / precision) * precision; return targetSize / size; } function dampenRelative(value, min, max, factor) { if (value < min) { const overshoot = value - min; return min + overshoot * factor / (1 + Math.abs(overshoot) / (max - min)); } else if (value > max) { const overshoot = value - max; return max + overshoot * factor / (1 + Math.abs(overshoot) / (max - min)); } return value; } function dampen(value, min, max, factor) { if (value < min) { const overshoot = value - min; return min + overshoot * factor; } else if (value > max) { const overshoot = value - max; return max + overshoot * factor; } return value; } function lerp(s, e, t) { return s * (1 - t) + e * t; } function clamp(a, min = 0, max = 1) { return Math.min(max, Math.max(min, a)); } function calculateCenterPosition(blockingRects, viewport, widthToPlace) { const viewportCenterX = (viewport.x + viewport.width) / 2; const blockingThreshold = viewport.width / 7; const horizontallyConstrainingRects = blockingRects.filter((rect) => { const rectLeft = rect.x; const rectRight = rect.x + rect.width; const viewportLeft = viewport.x; const viewportRight = viewport.x + viewport.width; const leftSpace = Math.max(0, rectLeft - viewportLeft); const rightSpace = Math.max(0, viewportRight - rectRight); if (rectRight <= viewportLeft || rectLeft >= viewportRight) { return false; } const overlapLeft = Math.max(rectLeft, viewportLeft); const overlapRight = Math.min(rectRight, viewportRight); const overlapWidth = Math.max(0, overlapRight - overlapLeft); const coverageRatio = overlapWidth / viewport.width; if (coverageRatio > 0.85) { return false; } const rectCenterX = (rectLeft + rectRight) / 2; const isCentered = Math.abs(rectCenterX - viewportCenterX) < blockingThreshold; if (isCentered) { const minUsableSpace = Math.max(widthToPlace, viewport.width * 0.2); return leftSpace >= minUsableSpace || rightSpace >= minUsableSpace; } return true; }); const x = horizontallyConstrainingRects.reduce((acc, rect) => { if (rect.x < viewportCenterX && viewportCenterX - rect.x > blockingThreshold && rect.x + rect.width > acc) { return rect.x + rect.width; } return acc; }, viewport.x); const availableWidth = horizontallyConstrainingRects.reduce((acc, rect) => { if (rect.x > viewportCenterX && rect.x - viewportCenterX > blockingThreshold && rect.x < acc) { return rect.x; } return acc; }, viewport.width + viewport.x); const centerX = (x + availableWidth) / 2 - widthToPlace / 2 - viewport.x; return { centerX, availableWidth: availableWidth - x, availableLeft: x }; } function getMidpoint(touches) { const first = touches[0]; if (!first) { throw new Error("Need at least one touch to determine midpoint."); } const x = first.clientX; const y = first.clientY; const second = touches[1]; if (second) { return { x: (x + second.clientX) / 2, y: (y + second.clientY) / 2 }; } return { x, y }; } function getDistance(a, b) { const dx = a.x - b.x; const dy = a.y - b.y; return Math.sqrt(dx * dx + dy * dy); } function getTouchDistance(touches) { const first = touches[0]; const second = touches[1]; if (!first || !second) { return 0; } return getDistance( { x: first.clientX, y: first.clientY }, { x: second.clientX, y: second.clientY } ); } function limitOffset(x, y, boundaries) { return { x: clamp(x, boundaries.xMin, boundaries.xMax), y: clamp(y, boundaries.yMin, boundaries.yMax) }; } function getDirection(a, b, threshold) { const angle = Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI; const isHorizontal = angle >= -threshold && angle <= threshold || angle >= 180 - threshold || angle <= -180 + threshold; const isVertical = angle >= 90 - threshold && angle <= 90 + threshold || angle >= -90 - threshold && angle <= -90 + threshold; if (isHorizontal && !isVertical) { return "horizontal"; } else if (!isHorizontal && isVertical) { return "vertical"; } return "both"; } function getEventCoords(e) { if (isTouchEvent(e)) { return { x: e.touches[0].pageX, y: e.touches[0].pageY }; } return { x: e.pageX, y: e.pageY }; } function isTouchEvent(e) { return window.TouchEvent && e instanceof TouchEvent; } function withPrecision(value, precision) { return Math.ceil(value / precision) * precision; } function parseOrigin(origin) { const [vertical, horizontal] = origin.split("-"); const x = horizontal === "left" ? 0 : horizontal === "center" ? 0.5 : ( /* 'right' */ 1 ); const y = vertical === "top" ? 0 : vertical === "center" ? 0.5 : ( /* 'bottom' */ 1 ); return { x, y }; } function withDefault(v, defaultValue) { if (v === void 0 || v === null) { return defaultValue; } return Number.isNaN(v) ? defaultValue : v; } function parseEdges(v, defaultValue = 0) { if (typeof v === "number") { return { top: v, right: v, bottom: v, left: v }; } if (!v) { return { top: defaultValue, right: defaultValue, bottom: defaultValue, left: defaultValue }; } return { top: withDefault(v.top, defaultValue), right: withDefault(v.right, defaultValue), bottom: withDefault(v.bottom, defaultValue), left: withDefault(v.left, defaultValue) }; } function asValidNumber(value, defaultValue = 0) { if (value === null || value === void 0) { return defaultValue; } if (typeof value === "string") { return asValidNumber(parseFloat(value), defaultValue); } if (Number.isNaN(value)) { return defaultValue; } return value; } function easeOutQuad(x) { return 1 - (1 - x) * (1 - x); } function easeOutBack(x) { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); } function easeOutElastic(x) { const c4 = 2 * Math.PI / 3; if (x === 0) { return 0; } else if (x === 1) { return 1; } return Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * c4) + 1; } function easeOutCirc(x) { return Math.sqrt(1 - Math.pow(x - 1, 2)); } function easeInOutExpo(x) { if (x === 0) { return 0; } else if (x === 1) { return 1; } return x < 0.5 ? Math.pow(2, 20 * x - 10) / 2 : (2 - Math.pow(2, -20 * x + 10)) / 2; } function easeOutCubic(x) { return 1 - Math.pow(1 - x, 3); } function easeInSine(x) { return 1 - Math.cos(x * Math.PI / 2); } function easeInOutSine(x) { return -(Math.cos(Math.PI * x) - 1) / 2; } function easeInOutQuad(x) { return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; } function easeInOutQuart(x) { return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2; } function easeInOutCirc(x) { return x < 0.5 ? (1 - Math.sqrt(1 - Math.pow(2 * x, 2))) / 2 : (Math.sqrt(1 - Math.pow(-2 * x + 2, 2)) + 1) / 2; } function easeInOutQuint(x) { return x < 0.5 ? 16 * x * x * x * x * x : 1 - Math.pow(-2 * x + 2, 5) / 2; } function linear(x) { return x; } const EASINGS = { easeInOutExpo, easeOutCirc, easeOutElastic, easeOutQuad, easeOutBack, easeOutCubic, easeInSine, easeInOutSine, easeInOutQuad, easeInOutQuart, easeInOutCirc, easeInOutQuint, linear }; function applyAnimation(state, currentTime, animation) { if (!animation.startTime) { animation.startTime = currentTime; } const elapsedTime = currentTime - animation.startTime; const progress = Math.min(elapsedTime / animation.duration, 1); const easedProgress = typeof animation.easing === "string" ? EASINGS[animation.easing](progress) : animation.easing(progress); const x = lerp(animation.startX, animation.x, easedProgress); const y = lerp(animation.startY, animation.y, easedProgress); state.offset.x = x; state.offset.y = y; state.scale = lerp(animation.startScale, animation.scale, easedProgress); if (progress >= 1) { state.offset.x = animation.x; state.offset.y = animation.y; state.scale = animation.scale; return true; } return false; } function applyMomentum(state, currentTime, boundaries) { if (state.momentum === null) { return true; } const deltaTime = (currentTime - state.lastLoopTimestamp) / 1e3; if (deltaTime > 0.5) { state.momentum = null; return true; } const inLeft = state.offset.x - boundaries.xMin >= -1.5; const inRight = state.offset.x - boundaries.xMax <= 1.5; const inTop = state.offset.y - boundaries.yMin >= -1.5; const inBottom = state.offset.y - boundaries.yMax <= 1.5; const deceleration = state.momentum.deceleration; const appliedDecelerationX = inLeft && inRight ? deceleration : 0.85; const appliedDecelerationY = inTop && inBottom ? deceleration : 0.85; const dtFactor = deltaTime * 60; state.momentum.x *= Math.pow(appliedDecelerationX, dtFactor); state.momentum.y *= Math.pow(appliedDecelerationY, dtFactor); const vxDone = Math.abs(state.momentum.x) < 2; const vyDone = Math.abs(state.momentum.y) < 2; if (!vxDone) { state.offset.x = dampen( state.offset.x + state.momentum.x * deltaTime, boundaries.xMin, boundaries.xMax, appliedDecelerationX ); } if (!vyDone) { state.offset.y = dampen( state.offset.y + state.momentum.y * deltaTime, boundaries.yMin, boundaries.yMax, appliedDecelerationY ); } return vxDone && vyDone && inLeft && inRight && inTop && inBottom; } function applyScaleMomentum(state, currentTime, _minScale, maxScale) { const deceleration = 0.09; const minThreshold = 0.01; if (!state.scaleVelocity) { return true; } const deltaTime = (currentTime - state.lastLoopTimestamp) / 1e3; if (deltaTime > 0.5) { state.scaleVelocity = null; return true; } if (state.scaleVelocity.scale > maxScale) { state.scaleVelocity.scale = state.scaleVelocity.scale * Math.pow(0.95, deltaTime * 60); } const dtDeceleration = 1 - Math.pow(1 - deceleration, deltaTime * 60); state.offset.x = lerp(state.offset.x, state.scaleVelocity.x, dtDeceleration); state.offset.y = lerp(state.offset.y, state.scaleVelocity.y, dtDeceleration); state.scale = lerp(state.scale, state.scaleVelocity.scale, dtDeceleration); const vxDone = Math.abs(state.scaleVelocity.x - state.offset.x) < minThreshold; const vyDone = Math.abs(state.scaleVelocity.y - state.offset.y) < minThreshold; const scaleDone = Math.abs(state.scaleVelocity.scale - state.scale) < minThreshold; const isDone = vxDone && vyDone && scaleDone; if (isDone) { state.offset.x = state.scaleVelocity.x; state.offset.y = state.scaleVelocity.y; state.scale = state.scaleVelocity.scale; } return isDone; } function createOptions(initOptions) { const state = { options: initOptions || {}, overscrollBounds: { top: 0, right: 0, bottom: 0, left: 0 } }; function calculateOverscrollBounds() { state.overscrollBounds = parseEdges(state.options.overscrollBounds, 30); } function setAllOptions(newOptions) { if (newOptions) { state.options = { ...newOptions }; } else { state.options = {}; } calculateOverscrollBounds(); } function set(key, value) { state.options[key] = value; if (key === "overscrollBounds") { calculateOverscrollBounds(); } } calculateOverscrollBounds(); return { setAllOptions, set, get hasBlockingRects() { return !!state.options.getBlockingRects; }, get overscrollBounds() { return state.overscrollBounds; }, get minScale() { return withDefault(state.options.minScale, 0.1); }, get maxScale() { return withDefault(state.options.maxScale, 5); }, get scrollStepAmount() { return withDefault(state.options.scrollStepAmount, 256); }, get margin() { return withDefault(state.options.margin, 20); }, get momentumDeceleration() { return withDefault(state.options.momentumDeceleration, 0.96); }, get springDamping() { return withDefault(state.options.springDamping, 0.5); }, get direction() { return state.options.direction || "both"; }, get rootClientRectMaxStale() { return withDefault(state.options.rootClientRectMaxStale, 5e3); }, get blockingRects() { if (state.options.getBlockingRects) { const rects = state.options.getBlockingRects(); return rects.map((rect) => { if (Array.isArray(rect)) { return { x: rect[0], y: rect[1], width: rect[2], height: rect[3] }; } return rect; }); } return []; } }; } function createArtboard(providedRootEl, initPlugins = [], initOptions = {}) { const options = createOptions(initOptions); const state = { animation: null, artboardSize: null, interaction: "none", lastAnimateToTimestamp: 0, lastLoopTimestamp: 0, offset: { x: asValidNumber(initOptions?.initTransform?.x, 0), y: asValidNumber(initOptions?.initTransform?.y, 0) }, rootRect: providedRootEl.getBoundingClientRect(), rootSize: { width: providedRootEl.offsetWidth, height: providedRootEl.offsetHeight }, scale: asValidNumber(initOptions?.initTransform?.scale, 1), touchDirection: "none", momentum: null, momentumStopTimestamp: 0, scaleVelocity: null }; const rootEl = providedRootEl; let plugins = []; let lastRootRectUpdate = 0; function handleResize(entries) { for (const entry of entries) { if (entry.target === rootEl) { if (entry.target instanceof HTMLElement) { updateRootRect(true); } const size = entry.contentBoxSize[0]; if (!size) { return; } state.rootSize.width = size.inlineSize; state.rootSize.height = size.blockSize; } else { const cb = sizeObserverMap.get(entry.target); if (cb) { cb(entry); } } } } const sizeObserverMap = /* @__PURE__ */ new WeakMap(); let resizeTimeout = null; let init = true; const resizeObserver = new ResizeObserver(function(entries) { if (init) { init = false; handleResize(entries); return; } if (resizeTimeout) { window.clearTimeout(resizeTimeout); } resizeTimeout = window.setTimeout(function() { handleResize(entries); }, 300); }); function observeSizeChange(element, cb) { if (sizeObserverMap.has(element)) { throw new Error("An observer for this element has already been added."); } sizeObserverMap.set(element, cb); resizeObserver.observe(element); return { unobserve: () => { sizeObserverMap.delete(element); resizeObserver.unobserve(element); } }; } function setOptions(newOptions) { options.setAllOptions(newOptions); } function setOption(key, value) { options.set(key, value); } function getScale() { return state.scale; } function getFinalScale() { if (state.animation) { return state.animation.scale; } else if (state.scaleVelocity) { return state.scaleVelocity.scale; } return state.scale; } function getOffset() { return { ...state.offset }; } function getFinalOffset() { if (state.animation) { return { x: state.animation.x, y: state.animation.y }; } return { ...state.offset }; } function destroy() { plugins.forEach((plugin) => { if (plugin.destroy) { plugin.destroy(); } }); resizeObserver.disconnect(); } function animateToBoundary() { const boundaries = getBoundaries(); const targetX = clamp(state.offset.x, boundaries.xMin, boundaries.xMax); const targetY = clamp(state.offset.y, boundaries.yMin, boundaries.yMax); if (Math.abs(targetX - state.offset.x) > 0.1 || Math.abs(targetY - state.offset.y) > 0.1) { setMomentum(state.offset.x - targetX, state.offset.y - targetY); setInteraction("momentum"); } } function stopMomentum() { animateToBoundary(); setInteraction(); setTouchDirection(); state.momentumStopTimestamp = performance.now(); state.momentum = null; state.scaleVelocity = null; state.animation = null; } function loop(currentTime) { if (!state.lastLoopTimestamp) { state.lastLoopTimestamp = currentTime; } const boundaries = getBoundaries(); if (state.interaction === "momentum") { const shouldStop = applyMomentum(state, currentTime, boundaries); if (shouldStop) { stopMomentum(); } } else if (state.interaction === "momentumScaling") { const shouldStop = applyScaleMomentum( state, currentTime, options.minScale, options.maxScale ); if (shouldStop) { stopMomentum(); } } else if (state.animation) { const isFinished = applyAnimation(state, currentTime, state.animation); if (isFinished) { state.animation = null; } } const ctx = { rootSize: { ...state.rootSize }, artboardSize: state.artboardSize ? { ...state.artboardSize } : null, offset: { ...state.offset }, scale: state.scale, boundaries, currentTime }; for (let i = 0; i < plugins.length; i++) { const plugin = plugins[i]; if (plugin && plugin.loop) { plugin.loop(ctx); } } state.lastLoopTimestamp = currentTime; return ctx; } function getCenterX(targetScale) { if (!state.artboardSize) { return state.offset.x; } const scaleToUse = targetScale ?? state.scale; const blockingRects = options.blockingRects; return calculateCenterPosition( blockingRects, rootEl.getBoundingClientRect(), state.artboardSize.width * scaleToUse ).centerX; } function animateTo(key, targetX, targetY, targetScale, animationOptions) { setInteraction("none"); const direction = options.direction; const x = direction === "both" || direction === "horizontal" ? targetX : getCenterX(targetScale); const y = direction === "both" || direction === "vertical" ? targetY : 0; state.animation = { key, x, y, scale: targetScale || state.scale, startX: state.offset.x, startY: state.offset.y, startScale: state.scale, easing: animationOptions?.easing || "easeOutCubic", duration: animationOptions?.duration || 400, startTime: 0 }; state.momentum = null; state.scaleVelocity = null; } function getArtboardSize() { if (!state.artboardSize) { return null; } return { width: state.artboardSize.width, height: state.artboardSize.height }; } function getRootSize() { return { width: state.rootSize.width, height: state.rootSize.height }; } function animateOrJumpBy(providedX, providedY, options2) { const x = typeof providedX === "number" ? providedX : 0; const y = typeof providedY === "number" ? providedY : 0; const diff = performance.now() - state.lastAnimateToTimestamp; if (diff < 300) { setOffset( (state.animation?.x || state.offset.x) + x, (state.animation?.y || state.offset.y) + y, true ); state.animation = null; } else { const limited = limitOffset( state.offset.x + x, state.offset.y + y, getBoundaries() ); animateTo("animateOrJumpBy", limited.x, limited.y, state.scale, options2); } state.lastAnimateToTimestamp = performance.now(); } function animateOrJumpTo(providedX, providedY, options2) { const x = typeof providedX === "number" ? providedX : state.offset.x; const y = typeof providedY === "number" ? providedY : state.offset.y; const diff = performance.now() - state.lastAnimateToTimestamp; if (diff < 300) { setOffset(x, y, true); state.animation = null; } else { animateTo("animateOrJumpTo", x, y, state.scale, options2); } state.lastAnimateToTimestamp = performance.now(); } function scrollPageUp(o) { animateOrJumpBy(0, state.rootSize.height, o); } function scrollPageDown(o) { animateOrJumpBy(0, -state.rootSize.height, o); } function scrollPageLeft(o) { animateOrJumpBy(state.rootSize.width, null, o); } function scrollPageRight(o) { animateOrJumpBy(-state.rootSize.width, null, o); } function scrollUp(amount, o) { animateOrJumpBy(0, amount || options.scrollStepAmount, o); } function scrollDown(amount, o) { animateOrJumpBy(0, amount || -options.scrollStepAmount, o); } function scrollLeft(amount, o) { animateOrJumpBy(amount || options.scrollStepAmount, null, o); } function scrollRight(amount, o) { animateOrJumpBy(amount || -options.scrollStepAmount, null, o); } function scrollToTop(o) { animateOrJumpTo(null, options.margin, o); } function scrollToEnd(o) { if (!state.artboardSize) { return; } const v = state.artboardSize.height * state.scale; const y = -v + state.rootSize.height - options.margin; animateOrJumpTo(null, y, o); } function scaleToFit(scrollOptions) { if (!state.artboardSize) { return; } scrollIntoView( { x: 0, y: 0, width: state.artboardSize.width, height: state.artboardSize.height }, { scale: "blocking", ...scrollOptions || {} } ); } function resetZoom(o) { if (!state.artboardSize) { return; } const animationOptions = { duration: o?.duration || 300, easing: o?.easing }; const viewportCenterY = state.rootSize.height / 2; const currentCenterOnArtboard = (-state.offset.y + viewportCenterY) / state.scale; if (state.artboardSize.height < state.rootSize.height) { const newYOffset2 = state.rootSize.height / 2 - state.artboardSize.height / 2; return animateTo( "resetZoom", getCenterX(1), newYOffset2, 1, animationOptions ); } const newYOffset = Math.min( Math.max( -currentCenterOnArtboard + viewportCenterY, -state.artboardSize.height + state.rootSize.height - options.overscrollBounds.top ), options.overscrollBounds.top + options.overscrollBounds.bottom ); animateTo("resetZoom", getCenterX(1), newYOffset, 1, animationOptions); } function getBoundaries(providedTargetScale) { if (!state.artboardSize) { return { xMin: Number.NEGATIVE_INFINITY, xMax: Number.POSITIVE_INFINITY, yMin: Number.NEGATIVE_INFINITY, yMax: Number.POSITIVE_INFINITY }; } const targetScale = providedTargetScale || state.scale; const artboardWidth = state.artboardSize.width * targetScale; const artboardHeight = state.artboardSize.height * targetScale; const bounds = options.overscrollBounds; const paddingTop = Math.min(bounds.top, state.rootSize.height / 4); const paddingBottom = Math.min(bounds.bottom, state.rootSize.height / 4); const paddingLeft = Math.min(bounds.left, state.rootSize.width / 4); const paddingRight = Math.min(bounds.right, state.rootSize.width / 4); const xMin = -artboardWidth + paddingLeft; const xMax = state.rootSize.width - paddingRight; const yMin = -artboardHeight + paddingTop; const yMax = state.rootSize.height - paddingBottom; return { xMin, xMax, yMin, yMax }; } function constrainScale(scale) { return clamp(scale, options.minScale, options.maxScale); } function setScale(newScale, immediate) { if (immediate) { state.scale = constrainScale(newScale); return; } state.scale = dampenRelative( newScale, options.minScale, options.maxScale, 0.9 ); } function zoomIn(delta = 10) { doZoom(state.rootSize.width / 2, state.rootSize.height / 2, delta); } function zoomOut(delta = -10) { doZoom(state.rootSize.width / 2, state.rootSize.height / 2, delta); } function setOffset(providedX, providedY, immediate) { const direction = options.direction; const setX = direction === "both" || direction === "horizontal"; const setY = direction === "both" || direction === "vertical"; const x = typeof providedX === "number" && setX ? providedX : state.offset.x; const y = typeof providedY === "number" && setY ? providedY : state.offset.y; const boundaries = getBoundaries(); if (immediate) { const limited = limitOffset(x, y, boundaries); state.offset.x = limited.x; state.offset.y = limited.y; return; } state.offset.x = dampenRelative( x, boundaries.xMin, boundaries.xMax, options.springDamping ); state.offset.y = dampenRelative( y, boundaries.yMin, boundaries.yMax, options.springDamping ); } function cancelAnimation() { state.animation = null; state.momentum = null; } function updateRootRect(force) { const now = performance.now(); if (force || now - lastRootRectUpdate > options.rootClientRectMaxStale) { state.rootRect = rootEl.getBoundingClientRect(); lastRootRectUpdate = now; } } function calculateScaleAroundPoint(pageX, pageY, targetScale, providedOffset, providedScale) { updateRootRect(); const newScale = constrainScale(targetScale); const offset = providedOffset || getOffset(); const scale = providedScale || getScale(); const x = pageX - state.rootRect.x; const y = pageY - state.rootRect.y; const transformedX = (x - offset.x) / scale; const transformedY = (y - offset.y) / scale; const targetX = -transformedX * newScale + x; const targetY = -transformedY * newScale + y; const limited = limitOffset(targetX, targetY, getBoundaries(newScale)); return { x: limited.x, y: limited.y, scale: newScale }; } function scaleAroundPoint(pageX, pageY, targetScale, animationOptions) { updateRootRect(); const newScale = constrainScale(targetScale); const x = pageX - state.rootRect.x; const y = pageY - state.rootRect.y; const transformedX = (x - state.offset.x) / state.scale; const transformedY = (y - state.offset.y) / state.scale; const targetX = -transformedX * newScale + x; const targetY = -transformedY * newScale + y; const limited = limitOffset(targetX, targetY, getBoundaries(newScale)); if (animationOptions) { animateTo( "scaleAroundPoint", limited.x, limited.y, newScale, typeof animationOptions === "object" ? animationOptions : null ); return; } setScale(newScale, true); setOffset(limited.x, limited.y, true); } function doZoom(x, y, delta) { const scaleFactor = Math.pow(1.5, Math.sign(delta) / 2); const newScale = state.scale * scaleFactor; scaleAroundPoint(x, y, newScale); } function scrollIntoView(targetRect, animationOptions) { const targetWidth = targetRect.width; const targetHeight = targetRect.height; const targetX = targetRect.x; const targetY = targetRect.y; const scaleOption = animationOptions?.scale || "none"; const block = animationOptions?.block || "center"; const inline = animationOptions?.inline || "center"; const pad = parseEdges(animationOptions?.padding); const useBlockingArea = animationOptions?.area === "blocking" || scaleOption === "blocking"; const effectiveLeft = pad.left; const effectiveTop = pad.top; const effectiveWidth = state.rootSize.width - pad.left - pad.right; const effectiveHeight = state.rootSize.height - pad.top - pad.bottom; let blockingInfo = null; if (useBlockingArea && options.hasBlockingRects) { blockingInfo = calculateCenterPosition( options.blockingRects, rootEl.getBoundingClientRect(), targetWidth ); } const targetScale = (() => { if (scaleOption === "full") { const scaleX = (effectiveWidth - options.margin * 2) / targetWidth; const scaleY = (effectiveHeight - options.margin * 2) / targetHeight; return Math.min(scaleX, scaleY, options.maxScale); } else if (scaleOption === "blocking" && blockingInfo) { const scaleX = (blockingInfo.availableWidth - options.margin * 2) / targetWidth; const scaleY = (effectiveHeight - options.margin * 2) / targetHeight; return Math.min(scaleX, scaleY, options.maxScale); } return 1; })(); const behavior = animationOptions?.behavior || "auto"; const axis = animationOptions?.axis || "both"; const scrollX = axis === "x" || axis === "both"; const scrollY = axis === "y" || axis === "both"; const scaledTargetX = targetX * targetScale; const scaledTargetY = targetY * targetScale; const scaledTargetW = targetWidth * targetScale; const scaledTargetH = targetHeight * targetScale; const centeredOffsetX = (() => { if (!scrollX) { return state.offset.x; } if (blockingInfo) { const rootRect = rootEl.getBoundingClientRect(); const areaLeft = blockingInfo.availableLeft - rootRect.x; const areaWidth = blockingInfo.availableWidth; return computeAlignedOffset( inline, scaledTargetX, scaledTargetW, areaLeft, areaWidth, state.offset.x ); } return computeAlignedOffset( inline, scaledTargetX, scaledTargetW, effectiveLeft, effectiveWidth, state.offset.x ); })(); const centeredOffsetY = (() => { if (!scrollY) { return state.offset.y; } return computeAlignedOffset( block, scaledTargetY, scaledTargetH, effectiveTop, effectiveHeight, state.offset.y ); })(); if (behavior === "smooth" || behavior === "auto" && !state.animation) { animateTo( "scrollIntoView", centeredOffsetX, centeredOffsetY, targetScale, { duration: animationOptions?.duration || 300, easing: animationOptions?.easing || "easeInOutExpo" } ); return; } cancelAnimation(); setOffset(centeredOffsetX, centeredOffsetY, true); setScale(targetScale, true); } function computeAlignedOffset(alignment, scaledTargetPos, scaledTargetSize, areaStart, areaSize, currentOffset) { switch (alignment) { case "start": return areaStart - scaledTargetPos; case "end": return areaStart + areaSize - scaledTargetSize - scaledTargetPos; case "auto": if (scaledTargetSize > areaSize) { return areaStart - scaledTargetPos; } return areaStart + (areaSize - scaledTargetSize) / 2 - scaledTargetPos; case "center": return areaStart + (areaSize - scaledTargetSize) / 2 - scaledTargetPos; case "nearest": { const targetStart = currentOffset + scaledTargetPos; const targetEnd = targetStart + scaledTargetSize; const areaEnd = areaStart + areaSize; if (targetStart >= areaStart && targetEnd <= areaEnd) { return currentOffset; } if (scaledTargetSize >= areaSize) { return areaStart - scaledTargetPos; } if (targetStart < areaStart) { return areaStart - scaledTargetPos; } return areaStart + areaSize - scaledTargetSize - scaledTargetPos; } } } function setArtboardSize(width, height) { if (!state.artboardSize) { state.artboardSize = { width: 0, height: 0 }; } state.artboardSize.width = width; state.artboardSize.height = height; } function getRootElement() { return rootEl; } function getInteraction() { return state.interaction; } function setInteraction(v) { const targetInteraction = v || "none"; if (state.interaction === targetInteraction) { return; } if ((state.interaction === "momentum" || state.interaction === "momentumScaling") && targetInteraction !== "momentum" && targetInteraction !== "momentumScaling") { state.momentumStopTimestamp = performance.now(); } state.interaction = targetInteraction; } function getMomentum() { if (state.momentum) { return { ...state.momentum }; } return null; } function setMomentum(momentumX, momentumY, deceleration) { if (momentumX !== void 0 && momentumX !== null && momentumY !== void 0) { const direction = options.direction; const x = direction === "both" || direction === "horizontal" ? momentumX : 0; const y = direction === "both" || direction === "vertical" ? momentumY : 0; state.momentum = { x, y, deceleration: deceleration || options.momentumDeceleration }; } else { state.momentum = null; } } function getTouchDirection() { return state.touchDirection; } function setTouchDirection(v) { state.touchDirection = v || "none"; } function startMomentum(velocity) { const totalVelocity = velocity ? Math.abs(velocity.x) + Math.abs(velocity.y) : null; if (!velocity || !totalVelocity) { state.momentum = null; setInteraction("none"); animateToBoundary(); return; } const x = state.touchDirection === "horizontal" || state.touchDirection === "both" ? velocity.x : 0; const y = state.touchDirection === "vertical" || state.touchDirection === "both" ? velocity.y : 0; setMomentum(x, y); setInteraction("momentum"); } function setDirectionOffset(x, y) { if (state.touchDirection === "both" || state.touchDirection === "none") { setOffset(x, y); } else if (state.touchDirection === "vertical") { setOffset(state.offset.x, y); } else if (state.touchDirection === "horizontal") { setOffset(x, state.offset.y); } } function setScaleTarget(newX, newY, scale) { const direction = options.direction; const x = direction === "both" || direction === "horizontal" ? newX : getCenterX(); const y = direction === "both" || direction === "vertical" ? newY : 0; if (state.scaleVelocity) { state.scaleVelocity.scale = scale; state.scaleVelocity.x = x; state.scaleVelocity.y = y; } else { state.scaleVelocity = { x, y, scale }; } } function getScaleTarget() { if (state.scaleVelocity) { return { ...state.scaleVelocity }; } return null; } function scrollElementIntoView(targetEl, options2) { const targetBoundingRect = targetEl.getBoundingClientRect(); const targetWidth = targetEl.offsetWidth; const targetHeight = targetEl.offsetHeight; const scale = getScale(); const targetX = (targetBoundingRect.x - state.offset.x - state.rootRect.x) / scale; const targetY = (targetBoundingRect.y - state.offset.y - state.rootRect.y) / scale; scrollIntoView( { width: targetWidth, height: targetHeight, x: targetX, y: targetY }, options2 ); } function getAnimation() { if (state.animation) { return { ...state.animation }; } return null; } function wasMomentumScrolling() { return performance.now() - state.momentumStopTimestamp < 200; } const artboard = { observeSizeChange, wasMomentumScrolling, addPlugin, animateOrJumpBy, animateOrJumpTo, animateTo, animateToBoundary, startMomentum, cancelAnimation, destroy, getArtboardSize, getCenterX, getFinalOffset, getFinalScale, getInteraction, getOffset, getRootElement, getRootSize, getScale, getTouchDirection, getMomentum, getAnimation, loop, options, removePlugin, resetZoom, scaleAroundPoint, scaleToFit, scrollDown, scrollElementIntoView, scrollIntoView, scrollLeft, scrollPageDown, scrollPageLeft, scrollPageRight, scrollPageUp, scrollRight, scrollToEnd, scrollToTop, scrollUp, setArtboardSize, setDirectionOffset, setInteraction, setOffset, setOption, setOptions, setScale, setTouchDirection, setMomentum, zoomIn, zoomOut, getBoundaries, setScaleTarget, calculateScaleAroundPoint, getScaleTarget }; function addPlugin(plugin) { const pluginInstance = plugin.init(artboard, plugin.options); plugins.push(pluginInstance); return pluginInstance; } function removePlugin(plugin) { if (plugin.destroy) { plugin.destroy(); } plugins = plugins.filter((v) => v !== plugin); } resizeObserver.observe(rootEl); if (initPlugins?.length) { initPlugins.forEach((plugin) => addPlugin(plugin)); } return artboard; } function inlineStyleOverrider(element) { const overridenStyles = /* @__PURE__ */ new Map(); const overridenProperties = /* @__PURE__ */ new Map(); const prevValues = /* @__PURE__ */ new Map(); function set(property, value) { const prev = prevValues.get(property); if (prev === value) { return; } if (prev === void 0) { overridenStyles.set(property, element.style[property] ?? ""); } element.style[property] = typeof value === "number" ? value + "px" : value; prevValues.set(property, value); } function setProperty(property, value) { const prev = prevValues.get(property); if (prev === value) { return; } if (prev === void 0) { overridenProperties.set( property, element.style.getPropertyValue(property) ); } const propertyValue = typeof value === "number" ? value + "px" : value; element.style.setProperty(property, propertyValue); prevValues.set(property, value); } function setTransform(x, y, scale, scaleY) { let transform = `translate3d(${x}px, ${y}px, 0px)`; if (scale) { if (scaleY) { transform += ` scale(${scale}, ${scaleY})`; } else { transform += ` scale(${scale})`; } } set("transform", transform); } function setMultiple(styles) { Object.entries(styles).forEach(([property, value]) => { if (value !== void 0) { set(property, value); } }); } function restore() { try { const styleEntries = [...overridenStyles.entries()]; styleEntries.forEach(([property, value]) => { element.style[property] = value; }); const propertyEntries = [...overridenProperties.entries()]; propertyEntries.forEach(([property, value]) => { element.style.setProperty(property, value); }); } catch { } } return { set, setTransform, setProperty, setMultiple, restore }; } function pluginOptions(providedOptions) { let options = providedOptions; const computedCache = /* @__PURE__ */ new Map(); function get(key, defaultValue) { const v = options ? options[key] : void 0; if (v === void 0 && defaultValue !== void 0) { return defaultValue; } if (typeof v === "number" && Number.isNaN(v)) { return defaultValue; } return v; } function getRequired(key) { const value = get(key); if (value === void 0 || value === null) { throw new Error(`Missing required plugin option "${String(key)}".`); } return value; } function getElement(key, fallbackSelector, parent) { const value = get(key); if (value instanceof HTMLElement) { return value; } else if (typeof value === "string") { const possibleElement = parent.querySelector(value); if (possibleElement instanceof HTMLElement) { return possibleElement; } } else { const possibleElement = parent.querySelector(fallbackSelector); if (possibleElement instanceof HTMLElement) { return possibleElement; } } throw new Error(`Failed to locate element for "${String(key)} option."`); } function should(key, defaultValue) { const v = options ? options[key] : void 0; if (v === void 0 && defaultValue !== void 0) { return !!defaultValue; } return !!v; } function recompute() { computedCache.forEach((cacheEntry, callback) => { cacheEntry.value = callback(options); }); } function set(key, value) { if (!options) { options = {}; } options[key] = value; recompute(); } function setAll(newOptions) { options = newOptions; recompute(); } function setMultiple(newOptions) { options = { ...options, ...newOptions }; recompute(); } function computed(callback) { if (computedCache.has(callback)) { return computedCache.get(callback); } const initialValue = callback(options); const cacheEntry = { value: initialValue }; computedCache.set(callback, cacheEntry); return cacheEntry; } return { get, getRequired, getElement, should, set, setAll, setMultiple, computed }; } function defineArtboardPlugin(init) { return function(providedOptions) { const options = pluginOptions(providedOptions); return { options, init }; }; } const clickZoom = defineArtboardPlugin(function(artboard, options) { const rootEl = artboard.getRootElement(); let mouseStartCoords = null; function shouldZoom() { return !artboard.wasMomentumScrolling(); } function onPointerDown(e) { if (e.pointerType === "touch") { destroy(); return; } mouseStartCoords = getEventCoords(e); } function onPointerUp(e) { if (!mouseStartCoords) { return; } const distance = getDistance(mouseStartCoords, getEventCoords(e)); if (distance > 10) { return; } if (!shouldZoom()) { return; } doZoom(mouseStartCoords); } function getTargetScale() { const maxScale = artboard.options.maxScale; const threshold = maxScale * 0.5; const currentScale = artboard.getFinalScale(); if (currentScale < 1) { return 1; } return currentScale >= threshold ? 1 : maxScale; } function doZoom(coords) { const animation = options.get("animation", { duration: 500, easing: "easeInOutExpo" }); artboard.scaleAroundPoint(coords.x, coords.y, getTargetScale(), animation); } function destroy() { rootEl.removeEventListener("pointerdown", onPointerDown); rootEl.removeEventListener("pointerup", onPointerUp); } rootEl.addEventListener("pointerdown", onPointerDown); rootEl.addEventListener("pointerup", onPointerUp); return { options, destroy }; }); const cssProperties = defineArtboardPlugin(function(artboard, options) { const element = options.get("element") || artboard.getRootElement(); const style = inlineStyleOverrider(element); function getValue(v, precision) { if (options.should("unitless")) { return withPrecision(v, precision).toString(); } return withPrecision(v, precision); } const properties = options.computed( function(o) { return Object.fromEntries(o.properties.map((v) => [v, true])); } ); function loop(ctx) { const precision = options.get("precision", 0.5); if (properties.value["--artboard-offset-x"]) { const x = getValue(ctx.offset.x, precision); style.setProperty("--artboard-offset-x", x); } if (properties.value["--artboard-offset-y"]) { const y = getValue(ctx.offset.y, precision); style.setProperty("--artboard-offset-y", y); } if (properties.value["--artboard-size-width"]) { const width = getValue(ctx.artboardSize?.width || 0, precision); style.setProperty("--artboard-size-width", width); } if (properties.value["--artboard-size-height"]) { const height = getValue(ctx.artboardSize?.height || 0, precision); style.setProperty("--artboard-size-height", height); } if (properties.value["--artboard-root-width"]) { const width = getValue(ctx.rootSize.width, precision); style.setProperty("--artboard-root-width", width); } if (properties.value["--artboard-root-height"]) { const height = getValue(ctx.rootSize.height, precision); style.setProperty("--artboard-root-height", height); } if (properties.value["--artboard-scale"]) { style.setProperty("--artboard-scale", ctx.scale.toString()); } } function destroy() { if (options.should("restoreProperties")) { style.restore(); } } return { options, destroy, loop }; }); const dom = defineArtboardPlugin(function(artboard, options) { const artboardElement = options.getRequired("element"); const styleOverrider = inlineStyleOverrider(artboardElement); function loop(ctx) { if (!ctx.artboardSize) { return; } const precision = options.get("precision", 0.5); const x = withPrecision(ctx.offset.x, precision); const y = withPrecision(ctx.offset.y, precision); if (options.should("applyScalePrecision")) { const scaleX = adjustScaleForPrecision( ctx.artboardSize.width, ctx.scale, precision ); const scaleY = adjustScaleForPrecision( ctx.artboardSize.height, ctx.scale, precision ); styleOverrider.setTransform(x, y, scaleX, scaleY); } else { styleOverrider.setTransform(x, y, ctx.scale); } } const { unobserve } = artboard.observeSizeChange(artboardElement, (entry) => { const size = entry.contentBoxSize[0]; if (!size) { return; } if (entry.target instanceof HTMLImageElement) { artboard.setArtboardSize( entry.target.naturalWidth, entry.target.naturalHeight ); styleOverrider.set("width", entry.target.naturalWidth); styleOverrider.set("height", entry.target.naturalHeight); return; } artboard.setArtboardSize(size.inlineSize, size.blockSize); }); function applyInitStyles() { styleOverrider.setMultiple({ position: "absolute", top: "0px", left: "0px", transformOrigin: "0 0" }); } function onImageLoaded() { if (artboardElement instanceof HTMLImageElement) { artboard.setArtboardSize( artboardElement.naturalWidth, artboardElement.naturalHeight ); if (options.should("setInitTransformFromRect")) { setInitTransformFromRect(); } styleOverrider.set("width", artboardElement.naturalWidth); styleOverrider.set("height", artboardElement.naturalHeight); styleOverrider.set("maxWidth", "none"); applyInitStyles(); } } function setInitTransformFromRect() { const artboardRect = artboardElement.getBoundingClientRect(); const rootRect = artboard.getRootElement().getBoundingClientRect(); const x = artboardRect.x - rootRect.x; const y = artboardRect.y - rootRect.y; const artboardWidth = artboardElement instanceof HTMLImageElement ? artboardElement.naturalWidth : artboardElement.offsetWidth; const scale = Math.round(artboardRect.width / artboardWidth * 1e3) / 1e3; artboard.setScale(scale, true); artboard.setOffset(x, y, true); } if (artboardElement instanceof HTMLImageElement) { if (artboardElement.complete) { onImageLoaded(); } else { artboardElement.addEventListener("load", onImageLoaded); } } else { artboard.setArtboardSize( artboardElement.offsetWidth, artboardElement.offsetHeight ); if (options.should("setInitTransformFromRect")) { setInitTransformFromRect(); } applyInitStyles(); } function destroy() { unobserve(); artboardElement.removeEventListener("load", onImageLoaded); if (options.should("restoreStyles")) { styleOverrider.restore(); } } return { options, loop, destroy }; }); const doubleTapZoom = defineArtboardPlugin(function(artboard, options) { const rootEl = artboard.getRootElement(); let startTime = 0; let startOffset = null; function onTouchStart(e) { if (e.touches.length !== 1) { return; } const duration = e.timeStamp - startTime; if (duration < 300 && startOffset) { const distance = getDistance(startOffset, artboard.getOffset()); if (distance < 10) { doZoom(getEventCoords(e)); return; } } startTime = e.timeStamp; startOffset = artboard.getOffset(); } function doZoom(coords) { const currentScale = artboard.getFinalScale(); const targetScale = currentScale >= 3 ? 1 : 6; const animation = options.get("animation", { duration: 500, easing: "easeInOutExpo" }); artboard.scaleAroundPoint(coords.x, coords.y, targetScale, animation); } function destroy() { rootEl.removeEventListener("touchstart", onTouchStart); } rootEl.addEventListener("touchstart", onTouchStart); return { options, destroy }; }); const DEFAULT_KEYMAP = { ArrowDown: ["scrollDown"], ArrowUp: ["scrollUp"], ArrowLeft: ["scrollLeft"], ArrowRight: ["scrollRight"], Home: ["scrollToTop"], End: ["scrollToEnd"], PageUp: ["scrollPageUp"], PageDown: ["scrollPageDown"], Digit0: ["resetZoom", true], Digit1: ["scaleToFit", true] }; const keyboard = defineArtboardPlugin(function(artboard, options) { document.addEventListener("keydown", onKeyDown); function destroy() { document.removeEventListener("keydow