UNPKG

@activecollab/components

Version:

ActiveCollab Components

430 lines (412 loc) 18.3 kB
import _extends from "@babel/runtime/helpers/esm/extends"; import React, { useCallback, useEffect, useRef, useState } from "react"; import styled from "styled-components"; import { StyledFormattingToolbar, StyledToolbarButton, stopClick } from "./elements"; import { ArrowRefreshIcon, AttachmentIcon, CancelCrossIcon, DownloadIcon, TrashIcon, UploadIcon } from "../../components/Icons"; import { SpinnerLoader } from "../../components/Loaders"; import { ProgressRing } from "../../components/ProgressRing"; import { StackedCard, StackedCardCover, StackedCardRow } from "../../components/StackedCard"; import { Tooltip } from "../../components/Tooltip"; import { Body2, Caption1, Caption2 } from "../../components/Typography"; import { COVER_RATIO_IMAGE, Placeholder } from "../stackedCard/content"; /* ------------------------------------------------------------------ */ /* File element — the upload lifecycle + the file card */ /* ------------------------------------------------------------------ */ /** * The file card is the second element built on `StackedCard`, and it mirrors the * card in a project's Files tab (`Folders/GridView/Items/File.js`): a thumbnail * over a strip with the name, then size · date. * * Its lifecycle is the app's real upload lifecycle, which is two phases and not * one — the distinction the ring alone doesn't tell you: * * uploading XHR progress 0–100. `ProgressRing radius={22} stroke={4}` with * the percentage, exactly as `Items/FileUpload.js` does it. * preparing bytes are in, the server is generating the thumbnail. Progress * is no longer knowable, so the ring gives way to the * indeterminate `SpinnerLoader` at the same size — again as the * real card does at `progress === 100`. * ready the thumbnail exists; the card renders it. * error the upload failed: retry, same as the app's `ArrowRefreshIcon`. * * Both phases reserve the same box as the finished cover, so a card never * reflows as it climbs — the discipline the unfurl ladder established. */ /* The Files-tab card is a fixed 208×208. On a canvas the width is the host's, so 208 is only the default a click-placed card takes. */ export const FILE_DEFAULT_WIDTH = 208; /* Big enough to be worth refusing, so the error state is reachable: the server enforces the real limits (and answers StorageOverusedError when full). */ export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; export const UPLOAD_TICK_MS = 110; export const UPLOAD_STEP = 9; /* How long the server spends making a thumbnail once the bytes have landed. */ export const PREPARING_MS = 1100; /** What the mock keeps about a picked file. */ /** A file element on the board. Position lives in the shared positions map. */ /* Angie.functions.format_file_size — two decimals, no space, lower-case k. "1.20MB", not "1.2 MB". Reproduced exactly so the mock reads like the app. */ export const FILE_SIZE_UNITS = [["TB", 1099511627776], ["GB", 1073741824], ["MB", 1048576], ["kB", 1024]]; export const formatFileSize = value => { for (const _ref of FILE_SIZE_UNITS) { const unit = _ref[0]; const bytes = _ref[1]; const inUnit = value / bytes; if (inUnit > 0.9) { return "" + inUnit.toFixed(2) + unit; } } return Math.trunc(value) + "B"; }; export const describeFile = file => { const dot = file.name.lastIndexOf("."); const hasExtension = dot > 0; return { base: hasExtension ? file.name.slice(0, dot) : file.name, extension: hasExtension ? file.name.slice(dot + 1) : "", size: file.size, mimeType: file.type, // Images get a real thumbnail; the server would generate one for every type, // but the mock can only show what the browser can already decode. thumbnailUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : null }; }; /* Object URLs are a manual allocation — release one whenever the file it stands for stops being on the board. */ export const revokeThumbnail = file => { if (file != null && file.thumbnailUrl) { URL.revokeObjectURL(file.thumbnailUrl); } }; /** The first actual file on a clipboard or a drop, or null if there is none. */ export const binaryFrom = data => { if (!data) { return null; } if (data.files && data.files.length > 0) { return data.files[0]; } // A screenshot paste arrives as an item, not in `files`. for (const item of Array.from((_data$items = data.items) != null ? _data$items : [])) { var _data$items; if (item.kind === "file") { const picked = item.getAsFile(); if (picked) { return picked; } } } return null; }; /** * Drives one file through the upload lifecycle. Stands in for * `useFileHandler` + the XHR in `utils/upload.jsx`: ticks progress to 100, * hands over to the thumbnail phase, then resolves. */ export const useMockUpload = file => { const _useState = useState("uploading"), status = _useState[0], setStatus = _useState[1]; const _useState2 = useState(0), progress = _useState2[0], setProgress = _useState2[1]; const timers = useRef([]); const ticker = useRef(null); const progressRef = useRef(0); const clearAll = useCallback(() => { timers.current.forEach(clearTimeout); timers.current = []; if (ticker.current) { clearInterval(ticker.current); ticker.current = null; } }, []); const retry = useCallback(() => { clearAll(); progressRef.current = 0; setProgress(0); setStatus("uploading"); if (!file) { return; } if (file.size > MAX_UPLOAD_BYTES) { timers.current.push(setTimeout(() => setStatus("error"), 700)); return; } ticker.current = setInterval(() => { progressRef.current = Math.min(100, progressRef.current + UPLOAD_STEP); setProgress(progressRef.current); if (progressRef.current >= 100) { clearAll(); // The bytes are up; the thumbnail is the server's job now. setStatus("preparing"); timers.current.push(setTimeout(() => setStatus("ready"), PREPARING_MS)); } }, UPLOAD_TICK_MS); }, [clearAll, file]); useEffect(() => { retry(); return clearAll; }, [retry, clearAll]); return { status, progress, retry }; }; /* The card fills the box the board drew; height stays auto. */ export const StyledFileCard = styled(StackedCard).withConfig({ displayName: "fileElement__StyledFileCard", componentId: "sc-1bsy8py-0" })(["&&{width:100%;}user-select:none;"]); /* The cover while uploading or preparing — the ring/spinner centred in the box the finished thumbnail will occupy, so resolving never reflows the card. */ export const StyledUploadCover = styled.div.withConfig({ displayName: "fileElement__StyledUploadCover", componentId: "sc-1bsy8py-1" })(["display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;width:100%;height:100%;background-color:var(--color-theme-200);"]); /* No thumbnail (the browser can't decode a .zip): the clip plus the extension. The real card has NO type-icon fallback — it renders a bare <img> and trusts the server — so this is net-new and worth a decision. */ export const StyledFileFallback = styled.div.withConfig({ displayName: "fileElement__StyledFileFallback", componentId: "sc-1bsy8py-2" })(["display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;width:100%;height:100%;background-color:var(--color-theme-200);color:var(--color-theme-600);> svg{width:36px;height:36px;fill:var(--color-theme-500);}"]); /* Name row: the base name truncates, the extension never does — the same split the real card makes with removeExtensionFromFileName. */ export const StyledFileName = styled.div.withConfig({ displayName: "fileElement__StyledFileName", componentId: "sc-1bsy8py-3" })(["display:flex;align-items:baseline;min-width:0;width:100%;.file-base{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.file-ext{flex-shrink:0;}"]); /* The drop overlay, from directive.ang_drop_files.less: a near-opaque cover with a 2px dashed primary border and "Drop here". The app sets 24px type for a full-page target; on a card it reads at Body2. */ export const StyledDropOverlay = styled.div.withConfig({ displayName: "fileElement__StyledDropOverlay", componentId: "sc-1bsy8py-4" })(["position:absolute;inset:0;z-index:3;display:flex;align-items:center;justify-content:center;padding:8px;box-sizing:border-box;background-color:var(--page-paper-main);.drop-box{display:flex;align-items:center;justify-content:center;width:100%;height:100%;box-sizing:border-box;border:2px dashed var(--color-primary);border-radius:6px;}"]); export const StyledHiddenFileInput = styled.input.withConfig({ displayName: "fileElement__StyledHiddenFileInput", componentId: "sc-1bsy8py-5" })(["display:none;"]); /* The placeholder reserves the same box the finished cover will occupy — the cover's ratio, not a pixel height — so attaching a file does not resize the card under the pointer. Placeholder's own fixed height is overridden here; the descendant selector outranks its class. */ export const StyledPlaceholderBox = styled.div.withConfig({ displayName: "fileElement__StyledPlaceholderBox", componentId: "sc-1bsy8py-6" })(["width:100%;aspect-ratio:", ";> button{height:100%;}"], COVER_RATIO_IMAGE); /* The interactive empty state, in the shared placeholder frame the other card types use. Doubles as the drop target, like its copy promises. */ export const FilePlaceholder = props => /*#__PURE__*/React.createElement(StyledPlaceholderBox, null, /*#__PURE__*/React.createElement(Placeholder, _extends({ icon: /*#__PURE__*/React.createElement(AttachmentIcon, null), label: "Drop a file or click to upload", dashed: true, "data-drag-through": "" }, props))); /* The file element's floating toolbar. The leading action is whatever the current phase makes meaningful; Move to Trash is always available. */ export const FileFormattingToolbar = _ref2 => { let status = _ref2.status, onPick = _ref2.onPick, onCancel = _ref2.onCancel, onRetry = _ref2.onRetry, onDelete = _ref2.onDelete; return /*#__PURE__*/React.createElement(StyledFormattingToolbar, { onMouseDown: stopClick, onClick: stopClick }, status === "placeholder" ? /*#__PURE__*/React.createElement(StyledToolbarButton, { type: "button", onClick: onPick }, /*#__PURE__*/React.createElement(UploadIcon, { className: "i16" }), "Upload") : null, status === "uploading" || status === "preparing" ? /*#__PURE__*/React.createElement(StyledToolbarButton, { type: "button", onClick: onCancel }, /*#__PURE__*/React.createElement(CancelCrossIcon, { className: "i16" }), "Cancel") : null, status === "error" ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StyledToolbarButton, { type: "button", onClick: onRetry }, /*#__PURE__*/React.createElement(ArrowRefreshIcon, { className: "i16" }), "Retry"), /*#__PURE__*/React.createElement(StyledToolbarButton, { type: "button", onClick: onCancel }, /*#__PURE__*/React.createElement(CancelCrossIcon, { className: "i16" }), "Cancel")) : null, status === "ready" ? /*#__PURE__*/React.createElement(StyledToolbarButton, { type: "button" }, /*#__PURE__*/React.createElement(DownloadIcon, { className: "i16" }), "Download") : null, /*#__PURE__*/React.createElement("span", { className: "divider" }), /*#__PURE__*/React.createElement(Tooltip, { title: "Move to Trash" }, /*#__PURE__*/React.createElement(StyledToolbarButton, { type: "button", className: "icon-only", onClick: onDelete }, /*#__PURE__*/React.createElement(TrashIcon, { className: "i16" })))); }; /** * A file element with a file attached: the card runs the upload lifecycle and * then renders the finished preview. */ export const FileUploadCard = _ref3 => { let element = _ref3.element, selected = _ref3.selected, resize = _ref3.resize, onCancel = _ref3.onCancel, onDelete = _ref3.onDelete; const file = element.file; const upload = useMockUpload(file); const ready = upload.status === "ready"; return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StyledFileCard, _extends({ selected: selected, resizable: selected, resizeAxis: "width", resizeLabel: "Resize file card" }, resize), /*#__PURE__*/React.createElement(StackedCardCover, { aspectRatio: COVER_RATIO_IMAGE }, ready ? renderFileThumbnail(file) : /*#__PURE__*/React.createElement(StyledUploadCover, null, upload.status === "uploading" ? /*#__PURE__*/React.createElement(ProgressRing, { progress: upload.progress, radius: 22, stroke: 4 }, /*#__PURE__*/React.createElement(Caption2, { weight: "bold", color: "secondary" }, upload.progress, "%")) : null, upload.status === "preparing" ? /*#__PURE__*/React.createElement(SpinnerLoader, { radius: 22, stroke: 4 }) : null, upload.status === "error" ? /*#__PURE__*/React.createElement(ArrowRefreshIcon, { style: { cursor: "pointer" }, onClick: upload.retry }) : null, /*#__PURE__*/React.createElement(Caption2, { color: "tertiary" }, upload.status === "uploading" ? "Uploading…" : null, upload.status === "preparing" ? "Preparing preview…" : null, upload.status === "error" ? "Upload failed" : null))), /*#__PURE__*/React.createElement(StackedCardRow, { role: "title" }, /*#__PURE__*/React.createElement(StyledFileName, null, /*#__PURE__*/React.createElement(Caption1, { weight: "bold", className: "file-base" }, file == null ? void 0 : file.base), file != null && file.extension ? /*#__PURE__*/React.createElement(Caption1, { weight: "bold", className: "file-ext" }, ".", file.extension) : null)), /*#__PURE__*/React.createElement(StackedCardRow, { role: "meta" }, /*#__PURE__*/React.createElement(Caption2, { color: "tertiary" }, ready && file ? formatFileSize(file.size) + " \xB7 Just now" : " "))), selected ? /*#__PURE__*/React.createElement(FileFormattingToolbar, { status: upload.status, onCancel: onCancel, onRetry: upload.retry, onDelete: onDelete }) : null); }; /* An image renders for real; anything else falls back to the clip + extension. */ export const renderFileThumbnail = file => file != null && file.thumbnailUrl ? /*#__PURE__*/React.createElement("img", { src: file.thumbnailUrl, alt: "" }) : /*#__PURE__*/React.createElement(StyledFileFallback, null, /*#__PURE__*/React.createElement(AttachmentIcon, null), /*#__PURE__*/React.createElement(Caption2, { color: "tertiary" }, file != null && file.extension ? file.extension.toUpperCase() : "FILE")); /** * The two shapes of a file element: an empty placeholder that can be clicked or * dropped onto, and the uploading/ready card. * * A box drawn with the File tool opens the picker immediately. Dismissing the * picker fires no change, so the card simply stays a placeholder — which is what * makes Esc a no-op by construction rather than by handling. */ export const FileElement = _ref4 => { let element = _ref4.element, selected = _ref4.selected, autoOpen = _ref4.autoOpen, onPickFile = _ref4.onPickFile, resize = _ref4.resize, onCancel = _ref4.onCancel, onDelete = _ref4.onDelete; const inputRef = useRef(null); const _useState3 = useState(false), dropping = _useState3[0], setDropping = _useState3[1]; const openPicker = useCallback(() => { var _inputRef$current; (_inputRef$current = inputRef.current) == null || _inputRef$current.click(); }, []); useEffect(() => { if (autoOpen) { openPicker(); } }, [autoOpen, openPicker]); const handleInputChange = useCallback(event => { var _event$target$files; const picked = (_event$target$files = event.target.files) == null ? void 0 : _event$target$files[0]; if (picked) { onPickFile(picked); } // Let the same file be chosen again after a cancel or a delete. event.target.value = ""; }, [onPickFile]); const handleDrop = useCallback(event => { event.preventDefault(); event.stopPropagation(); setDropping(false); const dropped = binaryFrom(event.dataTransfer); if (dropped) { onPickFile(dropped); } }, [onPickFile]); const input = /*#__PURE__*/React.createElement(StyledHiddenFileInput, { ref: inputRef, type: "file", onChange: handleInputChange }); if (element.file != null) { return /*#__PURE__*/React.createElement(React.Fragment, null, input, /*#__PURE__*/React.createElement(FileUploadCard, { element: element, selected: selected, resize: resize, onCancel: onCancel, onDelete: onDelete })); } return /*#__PURE__*/React.createElement(React.Fragment, null, input, /*#__PURE__*/React.createElement(StyledFileCard, { selected: selected, "aria-label": "Empty file card" }, /*#__PURE__*/React.createElement(StackedCardRow, { role: "custom", bleed: true /* Marks this row as owning its own drops, so the board-level handler stands aside and lets the card take the file. */, "data-file-dropzone": "", onDragEnter: event => { event.preventDefault(); setDropping(true); }, onDragOver: event => event.preventDefault(), onDragLeave: event => { // Ignore the leaves fired when crossing onto a child. if (!event.currentTarget.contains(event.relatedTarget)) { setDropping(false); } }, onDrop: handleDrop }, /*#__PURE__*/React.createElement(FilePlaceholder, { onClick: openPicker }), dropping ? /*#__PURE__*/React.createElement(StyledDropOverlay, null, /*#__PURE__*/React.createElement("span", { className: "drop-box" }, /*#__PURE__*/React.createElement(Body2, { color: "secondary" }, "Drop here"))) : null)), selected ? /*#__PURE__*/React.createElement(FileFormattingToolbar, { status: "placeholder", onPick: openPicker, onDelete: onDelete }) : null); }; //# sourceMappingURL=fileElement.js.map