@activecollab/components
Version:
ActiveCollab Components
430 lines (412 loc) • 18.3 kB
JavaScript
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