UNPKG

@amaui/ui-react

Version:
723 lines (710 loc) 26 kB
import _extends from "@babel/runtime/helpers/extends"; import _objectWithoutProperties from "@babel/runtime/helpers/objectWithoutProperties"; import _defineProperty from "@babel/runtime/helpers/defineProperty"; const _excluded = ["size", "width", "height", "minZoom", "maxZoom", "showGuidelinesDefault", "guidelines", "pre", "post", "miniMap", "methods", "onChange", "onWheel", "onMouseDown", "onTouchStart", "noActions", "noGuideLines", "noFitCenter", "noZoomMenu", "disabled", "IconCenter", "ContainerProps", "IconButtonProps", "Component", "className", "children"]; function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } import React from 'react'; import { clamp, debounce, is, isEnvironment } from '@amaui/utils'; import { classNames, style, useAmauiTheme } from '@amaui/style-react'; import IconMaterialCenterFocusWeak from '@amaui/icons-material-rounded-react/IconMaterialCenterFocusWeakW100'; import LineElement from '../Line'; import SurfaceElement from '../Surface'; import IconButtonElement from '../IconButton'; import TypeElement from '../Type'; import TooltipElement from '../Tooltip'; import MenuElement from '../Menu'; import ListItemElement from '../ListItem'; import LabelElement from '../Label'; import SwitchElement from '../Switch'; import { staticClassName } from '../utils'; const useStyle = style(theme => ({ root: { position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }, container: { position: 'absolute', transformOrigin: '0 0' }, miniMap: { position: 'absolute', bottom: '12px', left: '12px', borderRadius: 8, background: theme.palette.background.default.primary, border: theme.palette.light ? '' : `1px solid ${theme.palette.text.divider}`, boxShadow: theme.shadows.values.default[2], cursor: 'pointer' }, miniMapMain: { position: 'absolute', inset: 0 }, miniMapViewport: { position: 'absolute', borderRadius: 14, border: `2px solid ${theme.palette.color.info.main}` }, actions: { position: 'absolute', top: '8px', right: '8px', padding: '4px 20px', borderRadius: 140, background: theme.palette.background.default[theme.palette.light ? 'primary' : 'quaternary'], boxShadow: theme.shadows.values.default[1], overflow: 'auto hidden', zIndex: '1' }, // luv you: https://stackoverflow.com/a/32861765 guidelines_lines: { backgroundSize: '40px 40px', backgroundImage: `linear-gradient(to right, ${theme.palette.text.default.quaternary} 0.5px, transparent 0.5px), linear-gradient(to bottom, ${theme.palette.text.default.quaternary} 0.5px, transparent 0.5px)` }, guidelines_dots: { backgroundSize: '40px 40px', backgroundImage: `radial-gradient(circle, ${theme.palette.text.default.tertiary} 0.5px, transparent 0.5px)`, backgroundPosition: `20px 20px` }, move: { cursor: 'grabbing', userSelect: 'none' }, zoom: { width: 40, height: 34, cursor: 'pointer', userSelect: 'none' }, menu: { '& .amaui-List-root': { maxHeight: 240, overflow: 'hidden auto' } }, disabled: { pointerEvents: 'none', opacity: 0.54, cursor: 'default' } }), { name: 'amaui-HTMLCanvas' }); const HTMLCanvas = /*#__PURE__*/React.forwardRef((props_, ref) => { const theme = useAmauiTheme(); const props = React.useMemo(() => _objectSpread(_objectSpread(_objectSpread({}, theme?.ui?.elements?.all?.props?.default), theme?.ui?.elements?.amauiHTMLCanvas?.props?.default), props_), [props_]); const Line = React.useMemo(() => theme?.elements?.Line || LineElement, [theme]); const Surface = React.useMemo(() => theme?.elements?.Surface || SurfaceElement, [theme]); const IconButton = React.useMemo(() => theme?.elements?.IconButton || IconButtonElement, [theme]); const Type = React.useMemo(() => theme?.elements?.Type || TypeElement, [theme]); const Tooltip = React.useMemo(() => theme?.elements?.Tooltip || TooltipElement, [theme]); const Menu = React.useMemo(() => theme?.elements?.Menu || MenuElement, [theme]); const ListItem = React.useMemo(() => theme?.elements?.ListItem || ListItemElement, [theme]); const Label = React.useMemo(() => theme?.elements?.Label || LabelElement, [theme]); const Switch = React.useMemo(() => theme?.elements?.Switch || SwitchElement, [theme]); const { size = 'regular', width = 240_000, height = 240_000, minZoom = 0.04, maxZoom = 4, showGuidelinesDefault, guidelines = 'dots', pre, post, miniMap: useMiniMap = true, methods, onChange: onChange_, onWheel: onWheel_, onMouseDown: onMouseDown_, onTouchStart: onTouchStart_, noActions, noGuideLines, noFitCenter, noZoomMenu, disabled, IconCenter = IconMaterialCenterFocusWeak, ContainerProps, IconButtonProps, Component = Line, className, children } = props, other = _objectWithoutProperties(props, _excluded); const { classes } = useStyle(); const [positions, setPositions] = React.useState({ zoom: 1, left: 0, top: 0 }); const [keyDown, setKeyDown] = React.useState(); const [showGuidelines, setShowGuidelines] = React.useState(!!showGuidelinesDefault); const refs = { root: React.useRef(undefined), container: React.useRef(undefined), miniMap: React.useRef(undefined), minZoom: React.useRef(minZoom), maxZoom: React.useRef(maxZoom), positions: React.useRef(positions), previousMouseEvent: React.useRef(), mouseDown: React.useRef(false), mouseDownMiniMap: React.useRef(false), keyDown: React.useRef(undefined), boundaries: React.useRef({ x: [width * positions?.zoom * -1, 0], y: [height * positions?.zoom * -1, 0] }), width: React.useRef(width), height: React.useRef(height), disabled: React.useRef(disabled) }; refs.positions.current = positions; refs.minZoom.current = minZoom; refs.maxZoom.current = maxZoom; refs.keyDown.current = keyDown; refs.width.current = width; refs.height.current = height; refs.disabled.current = disabled; React.useEffect(() => { if (is('object', methods)) { methods.updatePositions = updatePositions; } }, [methods]); const onChange = React.useCallback(valueNew => { const root = refs.root.current; const values = _objectSpread(_objectSpread(_objectSpread({}, refs.positions.current), valueNew), {}, { root: { width: root.clientWidth, height: root.clientHeight }, boundaries: refs.boundaries.current }); if (is('function', onChange_)) onChange_(values); }, [onChange_]); const updateBoundaries = React.useCallback(function () { let valueZoom = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : refs.positions.current?.zoom; const root = refs.root.current; const rootRect = root.getBoundingClientRect(); refs.boundaries.current = { x: [refs.width.current * valueZoom * -1 + rootRect.width, 0], y: [refs.height.current * valueZoom * -1 + rootRect.height, 0] }; }, []); React.useEffect(() => { // update boundaries updateBoundaries(); }, [width, height, positions]); const updatePositions = React.useCallback(valueNew => { valueNew.zoom = clamp(valueNew.zoom, refs.minZoom.current, refs.maxZoom.current); valueNew.top = clamp(valueNew.top, ...refs.boundaries.current.y); valueNew.left = clamp(valueNew.left, ...refs.boundaries.current.x); refs.container.current.style.transform = `matrix(${valueNew.zoom}, 0, 0, ${valueNew.zoom}, ${valueNew.left}, ${valueNew.top})`; setPositions(valueNew); onChange(valueNew); }, []); const update = React.useCallback((values, event) => { const root = refs.root.current; const container = refs.container.current; const rootRect = root.getBoundingClientRect(); const transform = window.getComputedStyle(container).transform; const matrix = new DOMMatrixReadOnly(transform); let left = matrix.e || 0; let top = matrix.f || 0; let zoom_ = matrix.a || 1; const { x = event ? event.clientX - rootRect.x : rootRect.width / 2, y = event ? event.clientY - rootRect.y : rootRect.height / 2 } = values; if (values.zoom !== undefined) { zoom_ = clamp(values.zoom, refs.minZoom.current, refs.maxZoom.current); // update boundaries updateBoundaries(zoom_); // left, top const zoomDelta = zoom_ / matrix.a; left = values.left !== undefined ? values.left : x - (left >= 0 ? left - x : x - left) * zoomDelta; top = values.top !== undefined ? values.top : y - (top >= 0 ? top - y : y - top) * zoomDelta; } else { if (values.top !== undefined) { top = values.top; } if (values.left !== undefined) { left = values.left; } } // update updatePositions({ zoom: zoom_, left, top }); }, []); const zoom = React.useCallback(function () { let value = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; let event = arguments.length > 1 ? arguments[1] : undefined; update({ zoom: value }); }, []); const onCenter = React.useCallback(() => { const root = refs.root.current; const container = refs.container.current; const rootRect = root.getBoundingClientRect(); const items = Array.from(container.children); // container // reset container.style.transform = `matrix(1, 0, 0, 1, 0, 0)`; const values = { top: Number.MAX_SAFE_INTEGER, left: Number.MAX_SAFE_INTEGER, width: 0, height: 0 }; // left, top items.forEach(item => { const itemRect = item.getBoundingClientRect(); item.rect = itemRect; values.top = Math.min(values.top, itemRect.top - rootRect.top); values.left = Math.min(values.left, itemRect.left - rootRect.left); }); // width, height items.forEach(item => { const itemRect = item.rect; const top_ = itemRect.top - rootRect.top; const left_ = itemRect.left - rootRect.left; const right = left_ + itemRect.width; const bottom = top_ + itemRect.height; values.width = Math.max(values.width, Math.abs(right) - Math.abs(values.left)); values.height = Math.max(values.height, Math.abs(bottom) - Math.abs(values.top)); }); if (!items.length) { values.top = container.clientHeight / 2; values.left = container.clientWidth / 2; values.width = values.height = 0; } const top = (values.top + values.height / 2 - rootRect.height / 2) * -1; const left = (values.left + values.width / 2 - rootRect.width / 2) * -1; const zoomPadding = 0.94; let zoom_ = Math.min(clamp(rootRect.width / values.width, 0, 4) * zoomPadding, clamp(rootRect.height / values.height, 0, 4) * zoomPadding); if (!items.length || items.length === 1) zoom_ = 1; // update // top, left update({ zoom: 1, top, left }); // update // zoom if (zoom_ !== 1) update({ zoom: zoom_ }); }, []); const init = React.useCallback(() => { // initially // center onCenter(); }, []); const onWheel = React.useCallback(event => { if (event.target === refs.root.current || refs.root.current?.contains(event.target)) { const positions_ = refs.positions.current; const root = refs.root.current; const rootRect = root.getBoundingClientRect(); const x = event.clientX - rootRect.x; const y = event.clientY - rootRect.y; const meta = event.ctrlKey || event.metaKey; // zoom if (event.deltaY !== 0 && meta) { const delta = event.deltaY * -0.0024; const value = positions_.zoom + delta; update({ zoom: value, x, y, delta }, event); event.preventDefault(); } // move else if (!meta) { // vertical if (event.deltaY !== 0) { const value = positions_.top + event.deltaY * -1; update({ top: value, x, y }); } // horizontal if (event.deltaX !== 0) { const value = positions_.left + event.deltaX * -1; update({ left: value, x, y }); } event.preventDefault(); } } }, []); const onKeyUp = React.useCallback(() => { setKeyDown(null); }, []); const onKeyDown = React.useCallback(event => { if (event.key === ' ') setKeyDown(event.key); }, []); const onMouseUp = React.useCallback(() => { refs.mouseDown.current = false; refs.mouseDownMiniMap.current = false; refs.previousMouseEvent.current = undefined; }, []); const onMouseDown = React.useCallback(event => { refs.mouseDown.current = true; if (is('function', onMouseDown_)) onMouseDown_(event); }, [onMouseDown_]); const onTouchStart = React.useCallback(event => { refs.mouseDown.current = true; if (is('function', onTouchStart_)) onTouchStart_(event); }, [onTouchStart_]); const onMouseDownMiniMap = React.useCallback(event => { refs.mouseDownMiniMap.current = true; }, []); const onTouchStartMiniMap = React.useCallback(event => { refs.mouseDownMiniMap.current = true; }, []); const onMoveMiniMap = React.useCallback((x_, y_, event) => { if (refs.mouseDownMiniMap.current && refs.previousMouseEvent.current && !refs.disabled.current) { const positions_ = refs.positions.current; const root = refs.root.current; const container = refs.container.current; const miniMap_ = refs.miniMap.current; const rectMiniMap = miniMap_.getBoundingClientRect(); const ratios = { containerMiniMap: container.clientWidth / 170, rootContainer: root.clientWidth / container.clientWidth }; const zoomAdjusted = 1 / positions_.zoom; const x = (event.clientX - rectMiniMap.x) * ratios.containerMiniMap / zoomAdjusted; const y = (event.clientY - rectMiniMap.y) * ratios.containerMiniMap / zoomAdjusted; const left = x * -1 + root.clientWidth / 2; const top = y * -1 + root.clientHeight / 2; update({ left, top }); } }, []); const onMove = React.useCallback((x_, y_, event) => { if (refs.keyDown.current === ' ' && refs.mouseDown.current && refs.previousMouseEvent.current && !refs.disabled.current) { const { clientX: xPrevious, clientY: yPrevious } = refs.previousMouseEvent.current; const positions_ = refs.positions.current; const x = x_ - xPrevious; const y = y_ - yPrevious; const left = x + positions_.left; const top = y + positions_.top; update({ left, top }); } }, []); const onMouseMove = React.useCallback(event => { if ((refs.mouseDown.current || refs.mouseDownMiniMap.current) && !refs.disabled.current) { const { clientY, clientX } = event; if (refs.mouseDownMiniMap.current) onMoveMiniMap(clientX, clientY, event);else if (refs.mouseDown.current) onMove(clientX, clientY, event); refs.previousMouseEvent.current = event; } }, []); const onTouchMove = React.useCallback(event => { if ((refs.mouseDown.current || refs.mouseDownMiniMap.current) && !refs.disabled.current) { const { clientY, clientX } = event.touches[0]; if (refs.mouseDownMiniMap.current) onMoveMiniMap(clientX, clientY, event);else if (refs.mouseDown.current) onMove(clientX, clientY, event); refs.previousMouseEvent.current = event; // Normalize for use as a mouseDown value refs.previousMouseEvent.current.clientY = clientY; refs.previousMouseEvent.current.clientX = clientX; } }, []); React.useEffect(() => { // init init(); const rootDocument = isEnvironment('browser') ? refs.root.current?.ownerDocument || window.document : undefined; window.addEventListener('wheel', onWheel, { passive: false }); rootDocument.addEventListener('keyup', onKeyUp); rootDocument.addEventListener('keydown', onKeyDown); rootDocument.addEventListener('mousemove', onMouseMove); rootDocument.addEventListener('touchmove', onTouchMove, { passive: true }); rootDocument.addEventListener('mouseup', onMouseUp); rootDocument.addEventListener('touchend', onMouseUp); return () => { window.removeEventListener('wheel', onWheel); rootDocument.removeEventListener('keyup', onKeyUp); rootDocument.removeEventListener('keydown', onKeyDown); rootDocument.removeEventListener('mousemove', onMouseMove); rootDocument.removeEventListener('touchmove', onTouchMove); rootDocument.removeEventListener('mouseup', onMouseUp); rootDocument.removeEventListener('touchend', onMouseUp); }; }, []); const onShowGuidelines = React.useCallback(valueNew => { setShowGuidelines(valueNew); }, []); const zoomOptions = React.useMemo(() => { return [{ name: 'Zoom to fit', props: { onClick: event => onCenter() } }, { name: 'Zoom to 25%', value: 0.25, props: { onClick: event => zoom(0.25, event) } }, { name: 'Zoom to 50%', value: 0.5, props: { onClick: event => zoom(0.5, event) } }, { name: 'Zoom to 75%', value: 0.75, props: { onClick: event => zoom(0.75, event) } }, { name: 'Zoom to 100%', value: 1, props: { onClick: event => zoom(1, event) } }, { name: 'Zoom to 125%', value: 1.25, props: { onClick: event => zoom(1.25, event) } }, { name: 'Zoom to 150%', value: 1.5, props: { onClick: event => zoom(1.5, event) } }, { name: 'Zoom to 175%', value: 1.75, props: { onClick: event => zoom(1.75, event) } }, { name: 'Zoom to 200%', value: 2, props: { onClick: event => zoom(2, event) } }, { name: 'Zoom to 400%', value: 4, props: { onClick: event => zoom(4, event) } }]; }, []); const miniMap = React.useMemo(() => { const root = refs.root.current; const container = refs.container.current; if (!container) return null; const zoomAdjusted = 1 / positions.zoom; const ratios = { root: root.clientWidth / root.clientHeight, container: container.clientWidth / container.clientHeight, rootContainer: root.clientWidth / container.clientWidth }; const width_ = 170; const height_ = 170 / ratios.container; const zoomPerSize = container.clientWidth / width_; const viewportStyles = { width: width_ * ratios.rootContainer * zoomAdjusted, height: width_ / ratios.root * ratios.rootContainer * zoomAdjusted }; viewportStyles.left = Math.abs(positions.left) / zoomPerSize * zoomAdjusted; viewportStyles.top = Math.abs(positions.top) / zoomPerSize * zoomAdjusted; return /*#__PURE__*/React.createElement(Line, { ref: refs.miniMap, onMouseDown: onMouseDownMiniMap, onTouchStart: onTouchStartMiniMap, className: classNames([classes.miniMap]), style: { width: width_, height: height_ } }, /*#__PURE__*/React.createElement(Line, { className: classes.miniMapMain }), /*#__PURE__*/React.createElement(Line, { className: classes.miniMapViewport, style: _objectSpread({}, viewportStyles) })); }, [positions, onMouseDownMiniMap, onTouchStartMiniMap]); const updateMiniMap = React.useCallback(debounce(() => { const root = refs.root.current; const container = refs.container.current; const miniMap_ = refs.miniMap.current; if (!miniMap_) return; const itemsContainer = Array.from(container.children); const itemsContainerMap = {}; itemsContainer.forEach(element => { itemsContainerMap[element.dataset.id] = element; }); const miniMapItems = miniMap_.children[0]; const itemsMiniMapItems = Array.from(miniMapItems.children); const itemsMiniMapItemsMap = {}; itemsMiniMapItems.forEach(element => { itemsMiniMapItemsMap[element.dataset.id] = element; }); const ratios = { root: root.clientWidth / root.clientHeight, container: container.clientWidth / container.clientHeight, contanerMiniMap: 170 / container.clientWidth }; const updateItemCopy = item => { const left = +item.style.left.replace('px', ''); const top = +item.style.top.replace('px', ''); const width_ = +item.style.width.replace('px', ''); const height_ = +item.style.height.replace('px', ''); item.style.width = `${width_ * ratios.contanerMiniMap}px`; item.style.height = `${height_ * ratios.contanerMiniMap}px`; item.style.left = `${left * ratios.contanerMiniMap}px`; item.style.top = `${top * ratios.contanerMiniMap}px`; }; itemsContainer.forEach(item => { const id = item.dataset.id; const itemCopy = item.cloneNode(); updateItemCopy(itemCopy); // add if (!itemsMiniMapItemsMap[id]) { miniMapItems.append(itemCopy); } // update else { const itemExisting = itemsMiniMapItemsMap[id]; itemExisting.style.left = itemCopy.style.left; itemExisting.style.top = itemCopy.style.top; itemExisting.style.width = itemCopy.style.width; itemExisting.style.height = itemCopy.style.height; } }); itemsMiniMapItems.forEach(item => { const id = item.dataset.id; // remove if (!itemsContainerMap[id]) { item.remove(); } }); }, 440), []); React.useEffect(() => { // update // mini map if (useMiniMap) updateMiniMap(); }, [children, useMiniMap]); return /*#__PURE__*/React.createElement(Surface, _extends({ ref: item => { if (ref) { if (is('function', ref)) ref(item); ref.current = item; } refs.root.current = item; }, color: "default", gap: 0, align: "unset", justify: "unset", flex: true, fullWidth: true, onMouseDown: onMouseDown, onTouchStart: onTouchStart, Component: Component, className: classNames([staticClassName('HTMLCanvas', theme) && ['amaui-HTMLCanvas-root'], className, classes.root, keyDown === ' ' && classes.move, disabled && classes.disabled]) }, other), pre, !noActions && /*#__PURE__*/React.createElement(Line, { gap: 0.5, direction: "row", align: "center", justify: "flex-start", className: classNames([staticClassName('HTMLCanvas', theme) && ['amaui-HTMLCanvas-actions'], classes.actions]) }, !noGuideLines && /*#__PURE__*/React.createElement(Label, { value: showGuidelines, onChange: onShowGuidelines, size: "small" }, /*#__PURE__*/React.createElement(Switch, null), "Guidelines"), !noFitCenter && /*#__PURE__*/React.createElement(Line, { gap: 1, direction: "row", align: "center", justify: "flex-end" }, /*#__PURE__*/React.createElement(Tooltip, { name: "Fit" }, /*#__PURE__*/React.createElement(IconButton, _extends({ size: size, onClick: onCenter }, IconButtonProps), /*#__PURE__*/React.createElement(IconCenter, null)))), !noZoomMenu && /*#__PURE__*/React.createElement(Menu, { menuItems: zoomOptions.map((item, index) => /*#__PURE__*/React.createElement(ListItem, _extends({ key: item.name, primary: /*#__PURE__*/React.createElement(Type, { version: "b3" }, item.name), value: item.name, selected: +positions.zoom.toFixed(2) === +(item.value || 0).toFixed(2) }, item.props, { size: "small", button: true }))), className: classes.menu }, /*#__PURE__*/React.createElement(Line, { align: "center", justify: "center", className: classes.zoom }, /*#__PURE__*/React.createElement(Type, { version: size === 'large' ? 'b2' : size === 'regular' ? 'b3' : 'b3', align: "center", whiteSpace: "nowrap", fullWidth: true }, (positions.zoom * 100).toFixed(0), "%")))), /*#__PURE__*/React.createElement(Line, _extends({}, ContainerProps, { ref: item => { if (ContainerProps?.ref) { if (is('function', ContainerProps?.ref)) ContainerProps.ref(item); ContainerProps.ref.current = item; } refs.container.current = item; }, gap: 0, align: "unset", justify: "unset", flex: true, fullWidth: true, className: classNames([staticClassName('HTMLCanvas', theme) && ['amaui-HTMLCanvas-container'], ContainerProps?.className, classes.container, showGuidelines && guidelines && classes[`guidelines_${[true, 'dots'].includes(guidelines) ? 'dots' : 'lines'}`]]), style: _objectSpread({ width, height }, ContainerProps?.style) }), children), useMiniMap && miniMap, post); }); HTMLCanvas.displayName = 'amaui-HTMLCanvas'; export default HTMLCanvas;