UNPKG

@activecollab/components

Version:

ActiveCollab Components

327 lines (308 loc) 14.1 kB
import _extends from "@babel/runtime/helpers/esm/extends"; import React, { useEffect, useMemo } from "react"; import styled from "styled-components"; import { SELECTION_BLUE, StyledFormattingToolbar, StyledToolbarButton, StyledToolbarLink, stopClick } from "./elements"; import { InsertLinkIcon, OpenExpandedIcon, TrashIcon } from "../../components/Icons"; import { StackedCard } from "../../components/StackedCard"; import { Tooltip } from "../../components/Tooltip"; import { Caption2 } from "../../components/Typography"; import { LinkCard, LinkPlaceholder, MOCK_LINKS, useUnfurl } from "../stackedCard/content"; import { useInlineEdit } from "../stackedCard/useInlineEdit"; import linkCoverPhoto from "../static/stacked-card-cover.png"; import linkHeroPhoto from "../static/stacked-card-hero.png"; /* ------------------------------------------------------------------ */ /* Link element — StackedCard + the URL unfurl lifecycle */ /* ------------------------------------------------------------------ */ /** * The only canvas element here built on the shipped design-system * `StackedCard`. The shell is hollow, so the board supplies the visual state * (`selected`) and the width, and the content comes from `LinkCard` — which * renders the unfurl lifecycle, not a static layout. * * Link cards are AUTO-HEIGHT. The host sets the width; the height follows the * content. That is why drawing a box commits only its width, and why resizing * is width-only. */ /* Width bounds shared by every drawn card (link, file). */ export const CARD_MIN_WIDTH = 180; export const CARD_MAX_WIDTH = 420; /* Below this the drag reads as a click, not a drawn box. */ export const CARD_DRAW_THRESHOLD = 8; export const clampCardWidth = width => Math.min(CARD_MAX_WIDTH, Math.max(CARD_MIN_WIDTH, Math.round(width))); export const LINK_DEFAULT_WIDTH = 260; /* A shape check, nothing more. The real validation is server-side: the backend normalizes the URL, refuses anything but http(s), blocks private address space and re-checks every redirect hop. */ export const isValidLinkUrl = value => /^https?:\/\/[^\s/?#]+\.[^\s]*$/i.test(value.trim()); /* https://www.example.com/a?b → example.com */ export const linkDomain = url => url.trim().replace(/^https?:\/\//i, "").split(/[/?#]/)[0].replace(/^www\./i, ""); /* Stand-in favicon / hero colours. A real unfurl stores the fetched favicon and OpenGraph image as attachments; here the colour is derived from the domain so every pasted host keeps a stable identity instead of sharing one colour. */ export const LINK_PALETTE = ["#0F6E56", "#8C5A2B", "#2F5DA8", "#8A3E6B", "#3F6B25", "#A8452F"]; export const linkColor = (domain, offset) => { let hash = 0; for (let index = 0; index < domain.length; index += 1) { hash = (hash * 31 + domain.charCodeAt(index)) % 997; } return LINK_PALETTE[(hash + offset) % LINK_PALETTE.length]; }; /* Hand-written seeds for the hosts worth demoing. Vimeo resolves to a playable video with a real cover image; the history link deliberately resolves without one, so the colour-cover fallback (a link whose page carries no OpenGraph image) is on the board too. */ export const KNOWN_LINKS = [_extends({}, MOCK_LINKS.vimeo, { heroSrc: linkHeroPhoto }), _extends({}, MOCK_LINKS.history)]; /** * The fake "server" response for a URL — what `GET /link-previews/...` would * eventually hold. Known hosts get their seed; anything else resolves to a * generic article keyed off its own domain, so pasting an arbitrary link still * produces a believable card instead of an empty one. */ export const resolveMockLink = url => { const domain = linkDomain(url); const known = KNOWN_LINKS.find(link => linkDomain(link.url) === domain); if (known) { return _extends({}, known, { url }); } return { url, domain, faviconColor: linkColor(domain, 0), heroColor: linkColor(domain, 3), heroSrc: linkCoverPhoto, title: domain + " \u2014 the title a real unfurl reads from og:title", description: "The summary an unfurl takes from og:description, falling back to the page's meta description when the site carries no OpenGraph tags.", kind: "Article", playable: false }; }; /* Private address space — the one class of URL that passes a shape check and still can never resolve, because the server refuses to fetch it (and re-checks every redirect hop). Pasting one of these is how the error rung of the ladder is reachable from the board. */ export const PRIVATE_HOST = /^(?:10|127|192\.168|169\.254|172\.(?:1[6-9]|2\d|3[01]))\./; export const isUnfurlable = domain => !PRIVATE_HOST.test(domain); /** A link element on the board. Position lives in the shared positions map. */ /* On the canvas a link is always the expanded card — hero, then the tinted footer band with the domain, title and description. Compact is a column-view density, so the board pins the level and shows no display switch. */ export const LINK_DISPLAY = "expanded"; /* The card fills the element box the board drew; the shell's reference 260px width gives way to the host. Height stays auto. */ export const StyledLinkCard = styled(StackedCard).withConfig({ displayName: "linkElement__StyledLinkCard", componentId: "sc-1e6iu71-0" })(["&&{width:100%;}user-select:none;"]); /* The inline URL editor — the placeholder's interactive half. */ export const StyledLinkEditArea = styled.div.withConfig({ displayName: "linkElement__StyledLinkEditArea", componentId: "sc-1e6iu71-1" })(["display:flex;flex-direction:column;gap:6px;padding:16px 12px;"]); export const StyledLinkInput = styled.input.withConfig({ displayName: "linkElement__StyledLinkInput", componentId: "sc-1e6iu71-2" })(["width:100%;box-sizing:border-box;padding:8px 10px;border:1.5px solid ", ";border-radius:6px;background:var(--page-paper-main);color:var(--color-theme-900);font-family:-apple-system,BlinkMacSystemFont,\"Roboto\",\"Helvetica Neue\",Arial,sans-serif;font-size:13px;text-align:center;outline:none;&::placeholder{color:var(--color-theme-500);}"], _ref => { let $invalid = _ref.$invalid; return $invalid ? "var(--red-alert)" : "var(--sc-selection-ring)"; }); export const StyledLinkHint = styled(Caption2).withConfig({ displayName: "linkElement__StyledLinkHint", componentId: "sc-1e6iu71-3" })(["display:block;text-align:center;color:var(--red-alert);"]); /* The dashed rubber band drawn while the Link tool is armed. The width readout is the honest signal: the box sets the card's width, the height is the card's own business. */ /* The outline the app's ShapePreview draws — stroke only, the selection blue, at 70% — used both for the shape rubber band and the hover preview. */ export const StyledShapePreview = styled.div.withConfig({ displayName: "linkElement__StyledShapePreview", componentId: "sc-1e6iu71-4" })(["position:absolute;box-sizing:border-box;pointer-events:none;svg{display:block;overflow:visible;fill:none;stroke:", ";stroke-width:2;opacity:0.7;}"], SELECTION_BLUE); export const shapePreviewGlyph = (tool, width, height) => /*#__PURE__*/React.createElement("svg", { width: "100%", height: "100%" }, tool === "circle" ? /*#__PURE__*/React.createElement("ellipse", { cx: "50%", cy: "50%", rx: "50%", ry: "50%" }) : null, tool === "roundedRectangle" ? /*#__PURE__*/React.createElement("rect", { x: "0", y: "0", width: "100%", height: "100%", rx: 8, ry: 8 }) : null, tool === "triangle" ? /*#__PURE__*/React.createElement("polygon", { points: width / 2 + ",0 " + width + "," + height + " 0," + height }) : null); export const StyledLinkDraft = styled.div.withConfig({ displayName: "linkElement__StyledLinkDraft", componentId: "sc-1e6iu71-5" })(["position:absolute;box-sizing:border-box;border:1.5px dashed ", ";border-radius:8px;background-color:rgba(59,130,246,0.08);pointer-events:none;.draft-width{position:absolute;top:-22px;left:0;padding:1px 6px;border-radius:4px;background-color:", ";color:#ffffff;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;white-space:nowrap;}"], SELECTION_BLUE, SELECTION_BLUE); /* The link element's floating toolbar. It acts on the ENTITY: go to the page, change which page, or bin the card. */ export const LinkFormattingToolbar = _ref2 => { let url = _ref2.url, onEditUrl = _ref2.onEditUrl, onDelete = _ref2.onDelete; return /*#__PURE__*/React.createElement(StyledFormattingToolbar, { onMouseDown: stopClick, onClick: stopClick }, /*#__PURE__*/React.createElement(StyledToolbarLink, { href: url, target: "_blank", rel: "noopener noreferrer" }, /*#__PURE__*/React.createElement(OpenExpandedIcon, { className: "i16" }), "Visit"), /*#__PURE__*/React.createElement(StyledToolbarButton, { type: "button", onClick: onEditUrl }, /*#__PURE__*/React.createElement(InsertLinkIcon, { className: "i16" }), "Edit"), /*#__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" })))); }; /** * The card's three resize callbacks, handed down untouched from the board. The * grip reports deltas measured from the gesture START and brackets the stream * with a start and a commit, so a card is only a conduit — the board resolves * what a delta means. */ /** * A link element with a committed URL: the card runs the unfurl lifecycle. * * One controller per card, and the seed is memoized on the URL so the board's * own re-renders (drag, selection, a grid change) never restart the ladder. */ export const LinkPreviewCard = _ref3 => { var _element$url; let element = _ref3.element, selected = _ref3.selected, resize = _ref3.resize, onEditUrl = _ref3.onEditUrl, onDelete = _ref3.onDelete; const url = (_element$url = element.url) != null ? _element$url : ""; const link = useMemo(() => resolveMockLink(url), [url]); const unfurl = useUnfurl({ link, delayMs: 700, shouldFail: !isUnfurlable(link.domain) }); return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StyledLinkCard, _extends({ selected: selected, resizable: selected, resizeAxis: "width", resizeLabel: "Resize link card" }, resize), /*#__PURE__*/React.createElement(LinkCard, { unfurl: unfurl, display: LINK_DISPLAY })), selected ? /*#__PURE__*/React.createElement(LinkFormattingToolbar, { url: url, onEditUrl: onEditUrl, onDelete: onDelete }) : null); }; /** * The three phases of a link element: * * placeholder drawn but empty — an interactive invitation, distinct from the * loading skeleton (spec §5) * editing the inline URL field, with the shell's `entered` ring and drag * switched off * preview a URL is committed; the unfurl lifecycle takes over */ export const LinkElement = _ref4 => { let element = _ref4.element, selected = _ref4.selected, autoEdit = _ref4.autoEdit, onCommitUrl = _ref4.onCommitUrl, resize = _ref4.resize, onDelete = _ref4.onDelete; const edit = useInlineEdit({ validate: value => isValidLinkUrl(value) ? true : "Enter a valid link, e.g. https://…", onCommit: value => onCommitUrl(value.trim()) }); const begin = edit.begin; // A box drawn with the Link tool is a request to add a link, so the editor // opens straight away; the placeholder is what a cancelled edit falls back to. useEffect(() => { if (autoEdit) { begin(); } }, [autoEdit, begin]); /* Editing wins over the preview, so the same editor serves the first fill and every later correction: "Edit link" seeds the field with the current URL and Esc drops back to the card that was already there. */ if (edit.editing) { return /*#__PURE__*/React.createElement(StyledLinkCard, { entered: true, draggable: false, "aria-label": "Add a link" }, /*#__PURE__*/React.createElement(StyledLinkEditArea, null, /*#__PURE__*/React.createElement(StyledLinkInput, _extends({}, edit.bind, { type: "text", inputMode: "url", placeholder: "https://\u2026", $invalid: edit.error != null })), edit.error ? /*#__PURE__*/React.createElement(StyledLinkHint, { role: "alert" }, edit.error) : /*#__PURE__*/React.createElement(Caption2, { color: "tertiary", style: { textAlign: "center" } }, "Enter to add \xB7 Esc to cancel"))); } if (element.url != null) { return /*#__PURE__*/React.createElement(LinkPreviewCard, { element: element, selected: selected, resize: resize, onEditUrl: () => { var _element$url2; edit.reset((_element$url2 = element.url) != null ? _element$url2 : ""); edit.begin(); }, onDelete: onDelete }); } return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StyledLinkCard, { selected: selected, "aria-label": "Empty link card" }, /*#__PURE__*/React.createElement(LinkPlaceholder, { "data-drag-through": "", onClick: edit.begin })), selected ? /*#__PURE__*/React.createElement(StyledFormattingToolbar, { onMouseDown: stopClick, onClick: stopClick }, /*#__PURE__*/React.createElement(StyledToolbarButton, { type: "button", onClick: edit.begin }, /*#__PURE__*/React.createElement(InsertLinkIcon, { className: "i16" }), "Edit"), /*#__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" })))) : null); }; //# sourceMappingURL=linkElement.js.map