@activecollab/components
Version:
ActiveCollab Components
327 lines (308 loc) • 14.1 kB
JavaScript
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