From e68ae3d58700ab414379dc7007802d268efb5924 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 14:49:05 -0800 Subject: [PATCH 1/2] Open every picture in one viewer laid out as Slack's, and step through a message's files with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat's pictures opened in assistant-ui's copied gallery dialog, and document and decision-card pictures in the editor's own lightbox: two looks and two keyboards for one job, neither with rotate or a zoom slider, both on a flat ink ground, with Download at the top beside Copy and Close. Pressing a file sent with a message downloaded it at once. Every caller now opens the editor's viewer (`ImageLightbox`), loaded only when a picture opens, with its own stylesheet. It is laid out as Slack's: the whole window over the picture itself, blurred and dimmed; along the top who sent it (their face and handle, chopin for the Planner's pictures), then "3 hours ago in – " ("in a thread in " for a thread's messages), or for a document's picture the document's title and the picture's name, with Close at the top end; the picture fitted with a margin; Rotate, then Zoom out, the zoom slider and Zoom in at the bottom start; Download alone at the bottom end, with nothing to its right. Several pictures step with Previous and Next at the sides and the arrow keys, and say "2 of 5". The keys, the wheel, a pinch and a double-click zoom; a zoomed picture pans under a drag. Download reads the original as bytes, so a signed-in upload saves as a file under its name; a picture whose host refuses the read opens in a new tab without opener or referrer. Focus is trapped and returned; it fades in and out, and not at all under reduced motion. A message's files join its viewer after its pictures: a text file shows as its text, and Rotate and zoom are not drawn for it. The shared tooltip draws itself inside an open modal dialog, so the viewer's controls say their names. The document's title reaches the viewer through a small `ImagePlace` context, and the viewer's styles leave the editor's stylesheet. The new and changed viewer specs fail on 38716a9ac (10 of them) and pass here. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun run types, bun run ci Assistant-verification: bun test packages/editor/src/widgets packages/icons apps/web/src/chat apps/web/src/icon-tooltip.test.ts (1103 pass) Assistant-verification: chat-media, chat-images, chat-images-gallery, chat-attachments, images, decision-option-previews on aaa1d6cea (95 passed), and harness "images the Planner makes" (1 passed) Assistant-verification: the changed chat-media and images specs on 38716a9ac (10 failed) Assistant-verification: bun run design:behavior (104 passed; cursors and emoji picker specs flaked under load and passed on their own, 20 passed) Assistant-verification: bun run build (initial JS 259,958 B raw / 82,482 B gzip, initial CSS 184,551 B raw / 32,672 B gzip) User-preference: the image click view should look something like Slack's, but remove everything to the right of the download button on the bottom right hand corner; the gallery for multiple images or documents in one message should have Next and Previous arrows Co-authored-by: Alex Lavaee --- apps/web/DESIGN.md | 6 +- .../src/chat/attachments/message-files.tsx | 37 +- apps/web/src/chat/image.css | 197 +---- apps/web/src/chat/image.tsx | 149 ++-- apps/web/src/chat/threads/thread-panel.tsx | 56 +- apps/web/src/chat/transcript.tsx | 5 +- .../src/design-audit/plates/chat-media.tsx | 2 +- apps/web/src/icon-tooltip.tsx | 3 + apps/web/src/question-markdown.tsx | 5 +- apps/web/src/room-workspace.tsx | 15 +- e2e/chat-attachments.e2e.ts | 27 +- e2e/chat-images-gallery.e2e.ts | 12 +- e2e/chat-images.e2e.ts | 149 ++-- e2e/chat-media.e2e.ts | 377 +++++++++- e2e/design/chat-media.e2e.ts | 20 +- e2e/harness.e2e.ts | 6 +- e2e/images.e2e.ts | 71 +- packages/editor/package.json | 4 + packages/editor/src/styles.css | 115 --- .../src/widgets/image-lightbox-state.test.ts | 124 +++- .../src/widgets/image-lightbox-state.ts | 153 ++++ .../editor/src/widgets/image-lightbox.css | 536 ++++++++++++++ .../editor/src/widgets/image-lightbox.tsx | 686 +++++++++++++++--- packages/editor/src/widgets/image-place.ts | 7 + packages/editor/src/widgets/image.tsx | 26 +- packages/icons/src/index.ts | 1 + packages/icons/src/line.tsx | 10 + .../exceptions/dynamic-packages.json | 9 +- .../exceptions/dynamic-web.json | 2 +- 29 files changed, 2119 insertions(+), 691 deletions(-) create mode 100644 packages/editor/src/widgets/image-lightbox.css create mode 100644 packages/editor/src/widgets/image-place.ts diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index f45dcacea..6f8428580 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -220,9 +220,9 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Document:** The authored page is white, readable, and wider only where content requires it. Headings, lists, tables, code, and callouts retain document semantics inside the editor, and edit as Notion's do: the `/` menu leads with the basic blocks (Text, Heading 1 to 3, Bulleted and Numbered list, Quote, Divider) before the blocks only it makes; Markdown shortcuts and `---` make blocks as they are typed; Mod+Shift+ArrowUp and ArrowDown move the block holding the caret, Mod+D copies it, and Mod+E makes inline code. A block's handle (⋮⋮: drag to move it, click for its menu) stands 4px left of its content on every block, always in the same place; a button a block draws in that gutter, such as a resolved decision's marker, stands left of the handle on the same line with the same 4px between, never under it, and for a writer a narrow gutter widens by 12px to hold both while the document has one. An image's left resize corners reach into the image, not into the handle's column. A code block is edited where it is read, as Notion's is: one surface, its own text coloured in place by the shared highlighter (Chat's inks, painted with CSS Custom Highlights, never written into the editor), with a code editor's keys inside (Enter is always a line, Tab and Shift+Tab indent every selected line, Mod+A selects its code first) and a document's at its edges (Escape, Mod+Enter, and the arrows at its end where nothing follows leave it; Backspace in an empty one makes it a paragraph; ``` with a language and Enter or Space makes one). Long lines scroll in the block's own lane until Wrap lines; for a reader who cannot edit, that lane takes keyboard focus. Only a patch keeps a drawn preview beside its source. A code block's controls sit in its top-end corner: Wrap lines, a diagram's or patch's source toggle and _Copy code_ come on hover or focus beside its language (always shown on touch). _Copy code_ is a Chat code block's control in the document's ghost icon button: it copies the block's source exactly as written, for readers too, then says _Copied_ with a check, or _Couldn't copy_ with a warning glyph in the danger icon role, for three seconds. Links are followed as GitHub's and Notion's are, from the document, its read-only and earlier versions, its link preview's Open and Chat alike: a link to a section of the document (`#heading`, the heading's text as GitHub slugs it, `-1` after a repeat, as a heading mention's anchor is) scrolls there in the same tab, sliding unless motion is reduced or the keyboard asked, with the heading washed in the change marks' colour for a moment and faded on the shared ease, and the hash in the address so Back returns to where the reader was; one to another document opens it in place, at its section when it names one; a section that is not there moves nothing. Only a link to another site opens a new tab, without an opener, and a modified or middle click still opens one. - **Document images:** Click opens a full-screen preview with zoom and close controls. Hover or keyboard focus reveals metadata editing and corner resize handles. Resizing preserves the image ratio, shares the chosen width with collaborators, and remains bounded by the document or column on narrow screens. - **Chat images:** Every image in a Chat message — an agent turn's image part, one written in Markdown, or an image a person attached and sent — is drawn one way, the way Slack draws images, through assistant-ui's copied `Image` and `ImageGallery` elements (recorded in `components/sources.json` with their extension-point patches; see `docs/assistant-ui.md` and `THIRD_PARTY_NOTICES.md`), restyled from outside by Chopin's own unlayered rules (`chat/image.css`) to Slack's proportions in Chopin's colours, and driven by Chopin's state through props with none of assistant-ui's runtime. A single image is a block under the message: its name (the alt text, an attachment's file name, a missing alt reading _Image_ or _Generated image_, with a small _Generated_ label and sparkle where the Planner made it) on a line of its own in the compact role and tertiary ink 4px above it, as Slack's file meta line is, whole however long; then the picture in a frame with a 1px hairline (`--color-button-outline-edge`) and 8px corners, fitted whole inside Slack's 480 by 360 and never drawn larger than it is; then any caption in tertiary ink. Its shape is held from the first paint where it is known (stated, or read from a data address's own bytes, turned a quarter where the file says so), so nothing moves when it arrives; one whose shape is not known yet holds Slack's quiet 360 by 240 box on the inset ground, whose pulsing glyph holds still under reduced motion. An upload is read from the server's copies at Slack's thumbnail widths (`?w=480`, or the narrowest of 360 to 1024 that fills the box on the screen it is on), never as its whole original; another address is drawn as written. The image's own toolbar sits on it, 10px into its top-right corner, as Slack's does, and the message row's toolbar stays on the row: the page's surface with a hairline, a 2px inset, 8px corners and the resting shadow, holding 32px icon buttons with 4px corners for _Open image_, _Copy image_ (a check for a moment), _Download image_, and, for an address on another site, _Open image in a new tab_; it fades in over 100ms on hover or keyboard focus, is held back while the image loads, and stands beside an image narrower than itself rather than over it. Pressing the picture opens the viewer. An address on another site is always drawn from its own address: once a pointer or focus reaches the image, the page probes it once without credentials or referrer to learn whether its host lets a page read its bytes; until then, and where the host does not, copy reads _Copy link_ and download becomes an _Open image_ link in a new tab without opener or referrer; where it does, copy and download work on the bytes and say _Couldn't copy image_ or _Couldn't download image_ if the read then fails. Chopin's server never fetches an address a person wrote. A path on this site that is not an upload is read without credentials and drawn only if a raster image answers. Several images of one message (from an agent turn, sent attachments, or written together in Markdown side by side or in consecutive image-only paragraphs) are Slack's grid, at most 480px wide with 4px between tiles of 8px corners on the inset ground: two side by side as squares, three as one tall tile beside two stacked squares, four or more as two rows of two whose fourth says _+N_ for the rest, which the viewer holds. Each tile is a button named _Open image: ``_ drawn from the copy for its size. An image that cannot load is Slack's placeholder in the box the image would have taken, never smaller than 160 by 96 so it can say so: the inset ground, a 24px off-image glyph and a plain sentence, _Couldn't load image_ for a broken address or a non-raster answer, _Image can't be shown_ for an address Chat never loads (sign-in and sign-out paths, cross-document uploads or attachments, SVG, script), with the image's name on its line above; it is one `role="img"` named by the reason and the name (_Image can't be shown: Bar chart of weekly signups_) and has no toolbar. A tile that cannot load says the same in its own square. No image is ever said to be too large: an image too large to store or send is stored as a smaller copy of itself (see _Files and dictation in the composer_ and [Storage](../../docs/storage.md)). A turn's image that could not be stored for another reason appears inline in order as a refusal line with an off-image glyph, spanning the grid's columns when among tiles. Reference images (`![alt][name]`) resolve from their first definition, Markdown image titles become captions, and linked images lift out of their link so pressing the picture opens the viewer while an image-only link keeps an accessible name from the alt text or URL. -- **Chat files:** A file sent with a message is Slack's file card: assistant-ui's received-files tile (`MessageAttachments`, copied, with a recorded patch for a file's own icon, its type and its actions) restyled from outside (`chat/media/media.css`) to a card at most 400px wide and 64px tall, on the page's surface with a 1px hairline and 12px corners and 12px padding, stacked 8px apart. It holds a 40px rounded square in the colour of its type with the type's 24px glyph from Chopin's icons in the page's colour (PDF the danger graphic, a document petrol, a spreadsheet success green, slides warning amber, code secondary ink, text the icon grey, an archive warning ink, an image plum, a video deep plum, audio lime, anything else tertiary ink; the type is read from the name, since Chat keeps every text file as plain text), the name in bold primary ink, and under it the type in words (_PDF_, _CSV_, _TypeScript_, _Markdown_, _File_) and the size, as spaced siblings in tertiary ink. Pressing the card downloads the file under its own name. On hover or focus its own toolbar, like an image's, fades in at its end: _Download_ and _Copy link_; on a touch screen it is always there. -- **Chat image viewer:** All showable images of one message share one viewer, Slack's full-screen image viewer on Chopin's ink ground (`--color-text-primary`, its words in the page's colour): `ImageGallery`'s copied ``, with a recorded header slot, restyled to fill the window with no corners and no growing entrance. Along its top, who sent the message (their face, their name in bold), when, and the image's name, and, where its sender's browser sent a smaller copy, _Shown smaller. The original was 22.7 MB, 12000 × 8000._; at its top end _Copy image_, _Download image_ (of the stored file, never a copy) and _Close_. The image is centred between the top and the caption, drawn from the largest copy (1024px). Round translucent _Previous image_ and _Next image_ buttons sit halfway down the window's edges (at its foot on a phone), an arrow with nowhere to go not drawn, and ArrowLeft and ArrowRight step too; _Image M of N_ is said once per step. Focus is trapped and wraps, Escape or pressing the dark ground closes it, and focus returns to what opened it. Reduced motion shows it at once. -- **Image lightbox:** Document images and decision-card visuals open the editor's full-screen preview on the ink ground, with zoom and close controls at the top end and, when several images are present, a footer with Previous, alt text, caption, count and Next, focus trapping and return, and reduced-motion support. Chat images use Chat's own viewer instead (see _Chat image viewer_). +- **Chat files:** A file sent with a message is Slack's file card: assistant-ui's received-files tile (`MessageAttachments`, copied, with a recorded patch for a file's own icon, its type and its actions) restyled from outside (`chat/media/media.css`) to a card at most 400px wide and 64px tall, on the page's surface with a 1px hairline and 12px corners and 12px padding, stacked 8px apart. It holds a 40px rounded square in the colour of its type with the type's 24px glyph from Chopin's icons in the page's colour (PDF the danger graphic, a document petrol, a spreadsheet success green, slides warning amber, code secondary ink, text the icon grey, an archive warning ink, an image plum, a video deep plum, audio lime, anything else tertiary ink; the type is read from the name, since Chat keeps every text file as plain text), the name in bold primary ink, and under it the type in words (_PDF_, _CSV_, _TypeScript_, _Markdown_, _File_) and the size, as spaced siblings in tertiary ink. Pressing the card opens it in the message's viewer (see _Image viewer_), as its text. On hover or focus its own toolbar, like an image's, fades in at its end: _Download_ and _Copy link_; on a touch screen it is always there. +- **Chat image viewer:** All showable images of one message, and the files sent with it, share one viewer, in the order they are drawn (images first, then files): the editor's _Image viewer_, loaded only when one opens. Its header is who sent the message (their face and their handle as written, _chopin_ with its mark for a picture the Planner made), then in the muted line how long ago (_3 hours ago_), where (_in Weekly review_, _in a thread in Weekly review_ for a thread's messages) and the file's name, and, where its sender's browser sent a smaller copy, _Shown smaller. The original was 22.7 MB, 12000 × 8000._ The picture is drawn from the largest copy (1024px); Download saves the stored file, never a copy, under the name the message's own Download gives it. A text file shows as its text on a page-coloured sheet; Rotate and zoom are a picture's and are not drawn for a file. Copy stays on the image in the message: the viewer keeps Slack's single Download. +- **Image viewer:** Every picture opens in one viewer laid out as Slack's (`packages/editor/src/widgets/image-lightbox.tsx`, with its own stylesheet, loaded only when a picture opens): Chat images and files, document images and decision-card visuals. It fills the window over the picture itself, blurred 64px and dimmed (brightness 55% and 40% of `--color-gray-900` over it, on a `--color-gray-900` ground), so its words and controls are the page's colour whatever the picture is: the name in bold, the muted line in `--color-gray-300`, measured at 5:1 or more over a white picture's blur and 14:1 over a dark one. Along the top, who sent it (Chat) or the document's title (a document's picture has no sender) and, muted, when, where and its name; _Close_ at the top end. The picture is centred and fitted with a 32px margin (72px at the sides when there are arrows, none on a phone), never enlarged past its own pixels. Along the foot, at the start, _Rotate_ (a quarter clockwise) in its own quiet group, then _Zoom out_, the _Zoom_ slider (logarithmic, from fitted to eight times that) and _Zoom in_; at the end _Download_ alone, with nothing to its right. +, = and - zoom, 0 fits; the wheel and a trackpad's or a phone's pinch zoom about the pointer; a zoomed picture pans under a drag only as far as its edges; a double-click goes between fitted and the picture's own pixels. Several pictures or files step with round _Previous_ and _Next_ buttons on a dark ground of their own halfway down the window's sides (an arrow with nowhere to go is not drawn, and stays named and disabled) and with ArrowLeft and ArrowRight, and _2 of 5_ is said as a status at the foot. Every control is named and says its name in the shared tooltip, drawn inside the viewer; controls are 36px, 44px on a touch screen; safe areas pad every edge. Focus starts on the viewer, is trapped and wraps, Escape or pressing the ground closes it, and focus returns to what opened it. It opens with a 200ms fade as the picture settles from 96%, and closes in 120ms; reduced motion shows and hides it at once. Download reads the original as bytes (a signed-in upload or a data address saves as a file under its name); a picture on another site whose host refuses the read opens in a new tab without opener or referrer. An image that cannot load keeps its sentence in the middle. - **Decision decks:** Adjacent decisions stack as a deck. The top card is live; up to four cards behind it show as blank, inert edges, each nearer one showing more of itself. A capsule pager under the stack (previous, the top card's place, next) and Left and Right arrows page through the deck and stop at either end. Next drops the top card a short way as it fades and the card behind rises into its place with its content fading in; previous slides it back. Answered decisions settle to the back and keep an open card's full layout, with the chosen option selected. Moves animate transform and opacity, plus a clip and the leaving card's height so a taller card never covers the pager. Reduced motion crossfades without movement. Deck order is each reader's own. On desktop, Decisions shows open decisions as the same deck when two or more wait, with resolved decisions listed below in an accordion that opens by default; phones keep the single-pane list and save bar. A blank document whose Planner asks decisions opens Decisions once per visit, unless the reader is editing the document. - **Unfold:** `UnfoldSection` uses Base UI Accordion for plain disclosure; card previews use the shared Unfold primitives. Both use the Chevron icon from the icon system. Native `details` and `summary` elements are not used. A button with `aria-expanded` and `aria-controls` folds a mounted panel open through its grid row while the content fades and tips down from its top edge, with the shared ease-out: card previews take the linger duration, plain accordions the accordion duration. A second press reverses from the current frame. Closed content is inert and hidden once the fold ends. Reduced motion snaps the height and keeps a short crossfade. A card preview never replaces the card's own open action. diff --git a/apps/web/src/chat/attachments/message-files.tsx b/apps/web/src/chat/attachments/message-files.tsx index d98a6e470..cad37b0fb 100644 --- a/apps/web/src/chat/attachments/message-files.tsx +++ b/apps/web/src/chat/attachments/message-files.tsx @@ -6,17 +6,17 @@ * for one, `ImageGallery` for several, and the message's shared viewer), read at Slack's thumbnail * widths, so an image of any size shows. Other files are assistant-ui's copied `MessageAttachments` * tiles drawn as Slack's file cards (`media.css`): the type's coloured icon, the name in bold, the - * type and size under it, and a toolbar of their own on hover; pressing one downloads it under its - * own name. + * type and size under it, and a toolbar of their own on hover; pressing one opens it in the + * message's viewer, after the message's images, where Previous and Next step through all of them. */ -import { useState } from "react"; +import { useId, useLayoutEffect, useRef, useState } from "react"; import { CheckIcon, DownloadIcon, LinkIcon } from "@chopin/icons"; import { attachmentPath, formatBytes } from "@chopin/protocol/attachment"; import { MessageAttachments } from "@/components/assistant-ui/elements/message-attachment"; -import { ChatImageDocument, useImageDocument } from "../image"; +import { ChatImageDocument, useImageDocument, useImageHost } from "../image"; import { FileKindIcon } from "../media/file-icon"; import { fileType } from "../media/layout"; import { TurnImages } from "../turn-images"; @@ -86,6 +86,33 @@ export default function MessageFiles( } let documentId = useImageDocument(); + let host = useImageHost(); + let entries = useId(); + let anchor = useRef(null); + let registered = documents.map(file => file.id).join("\n"); + useLayoutEffect(() => { + let element = anchor.current; + if (!host || !element) return; + let cleanups = documents.map((file, order) => { + let type = fileType(file.name); + return host.register(`${entries}:${file.id}`, { + element, + order, + file: { + kind: "file", + name: file.name, + href: attachmentPath(channelId, file.id), + text: file.mimeType.startsWith("text/"), + type: type.label, + size: formatBytes(file.size), + icon: , + }, + }); + }); + return () => { + for (let cleanup of cleanups) cleanup(); + }; + }, [host, channelId, registered]); let content = ( <> {images.length > 0 && ( @@ -117,9 +144,11 @@ export default function MessageFiles( })} data-chat-files="" onOpen={id => { + if (host) return host.open(`${entries}:${id}`, document.activeElement as HTMLElement); let file = documents.find(candidate => candidate.id === id); if (file) downloadFile(file, attachmentPath(channelId, id)); }} + ref={anchor} role="group" /> )} diff --git a/apps/web/src/chat/image.css b/apps/web/src/chat/image.css index 6ea697781..7d20fb515 100644 --- a/apps/web/src/chat/image.css +++ b/apps/web/src/chat/image.css @@ -173,8 +173,7 @@ } } -.chat-image-actions-slot [data-slot="image-actions"], -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) [data-slot="image-actions"] { +.chat-image-actions-slot [data-slot="image-actions"] { gap: 0; padding: 2px; } @@ -186,10 +185,7 @@ outline: var(--edge-width) solid var(--color-edge); } -.chat-image-actions-slot [data-slot="image-actions"] > :is(button, a), -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - [data-slot="image-actions"] - > :is(button, a) { +.chat-image-actions-slot [data-slot="image-actions"] > :is(button, a) { inline-size: calc(var(--spacing) * 8); block-size: calc(var(--spacing) * 8); border-radius: var(--radius-sm); @@ -286,192 +282,3 @@ .chat-markdown > :is(.chat-image, [data-chat-image-gallery]):first-child { margin-block-start: 0; } - -/* - * The viewer: Slack's full-screen image viewer on Chopin's ink ground, with who sent the image and - * when along its top, the image in the middle, the arrows at the window's edges and the keys. - */ -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) { - --viewer-ink: var(--color-page); - --viewer-muted: color-mix(in oklch, var(--color-page) 70%, transparent); - --viewer-control: color-mix(in oklch, var(--color-page) 12%, transparent); - --viewer-control-hover: color-mix(in oklch, var(--color-page) 20%, transparent); - /* Slack's viewer fades in; it does not grow from the middle. */ - --tw-enter-scale: 1; - inset: 0; - translate: none; - grid-template-rows: auto minmax(0, 1fr) auto; - gap: 0; - inline-size: 100%; - max-inline-size: none; - block-size: 100dvh; - padding: 0; - padding-block-end: env(safe-area-inset-bottom, 0px); - border-radius: 0; - background: var(--color-text-primary); - box-shadow: none; - color: var(--viewer-ink); -} - -.chat-viewer-header { - display: flex; - align-items: center; - gap: calc(var(--spacing) * 3); - min-inline-size: 0; - min-block-size: calc(var(--spacing) * 16); - padding-block: calc(var(--spacing) * 3); - padding-inline: calc(env(safe-area-inset-left, 0px) + var(--spacing) * 5) - calc(env(safe-area-inset-right, 0px) + var(--spacing) * 44); - padding-block-start: calc(env(safe-area-inset-top, 0px) + var(--spacing) * 3); -} - -.chat-viewer-face { - display: flex; - flex: none; -} - -.chat-viewer-who { - display: flex; - flex-direction: column; - min-inline-size: 0; - font-size: var(--text-sm); - line-height: var(--text-sm--line-height); -} - -.chat-viewer-name { - overflow: hidden; - font-weight: 700; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-viewer-detail { - display: flex; - flex-wrap: wrap; - column-gap: calc(var(--spacing) * 2); - min-inline-size: 0; - color: var(--viewer-muted); - font-size: var(--text-xs); - line-height: var(--text-xs--line-height); -} - -.chat-viewer-note { - white-space: nowrap; -} - -.chat-viewer-file { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* The picture, between the header and the footer, with room either side for the arrows. */ -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) > [data-slot="dialog-title"] + div { - min-block-size: 0; - padding-inline: calc(var(--spacing) * 18); -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - > [data-slot="dialog-title"] - + div img { - max-block-size: 100%; -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - > [data-slot="dialog-title"] - + div > div { - block-size: 100%; - color: var(--viewer-muted); -} - -/* The footer: the caption and the place in the message, with the arrows lifted to the edges. */ -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - > [data-slot="dialog-title"] - + div + div { - justify-content: center; - padding: calc(var(--spacing) * 3) calc(var(--spacing) * 5) calc(var(--spacing) * 4); - color: var(--viewer-muted); - font-size: var(--text-xs); -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - > [data-slot="dialog-title"] - + div + div > div { - flex: 0 1 auto; -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) :is(p, span, a) { - color: inherit; -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - > [data-slot="dialog-title"] - + div + div > button:is([aria-label="Previous image"], [aria-label="Next image"]) { - position: absolute; - inset-block-start: 50%; - inline-size: calc(var(--spacing) * 10); - block-size: calc(var(--spacing) * 10); - translate: 0 -50%; - border-radius: var(--radius-full); - background: var(--viewer-control); - color: var(--viewer-ink); -} - -/* As Slack's, an arrow with nowhere to go is not drawn; it stays named, and disabled, for a reader. */ -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - > [data-slot="dialog-title"] - + div + div > button:is([aria-label="Previous image"], [aria-label="Next image"]):disabled { - opacity: 0; -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - > [data-slot="dialog-title"] - + div + div > button[aria-label="Previous image"] { - inset-inline-start: calc(env(safe-area-inset-left, 0px) + var(--spacing) * 4); -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - > [data-slot="dialog-title"] - + div + div > button[aria-label="Next image"] { - inset-inline-end: calc(env(safe-area-inset-right, 0px) + var(--spacing) * 4); -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) button:hover:not(:disabled), -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) a:hover { - background: var(--viewer-control-hover); -} - -/* Download and copy sit in the top bar beside close, where Slack's viewer keeps them. */ -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) [data-slot="image-actions"] { - position: absolute; - inset-block-start: calc(env(safe-area-inset-top, 0px) + var(--spacing) * 4); - inset-inline-end: calc(env(safe-area-inset-right, 0px) + var(--spacing) * 13); -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - [data-slot="image-actions"] - > :is(button, a) { - color: var(--viewer-ink); -} - -[data-slot="dialog-content"]:has(> [data-chat-viewer-header]) > [data-slot="dialog-close"] { - inset-block-start: calc(env(safe-area-inset-top, 0px) + var(--spacing) * 4); - inset-inline-end: calc(env(safe-area-inset-right, 0px) + var(--spacing) * 4); - inline-size: calc(var(--spacing) * 8); - block-size: calc(var(--spacing) * 8); - color: var(--viewer-ink); -} - -@media (max-width: 600px) { - [data-slot="dialog-content"]:has(> [data-chat-viewer-header]) > [data-slot="dialog-title"] + div { - padding-inline: 0; - } - - [data-slot="dialog-content"]:has(> [data-chat-viewer-header]) - > [data-slot="dialog-title"] - + div + div > button:is([aria-label="Previous image"], [aria-label="Next image"]) { - inset-block-start: auto; - inset-block-end: calc(env(safe-area-inset-bottom, 0px) + var(--spacing) * 2); - translate: none; - } -} diff --git a/apps/web/src/chat/image.tsx b/apps/web/src/chat/image.tsx index a0abb6c7e..2a668f30b 100644 --- a/apps/web/src/chat/image.tsx +++ b/apps/web/src/chat/image.tsx @@ -8,11 +8,13 @@ * runtime: measured aspect ratios hold each single image's box while pixels arrive, alt text and * captions stay on the page and available to assistive technology, external addresses are probed * without credentials or referrer before offering byte-level copy/download, and all showable images - * of one message share `ImageGallery`'s `` lightbox in document order. + * of one message, and the files sent with it, share the editor's image viewer in document order. */ import { createContext, + lazy, + Suspense, useContext, useEffect, useId, @@ -33,9 +35,8 @@ import { } from "@chopin/icons"; import { formatBytes } from "@chopin/protocol/attachment"; +import { ImagePlace } from "@chopin/editor/image-place"; import { ImageActions, ImagePreview, ImageRoot } from "@/components/assistant-ui/elements/image"; -import { ImageGallery as GalleryElement } from "@/components/assistant-ui/elements/image-gallery"; -import { hostOf } from "@/components/assistant-ui/utils/href"; import { adoptedSize, @@ -58,6 +59,7 @@ import { copyWidth, fittedBox, placeholderOf, thumbnailUrl, VIEWER_WIDTH } from import "./image.css"; import type { ComponentProps, CSSProperties, ReactNode, SyntheticEvent } from "react"; +import type { LightboxFile, LightboxItem } from "@chopin/editor/image-lightbox"; import type { ImageActionItem } from "@/components/assistant-ui/elements/image"; import type { Copied, ImageSource, Load, Settled } from "./image-state"; @@ -88,16 +90,25 @@ export type RegisteredImage = { shown: Shown; }; +/** A file sent with a message, which the viewer shows as its text or as a card. */ +export type RegisteredFile = { element: HTMLElement; order?: number; file: LightboxFile }; + +export type RegisteredEntry = RegisteredImage | RegisteredFile; + export type ImageHost = { - register(id: string, entry: RegisteredImage): () => void; + register(id: string, entry: RegisteredEntry): () => void; open(id: string, opener?: HTMLElement | null): void; }; +const ImageLightbox = lazy(() => + import("@chopin/editor/image-lightbox").then(({ ImageLightbox }) => ({ default: ImageLightbox })) +); + const HostContext = createContext(undefined); function compareEntries( - [, left]: [string, RegisteredImage], - [, right]: [string, RegisteredImage], + [, left]: [string, RegisteredEntry], + [, right]: [string, RegisteredEntry], ): number { if (left.element === right.element) return (left.order ?? 0) - (right.order ?? 0); return left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING @@ -105,35 +116,43 @@ function compareEntries( : 1; } -/** Who sent a message's images and when, for the top of the viewer, as Slack's viewer shows. */ -export type ImageSender = { name: string; face: ReactNode; time?: string }; +/** Who sent a message's images and when (in seconds), for the top of the viewer, as Slack's shows. */ +export type ImageSender = { name: string; face: ReactNode; ts?: number }; + +/** What the viewer shows for one of a message's images or files. */ +function viewed(entry: RegisteredEntry): LightboxItem { + if ("file" in entry) return entry.file; + let { data, label, shown } = entry; + return { + src: shown.source.kind === "hosted" ? thumbnailUrl(shown.url, VIEWER_WIDTH) : shown.url, + // Download saves the stored file, never the copy drawn. + original: shown.url, + alt: label, + name: label, + download: downloadName(data.alt, data.src, shown.type ?? data.mimeType), + ...(data.caption ? { caption: data.caption } : {}), + ...(data.generated ? { generated: true } : {}), + ...(data.original + ? { + note: `Shown smaller. The original was ${ + formatBytes(data.original.size) + }, ${data.original.width} × ${data.original.height}.`, + } + : {}), + }; +} export function MessageImages( { children, sender }: { children: ReactNode; sender?: ImageSender }, ) { - let entries = useRef(new Map()); + let entries = useRef(new Map()); let lastOpener = useRef(null); + let place = useContext(ImagePlace); let [view, setView] = useState<{ - activeId: string; - entries: Array<[string, RegisteredImage]>; + start: number; + entries: Array<[string, RegisteredEntry]>; }>(); - // Pressing the dark ground around the image closes the viewer, as Slack's does. - let viewing = view !== undefined; - useEffect(() => { - if (!viewing) return; - let onClick = (event: MouseEvent) => { - // Found from Chopin's own header, never by the copy's slot names, which are the copy's to set: - // the ground is the viewer itself (the header's parent) or the picture's row (two after it). - let target = event.target as Element | null; - let header = target?.querySelector(":scope > [data-chat-viewer-header]") - ?? target?.previousElementSibling?.previousElementSibling; - if (header?.hasAttribute("data-chat-viewer-header")) setView(undefined); - }; - document.addEventListener("click", onClick); - return () => document.removeEventListener("click", onClick); - }, [viewing]); - useEffect(() => { if (view !== undefined) return; let opener = lastOpener.current; @@ -149,9 +168,9 @@ export function MessageImages( open(id, opener) { let ordered = [...entries.current].sort(compareEntries); if (ordered.length === 0) return; - let found = ordered.find(([key]) => key === id)?.[0] ?? ordered[0]![0]; + let start = Math.max(ordered.findIndex(([key]) => key === id), 0); lastOpener.current = opener ?? (document.activeElement as HTMLElement | null); - setView({ activeId: found, entries: ordered }); + setView({ start, entries: ordered }); }, }), []); @@ -159,65 +178,19 @@ export function MessageImages( {children} {view && ( - { - let [, entry] = view.entries[index]!; - return ( - <> - {view.entries.length > 1 && ( - - Image {index + 1} of {view.entries.length} - - )} - - - ); - }} - dialogHeader={(_, index) => { - let [, entry] = view.entries[index]!; - return ( -
- {sender && ( - - )} - - {sender && {sender.name}} - - {sender?.time && {sender.time}} - {entry.label} - {entry.data.original && ( - - Shown smaller. The original was {formatBytes(entry.data.original.size)}, - {" "} - {entry.data.original.width} × {entry.data.original.height}. - - )} - - -
- ); - }} - hideGrid - images={view.entries.map(([key, entry]) => ({ - id: key, - src: entry.shown.source.kind === "hosted" - ? thumbnailUrl(entry.shown.url, VIEWER_WIDTH) - : entry.shown.url, - alt: entry.label, - caption: entry.data.caption || entry.label, - imgProps: { referrerPolicy: "no-referrer" }, - source: entry.data.generated - ? { label: "Generated" } - : entry.shown.source.kind === "external" - ? { label: hostOf(entry.shown.url) } - : undefined, - }))} - onActiveIdChange={nextId => { - if (nextId === null) setView(undefined); - else setView(current => current ? { ...current, activeId: nextId } : undefined); - }} - /> + + viewed(entry))} + onClose={() => setView(undefined)} + place={place} + sender={sender && { + name: sender.name, + face: sender.face, + ...(sender.ts === undefined ? {} : { at: sender.ts * 1000 }), + }} + start={view.start} + /> + )}
); diff --git a/apps/web/src/chat/threads/thread-panel.tsx b/apps/web/src/chat/threads/thread-panel.tsx index 0a2e9c7f1..abf37eae3 100644 --- a/apps/web/src/chat/threads/thread-panel.tsx +++ b/apps/web/src/chat/threads/thread-panel.tsx @@ -1,6 +1,7 @@ import { useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { PLANNER_NAME, usePopoverDismissal } from "@chopin/editor"; +import { ImagePlace } from "@chopin/editor/image-place"; import { ArrowLeftIcon, CloseIcon } from "@chopin/icons"; import { formatForkedClock, isReplyEntry, replyCountLabel } from "@chopin/protocol/threads"; @@ -455,6 +456,9 @@ export function ThreadPanel( ? repliesOffNotice(offline) : modelNotice; + // A thread's pictures are "in a thread in" the document, as Slack names a thread's files. + let outer = useContext(ImagePlace); + let place = outer && `a thread in ${outer}`; let lead = (

@@ -462,13 +466,15 @@ export function ThreadPanel(

{root && (
- + + +
)} {(replyCount > 0 || !!workingTurn) && ( @@ -523,23 +529,25 @@ export function ThreadPanel( )} - + + + {agent && !!view.runs?.length && (
diff --git a/apps/web/src/chat/transcript.tsx b/apps/web/src/chat/transcript.tsx index 5a4daffeb..289df871b 100644 --- a/apps/web/src/chat/transcript.tsx +++ b/apps/web/src/chat/transcript.tsx @@ -331,8 +331,9 @@ function MessageBody( : , diff --git a/apps/web/src/design-audit/plates/chat-media.tsx b/apps/web/src/design-audit/plates/chat-media.tsx index cf92681d6..dbef4b1d6 100644 --- a/apps/web/src/design-audit/plates/chat-media.tsx +++ b/apps/web/src/design-audit/plates/chat-media.tsx @@ -88,7 +88,7 @@ export function ChatMediaPlate() {
Image with its toolbar - + Three images, and four with the rest behind +N diff --git a/apps/web/src/icon-tooltip.tsx b/apps/web/src/icon-tooltip.tsx index b7e72cf83..da87e311f 100644 --- a/apps/web/src/icon-tooltip.tsx +++ b/apps/web/src/icon-tooltip.tsx @@ -161,6 +161,9 @@ export function IconTooltip() { let label = button.getAttribute("data-tooltip") ?? button.getAttribute("aria-label") ?? heldTitle ?? button.querySelector(".sr-only")?.textContent; if (!label) return hide(); + // A modal dialog is drawn above the page, so a tooltip for its controls is drawn inside it. + let layer = button.closest("dialog[open]") ?? document.body; + if (tooltip.parentElement !== layer) layer.append(tooltip); tooltip.textContent = tooltipText(label, button.hasAttribute("data-tooltip-verbatim")); tooltip.setAttribute("data-shortcut", button.dataset.tooltipShortcut ?? ""); let rect = button.getBoundingClientRect(); diff --git a/apps/web/src/question-markdown.tsx b/apps/web/src/question-markdown.tsx index 7a73f43d5..39f8c19f6 100644 --- a/apps/web/src/question-markdown.tsx +++ b/apps/web/src/question-markdown.tsx @@ -11,8 +11,9 @@ * own visual, which also reads tables. */ -import { lazy, Suspense, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useContext, useMemo, useRef, useState } from "react"; import { HOSTED_IMAGE_PATH } from "@chopin/dialect"; +import { ImagePlace } from "@chopin/editor/image-place"; import { QuestionMarkdownContext } from "@chopin/question/react"; import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; import { gfmTable } from "micromark-extension-gfm-table"; @@ -55,6 +56,7 @@ export function documentImageUrl(src: string | undefined, document: string): str function CardImage({ alt, document, src }: { alt: string; document: string; src?: string }) { let [failed, setFailed] = useState(false); let [viewing, setViewing] = useState(false); + let place = useContext(ImagePlace); let view = useRef(null); let url = documentImageUrl(src, document); if (!alt.trim()) return null; @@ -89,6 +91,7 @@ function CardImage({ alt, document, src }: { alt: string; document: string; src? setViewing(false); requestAnimationFrame(() => view.current?.focus()); }} + place={place} /> )} diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 7ec12f25b..29e22be1f 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { createPortal, flushSync } from "react-dom"; import { FollowLinkProvider } from "@chopin/editor/follow-link"; +import { ImagePlace } from "@chopin/editor/image-place"; import { rememberAvatars } from "@chopin/editor/people"; import { documentPath } from "@chopin/protocol/document-url"; import { @@ -2054,7 +2055,7 @@ export function RoomWorkspace( : editMode && { docked, editing: editMode === "editing", toggle: toggleEditing }; return linked( - +

@@ -2334,17 +2335,23 @@ export function RoomWorkspace( ); } -/** A decision's own page, and card text read as Markdown with this document's images. */ +/** + * A decision's own page, card text read as Markdown with this document's images, and the + * document's title, which the image viewer names as where its pictures are. + */ function DecisionContexts( - { children, document, page }: { + { children, document, page, title }: { children: ReactNode; document: string; page: QuestionPageRoot | undefined; + title: string; }, ) { return ( - {children} + + {children} + ); } diff --git a/e2e/chat-attachments.e2e.ts b/e2e/chat-attachments.e2e.ts index 179158b13..5c11f558b 100644 --- a/e2e/chat-attachments.e2e.ts +++ b/e2e/chat-attachments.e2e.ts @@ -315,17 +315,22 @@ test("sends the message with its files, and a second person reads them", async ( let figure = message.locator('[data-chat-image="single"]'); let files = message.getByRole("group", { name: "Files sent with this message" }); let image = (await figure.locator("img").getAttribute("src"))!; - // Pressing the image opens the lightbox; pressing a text file downloads it under its name. + // Pressing the image opens the viewer, and so does pressing the text file, which shows its text; + // the file's own toolbar downloads it under its name. await figure.locator("[data-chat-image-frame]").click(); - let dialog = reader.locator('[data-slot="dialog-content"]'); + let dialog = reader.locator("[data-image-viewer]"); await expect(dialog.getByRole("img", { name: "shot.png" })).toBeVisible(); - await expect(dialog.getByRole("button", { name: "Copy image" })).toBeVisible(); await expect(dialog.getByRole("button", { name: "Download image" })).toBeVisible(); + await reader.keyboard.press("ArrowRight"); + await expect(dialog.locator("[data-image-viewer-file='text']")).toHaveText( + NOTES.buffer.toString(), + ); await reader.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); + await files.getByRole("button", { name: /^notes\.md/ }).hover(); let [saved] = await Promise.all([ reader.waitForEvent("download"), - files.getByRole("button", { name: /^notes\.md/ }).click(), + files.getByRole("button", { name: "Download notes.md" }).click(), ]); expect(saved.suggestedFilename()).toBe("notes.md"); let notes = saved.url(); @@ -629,19 +634,23 @@ test("sent image attachments draw as a gallery, share the message's lightbox wit await expect(fileTiles.nth(1)).toHaveAccessibleName("Download notes.md"); await expect(fileTiles.nth(2)).toHaveAccessibleName("Copy link to notes.md"); - // Clicking the first gallery tile opens the shared message lightbox at Image 2 of 3 (after the Markdown image). + // Clicking the first gallery tile opens the shared message viewer at 2 of 4: after the Markdown + // image, and before the text file, which the viewer steps to as well. await tiles.nth(0).focus(); await page.keyboard.press("Enter"); - let dialog = page.locator('[data-slot="dialog-content"]'); - await expect(dialog.getByRole("status")).toHaveText("Image 2 of 3"); + let dialog = page.locator("[data-image-viewer]"); + await expect(dialog.getByRole("status")).toHaveText("2 of 4"); await expect(dialog.getByRole("img", { name: "shot.png" })).toBeVisible(); await page.keyboard.press("ArrowLeft"); - await expect(dialog.getByRole("status")).toHaveText("Image 1 of 3"); + await expect(dialog.getByRole("status")).toHaveText("1 of 4"); await expect(dialog.getByRole("img", { name: "inline" })).toBeVisible(); await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight"); - await expect(dialog.getByRole("status")).toHaveText("Image 3 of 3"); + await expect(dialog.getByRole("status")).toHaveText("3 of 4"); await expect(dialog.getByRole("img", { name: "second.png" })).toBeVisible(); + await page.keyboard.press("ArrowRight"); + await expect(dialog.getByRole("status")).toHaveText("4 of 4"); + await expect(dialog.locator("[data-image-viewer-file='text']")).toContainText("# Notes"); await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); await expect(tiles.nth(0)).toBeFocused(); diff --git a/e2e/chat-images-gallery.e2e.ts b/e2e/chat-images-gallery.e2e.ts index 0dd6716aa..b0f524c51 100644 --- a/e2e/chat-images-gallery.e2e.ts +++ b/e2e/chat-images-gallery.e2e.ts @@ -82,19 +82,19 @@ test("opening a gallery tile tells onOpen which tile it was, by its picture, its await expect(tiles.nth(3)).toContainText("+4"); for (let tile of await tiles.all()) await expect(tile).toHaveAttribute("data-load", "shown"); let opened = () => page.evaluate(() => (window as unknown as { __opened: number[] }).__opened); - let lightbox = page.locator('[data-slot="dialog-content"]'); + let lightbox = page.locator("[data-image-viewer]"); await tiles.nth(2).locator("img").click(); - await expect(lightbox.getByRole("status")).toHaveText("Image 3 of 8"); + await expect(lightbox.getByRole("status")).toHaveText("3 of 8"); expect(await opened()).toEqual([2]); await page.keyboard.press("ArrowRight"); - await expect(lightbox.getByRole("status")).toHaveText("Image 4 of 8"); + await expect(lightbox.getByRole("status")).toHaveText("4 of 8"); expect(await opened()).toEqual([2]); await page.keyboard.press("Escape"); await expect(lightbox).toHaveCount(0); await tiles.nth(3).click(); - await expect(lightbox.getByRole("status")).toHaveText("Image 4 of 8"); + await expect(lightbox.getByRole("status")).toHaveText("4 of 8"); expect(await opened()).toEqual([2, 3]); await page.keyboard.press("Escape"); await expect(lightbox).toHaveCount(0); @@ -102,12 +102,12 @@ test("opening a gallery tile tells onOpen which tile it was, by its picture, its let first = tiles.nth(0); await first.focus(); await page.keyboard.press("Enter"); - await expect(lightbox.getByRole("status")).toHaveText("Image 1 of 8"); + await expect(lightbox.getByRole("status")).toHaveText("1 of 8"); expect(await opened()).toEqual([2, 3, 0]); await page.keyboard.press("Escape"); await expect(lightbox).toHaveCount(0); await expect(first).toBeFocused(); await page.keyboard.press("Space"); - await expect(lightbox.getByRole("status")).toHaveText("Image 1 of 8"); + await expect(lightbox.getByRole("status")).toHaveText("1 of 8"); expect(await opened()).toEqual([2, 3, 0, 0]); }); diff --git a/e2e/chat-images.e2e.ts b/e2e/chat-images.e2e.ts index 7b5ed55aa..9832c187c 100644 --- a/e2e/chat-images.e2e.ts +++ b/e2e/chat-images.e2e.ts @@ -136,7 +136,7 @@ function settled(figure: Locator): Promise { } function lightbox(page: Page): Locator { - return page.locator('[data-slot="dialog-content"]'); + return page.locator("[data-image-viewer]"); } async function openChat(page: Page): Promise { @@ -418,10 +418,10 @@ test("more than four images show four tiles, as Slack does, and the lightbox rea let box = lightbox(page); await expect(box).toBeVisible(); await expect(box.getByRole("img", { name: "Variant 4" })).toBeVisible(); - await expect(box.getByRole("status")).toHaveText("Image 4 of 8"); + await expect(box.getByRole("status")).toHaveText("4 of 8"); for (let step = 0; step < 4; step++) await page.keyboard.press("ArrowRight"); await expect(box.getByRole("img", { name: "Variant 8" })).toBeVisible(); - await expect(box.getByRole("button", { name: "Next image" })).toBeDisabled(); + await expect(box.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); }); test("the lightbox opens, steps and closes by mouse", async ({ baseURL, join, page, room, seed }) => { @@ -439,16 +439,16 @@ test("the lightbox opens, steps and closes by mouse", async ({ baseURL, join, pa .toBeVisible(); await expect(box.getByText("Faster, and a crash can lose the last writes.")).toBeVisible(); await expect(box.getByText("Generated", { exact: true })).toBeVisible(); - await expect(box.getByRole("status")).toHaveText("Image 2 of 3"); + await expect(box.getByRole("status")).toHaveText("2 of 3"); - await box.getByRole("button", { name: "Previous image" }).click(); + await box.getByRole("button", { name: "Previous", exact: true }).click(); await expect(box.getByRole("img", { name: "The cap is a balance of 100." })).toBeVisible(); - await expect(box.getByRole("button", { name: "Previous image" })).toBeDisabled(); - await box.getByRole("button", { name: "Next image" }).click(); - await box.getByRole("button", { name: "Next image" }).click(); + await expect(box.getByRole("button", { name: "Previous", exact: true })).toBeDisabled(); + await box.getByRole("button", { name: "Next", exact: true }).click(); + await box.getByRole("button", { name: "Next", exact: true }).click(); await expect(box.getByRole("img", { name: "Write-through: write, cache, database" })) .toBeVisible(); - await expect(box.getByRole("button", { name: "Next image" })).toBeDisabled(); + await expect(box.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); await box.getByRole("button", { name: "Close", exact: true }).click(); await expect(box).toHaveCount(0); @@ -473,20 +473,19 @@ test("the lightbox opens, steps, traps focus and gives it back by keyboard", asy await page.keyboard.press("Enter"); let box = lightbox(page); await expect(box).toBeVisible(); - await expect(box.getByRole("status")).toHaveText("Image 2 of 3"); + await expect(box.getByRole("status")).toHaveText("2 of 3"); await page.keyboard.press("ArrowRight"); - await expect(box.getByRole("status")).toHaveText("Image 3 of 3"); + await expect(box.getByRole("status")).toHaveText("3 of 3"); await page.keyboard.press("ArrowRight"); - await expect(box.getByRole("status")).toHaveText("Image 3 of 3"); + await expect(box.getByRole("status")).toHaveText("3 of 3"); await page.keyboard.press("ArrowLeft"); await page.keyboard.press("ArrowLeft"); - await expect(box.getByRole("status")).toHaveText("Image 1 of 3"); + await expect(box.getByRole("status")).toHaveText("1 of 3"); await page.keyboard.press("ArrowLeft"); - await expect(box.getByRole("status")).toHaveText("Image 1 of 3"); + await expect(box.getByRole("status")).toHaveText("1 of 3"); - let inside = () => - page.evaluate(() => !!document.activeElement?.closest('[data-slot="dialog-content"]')); + let inside = () => page.evaluate(() => !!document.activeElement?.closest("[data-image-viewer]")); for (let step = 0; step < 7; step++) { await page.keyboard.press("Tab"); await expect.poll(inside).toBe(true); @@ -535,7 +534,7 @@ test("an image can be downloaded under the name of what it shows, and copied", a await expect(lightbox(page)).toHaveCount(0); }); -test("the lightbox carries open, copy and download for what it shows", async ({ baseURL, join, page, room, seed }) => { +test("the lightbox downloads what it shows, from Download alone at its bottom end", async ({ baseURL, join, page, room, seed }) => { await page.setViewportSize({ width: 1280, height: 900 }); let stored = await store(baseURL, room); await seed("# Out\n", { transcript: turn(three(stored)) }); @@ -550,8 +549,10 @@ test("the lightbox carries open, copy and download for what it shows", async ({ box.getByRole("button", { name: "Download image" }).click(), ]); expect(download.suggestedFilename()).toBe("the-cap-is-a-balance-of-100.webp"); - await box.getByRole("button", { name: "Next image" }).click(); - await expect(box.getByRole("button", { name: "Copy image" })).toBeVisible(); + await box.getByRole("button", { name: "Next", exact: true }).click(); + await expect(box.getByRole("button", { name: "Download image" })).toBeVisible(); + // Copy stays on the image in the message; the viewer keeps Slack's single Download. + await expect(box.getByRole("button", { name: "Copy image" })).toHaveCount(0); await page.keyboard.press("Escape"); await expect(box).toHaveCount(0); }); @@ -669,27 +670,26 @@ test("an image from another site can be opened, copied and saved where its host await closed.getByRole("button", { name: "Open image", exact: true }).click(); let box = lightbox(page); - await expect(box.getByRole("status")).toHaveText("Image 2 of 2"); - await expect(box.getByRole("button", { name: "Copy link" })).toBeVisible(); - await expect(box.getByRole("link", { name: "Open image", exact: true })).toHaveAttribute( - "href", - near, - ); - await expect(box.getByRole("link", { name: "Open image in a new tab" })).toHaveAttribute( - "href", - near, + await expect(box.getByRole("status")).toHaveText("2 of 2"); + // Its host refuses the read, so Download opens it in a new tab, without opener or referrer. + await expect(box.getByRole("link")).toHaveCount(0); + let [opened] = await Promise.all([ + page.waitForEvent("popup"), + box.getByRole("button", { name: "Download image" }).click(), + ]); + await opened.waitForLoadState(); + expect(opened.url()).toBe(near); + expect(await opened.evaluate(() => window.opener)).toBeNull(); + await opened.close(); + await expect(box.locator("[data-image-viewer-said]")).toHaveText( + "Opened the image in a new tab.", ); await page.keyboard.press("Escape"); await expect(box).toHaveCount(0); await flaky.getByRole("button", { name: "Open image", exact: true }).click(); - for (let name of ["Copy image", "Download image"]) { - await expect(box.getByRole("button", { name })).toBeVisible(); - } - await expect(box.getByRole("link", { name: "Open image in a new tab" })).toHaveAttribute( - "href", - "https://example.invalid/flaky/reach.png", - ); + await expect(box.getByRole("button", { name: "Download image" })).toBeVisible(); + await expect(box.getByRole("link")).toHaveCount(0); }); test("an image from another site offers its link while the page learns whether it can read it, and copy and download once it can", async ({ baseURL, join, page, seed }) => { @@ -854,12 +854,9 @@ test("every image a person writes in Markdown is drawn where they wrote it", asy await all.nth(1).getByRole("button", { name: "Open image", exact: true }).click(); let box = lightbox(page); - await expect(box.getByRole("status")).toHaveText("Image 2 of 4"); + await expect(box.getByRole("status")).toHaveText("2 of 4"); await expect(box.getByRole("img", { name: "a remote one" })).toBeVisible(); - for (let name of ["Copy image", "Download image"]) { - await expect(box.getByRole("button", { name })).toHaveCount(1); - } - await expect(box.getByRole("link", { name: "Open image in a new tab" })).toHaveCount(1); + await expect(box.getByRole("button", { name: "Download image" })).toHaveCount(1); await page.keyboard.press("Escape"); await expect(box).toHaveCount(0); for (let name of ["Copy image", "Download image"]) { @@ -974,26 +971,26 @@ test("images in paragraphs of their own are one gallery, and the message's light let box = lightbox(page); await expect(box).toBeVisible(); await expect(box.getByRole("img", { name: "Write-back" })).toBeVisible(); - await expect(box.getByRole("status")).toHaveText("Image 2 of 4"); - await box.getByRole("button", { name: "Next image" }).click(); + await expect(box.getByRole("status")).toHaveText("2 of 4"); + await box.getByRole("button", { name: "Next", exact: true }).click(); await expect(box.getByRole("img", { name: "A dot" })).toBeVisible(); - await box.getByRole("button", { name: "Next image" }).click(); + await box.getByRole("button", { name: "Next", exact: true }).click(); await expect(box.getByRole("img", { name: "Apart" })).toBeVisible(); - await expect(box.getByRole("status")).toHaveText("Image 4 of 4"); - await expect(box.getByRole("button", { name: "Next image" })).toBeDisabled(); - await box.getByRole("button", { name: "Previous image" }).click(); - await box.getByRole("button", { name: "Previous image" }).click(); - await box.getByRole("button", { name: "Previous image" }).click(); + await expect(box.getByRole("status")).toHaveText("4 of 4"); + await expect(box.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); + await box.getByRole("button", { name: "Previous", exact: true }).click(); + await box.getByRole("button", { name: "Previous", exact: true }).click(); + await box.getByRole("button", { name: "Previous", exact: true }).click(); await expect(box.getByRole("img", { name: "The cap" })).toBeVisible(); - await expect(box.getByRole("button", { name: "Previous image" })).toBeDisabled(); + await expect(box.getByRole("button", { name: "Previous", exact: true })).toBeDisabled(); await box.getByRole("button", { name: "Close", exact: true }).click(); await expect(box).toHaveCount(0); let apart = chat(page).locator('[data-chat-image="single"]').nth(0); // It is drawn at its own 20 by 30, never larger, so it is pressed in its middle. await apart.locator("[data-chat-image-frame]").click(); - await expect(box.getByRole("status")).toHaveText("Image 4 of 4"); - await box.getByRole("button", { name: "Previous image" }).click(); + await expect(box.getByRole("status")).toHaveText("4 of 4"); + await box.getByRole("button", { name: "Previous", exact: true }).click(); await expect(box.getByRole("img", { name: "A dot" })).toBeVisible(); // Pressing the dark ground around the image closes it, as Slack's viewer does. await lightbox(page).click({ position: { x: 6, y: 300 } }); @@ -1039,23 +1036,23 @@ test("the lightbox of a Markdown gallery opens, steps, traps focus and gives it await page.keyboard.press("Enter"); let box = lightbox(page); await expect(box).toBeVisible(); - await expect(box.getByRole("status")).toHaveText("Image 2 of 3"); + await expect(box.getByRole("status")).toHaveText("2 of 3"); await expect(box.getByRole("img", { name: names[1]! })).toBeVisible(); await page.keyboard.press("ArrowRight"); - await expect(box.getByRole("status")).toHaveText("Image 3 of 3"); + await expect(box.getByRole("status")).toHaveText("3 of 3"); await expect(box.getByRole("img", { name: names[2]! })).toBeVisible(); await page.keyboard.press("ArrowRight"); - await expect(box.getByRole("status")).toHaveText("Image 3 of 3"); + await expect(box.getByRole("status")).toHaveText("3 of 3"); await page.keyboard.press("ArrowLeft"); await page.keyboard.press("ArrowLeft"); - await expect(box.getByRole("status")).toHaveText("Image 1 of 3"); + await expect(box.getByRole("status")).toHaveText("1 of 3"); await expect(box.getByRole("img", { name: names[0]! })).toBeVisible(); await page.keyboard.press("ArrowLeft"); - await expect(box.getByRole("status")).toHaveText("Image 1 of 3"); + await expect(box.getByRole("status")).toHaveText("1 of 3"); let inside = () => - page.evaluate(() => !!document.activeElement?.closest('[data-slot="dialog-content"]')); + page.evaluate(() => !!document.activeElement?.closest("[data-image-viewer]")); for (let step = 0; step < 7; step++) { await page.keyboard.press("Tab"); await expect.poll(inside, { message: `${names[0]} Tab ${step}` }).toBe(true); @@ -1100,8 +1097,8 @@ test("on a phone a Markdown gallery stays inside the chat, and a tap opens the m await tiles.nth(1).tap({ position: { x: 20, y: 40 } }); let box = lightbox(phone); await expect(box).toBeVisible(); - await expect(box.getByRole("status")).toHaveText("Image 2 of 3"); - await box.getByRole("button", { name: "Next image" }).tap(); + await expect(box.getByRole("status")).toHaveText("2 of 3"); + await box.getByRole("button", { name: "Next", exact: true }).tap(); await expect(box.getByRole("img", { name: "A dot" })).toBeVisible(); await box.getByRole("button", { name: "Close", exact: true }).tap(); await expect(box).toHaveCount(0); @@ -1194,11 +1191,9 @@ test("an image on this site that is not an upload is drawn, read without the rea await all.nth(2).getByRole("button", { name: "Open image", exact: true }).click(); let box = lightbox(page); - await expect(box.getByRole("status")).toHaveText("Image 3 of 4"); + await expect(box.getByRole("status")).toHaveText("3 of 4"); await expect(box.getByRole("img", { name: "a dot path" })).toBeVisible(); - for (let name of ["Copy image", "Download image"]) { - await expect(box.getByRole("button", { name })).toBeVisible(); - } + await expect(box.getByRole("button", { name: "Download image" })).toBeVisible(); await page.keyboard.press("ArrowRight"); await expect(box.getByRole("img", { name: "absolute" })).toBeVisible(); await page.keyboard.press("Escape"); @@ -2315,22 +2310,18 @@ test("on a phone the actions are always there, nothing overflows, and the lightb let box = lightbox(phone); await expect(box).toBeVisible(); await settled(box); - await expect(box.getByRole("button", { name: "Copy image" })).toBeVisible(); await expect(box.getByRole("button", { name: "Download image" })).toBeVisible(); let dialogBox = (await box.boundingBox())!; expect(dialogBox.x).toBeGreaterThanOrEqual(0); expect(dialogBox.x + dialogBox.width).toBeLessThanOrEqual(390); expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(844); - for (let name of ["Previous image", "Next image", "Close"]) { - let button = box.getByRole("button", { name, exact: true }); - let target = (await button.boundingBox())!; - expect(target.width).toBeGreaterThanOrEqual(28); - expect( - await button.evaluate(node => parseFloat(getComputedStyle(node, "::after").width)), - ).toBeGreaterThanOrEqual(44); + for (let name of ["Previous", "Next", "Close"]) { + let target = (await box.getByRole("button", { name, exact: true }).boundingBox())!; + expect(target.width).toBeGreaterThanOrEqual(44); + expect(target.height).toBeGreaterThanOrEqual(44); } - await box.getByRole("button", { name: "Next image" }).tap(); - await expect(box.getByRole("status")).toHaveText("Image 2 of 3"); + await box.getByRole("button", { name: "Next", exact: true }).tap(); + await expect(box.getByRole("status")).toHaveText("2 of 3"); await box.getByRole("button", { name: "Close", exact: true }).tap(); await expect(box).toHaveCount(0); @@ -2397,7 +2388,7 @@ test("the images in Chat, and the open lightbox, pass an accessibility check", a await figures(page).nth(1).click(); await expect(lightbox(page)).toBeVisible(); - let open = await new AxeBuilder({ page }).include('[data-slot="dialog-content"]').analyze(); + let open = await new AxeBuilder({ page }).include("[data-image-viewer]").analyze(); expect( open.violations.map(item => `${item.id}: ${item.nodes.map(node => node.target).join(" ")}`), ) @@ -2467,7 +2458,7 @@ test("an image of a turn that was not shown is said so in the message, where it await expect(message).toContainText("Here is what I drew."); await message.locator('[data-chat-image="tile"]').first().click(); - await expect(lightbox(page).getByRole("status")).toHaveText("Image 1 of 3"); + await expect(lightbox(page).getByRole("status")).toHaveText("1 of 3"); }); test("two images with a refusal between them are still one gallery in the order they came, and one lightbox", async ({ baseURL, join, page, room, seed }) => { @@ -2501,9 +2492,9 @@ test("two images with a refusal between them are still one gallery in the order await tiles.first().click(); let box = lightbox(page); - await expect(box.getByRole("status")).toHaveText("Image 1 of 2"); + await expect(box.getByRole("status")).toHaveText("1 of 2"); await page.keyboard.press("ArrowRight"); - await expect(box.getByRole("status")).toHaveText("Image 2 of 2"); + await expect(box.getByRole("status")).toHaveText("2 of 2"); await page.keyboard.press("Escape"); await expect(box).toHaveCount(0); }); @@ -2819,11 +2810,11 @@ test("images written as references and in line are one compact gallery in the or await tiles.nth(1).click(); let box = lightbox(page); - await expect(box.getByRole("status")).toHaveText("Image 2 of 3"); + await expect(box.getByRole("status")).toHaveText("2 of 3"); await expect(box.getByRole("img", { name: "Two", exact: true })).toBeVisible(); await expect(box.getByText("Second title", { exact: true })).toBeVisible(); await page.keyboard.press("ArrowRight"); - await expect(box.getByRole("status")).toHaveText("Image 3 of 3"); + await expect(box.getByRole("status")).toHaveText("3 of 3"); await expect(box.getByText("Third title", { exact: true })).toBeVisible(); await page.keyboard.press("Escape"); await expect(box).toHaveCount(0); diff --git a/e2e/chat-media.e2e.ts b/e2e/chat-media.e2e.ts index 806fad3ba..e45738d84 100644 --- a/e2e/chat-media.e2e.ts +++ b/e2e/chat-media.e2e.ts @@ -75,7 +75,7 @@ function message(page: Page, id: string): Locator { } function viewer(page: Page): Locator { - return page.locator('[data-slot="dialog-content"]'); + return page.locator("[data-image-viewer]"); } async function openChat(page: Page): Promise { @@ -221,16 +221,16 @@ test("several images are Slack's grid: two side by side, three as one tall besid await expect(six.first()).toHaveCSS("border-radius", "8px"); }); -test("the viewer is Slack's: full screen, who sent it and when along the top, the arrows and the keys, and focus back where it was", async ({ baseURL, join, room, seed }) => { +test("the viewer is Slack's: the picture blurred behind it, who sent it, how long ago, where and its name along the top, rotate and zoom at the bottom start, Download alone at the bottom end", async ({ baseURL, join, room, seed }) => { let stored = await pictures(baseURL, room, 3); - await seed("# Viewer\n", { + await seed("# Weekly review\n", { transcript: [{ id: "sent", - author: { kind: "member", handle: "bo" }, + author: { kind: "member", handle: "Bo_Dev-42" }, text: `Three of them ![First](${stored[0]!.src}) ![Second](${stored[1]!.src}) ![Third](${ stored[2]!.src })`, - ts: 1_700_000_000_000, + ts: Math.floor(Date.now() / 1000) - 3 * 3600, }], }); let page = await join("ana"); @@ -241,40 +241,82 @@ test("the viewer is Slack's: full screen, who sent it and when along the top, th await expect(tiles).toHaveCount(3); await tiles.nth(1).click(); let open = viewer(page); - await expect(open.getByRole("status")).toHaveText("Image 2 of 3"); + await expect(open.getByRole("status")).toHaveText("2 of 3"); await expect.poll(async () => await open.boundingBox()).toEqual({ x: 0, y: 0, width: 1440, height: 900, }); - await expect(open).toHaveCSS("border-radius", "0px"); - let header = open.locator("[data-chat-viewer-header]"); - await expect(header.locator(".chat-viewer-name")).toHaveText("bo"); - await expect(header.locator(".chat-viewer-file")).toHaveText("Second"); - // Download and close are along the top, as Slack keeps them. - for (let name of ["Download image", "Close"]) { - let control = (await open.getByRole("button", { name, exact: true }).boundingBox())!; - expect(control.y).toBeLessThan(40); - expect(control.x).toBeGreaterThan(1200); + // Behind it, the picture itself, blurred and dimmed, fills the window. + let backdrop = open.locator(".image-viewer-backdrop img"); + await expect(backdrop).toHaveAttribute("src", /&w=1024$/); + await expect(backdrop).toHaveCSS("filter", /blur\(64px\) brightness\(0\.55\)/); + // The sender as written, then when, where and which file. + await expect(open.locator(".image-viewer-name")).toHaveText("Bo_Dev-42"); + await expect(open.locator(".image-viewer-detail-text")).toHaveText( + "3 hours ago in Weekly review – Second", + ); + await expect(open.locator(".image-viewer-face")).toBeVisible(); + let close = (await open.getByRole("button", { name: "Close", exact: true }).boundingBox())!; + expect(close.y).toBeLessThan(40); + expect(close.x + close.width).toBeGreaterThan(1400); + + // Along the foot: rotate, then zoom out, the slider and zoom in at the start, Download at the end, + // and nothing to the right of Download. + let rotate = (await open.getByRole("button", { name: "Rotate" }).boundingBox())!; + let out = (await open.getByRole("button", { name: "Zoom out" }).boundingBox())!; + let slider = (await open.getByRole("slider", { name: "Zoom" }).boundingBox())!; + let zoomIn = (await open.getByRole("button", { name: "Zoom in" }).boundingBox())!; + let download = (await open.getByRole("button", { name: "Download image" }).boundingBox())!; + expect(rotate.x).toBeLessThan(40); + expect(rotate.x < out.x && out.x < slider.x && slider.x < zoomIn.x).toBe(true); + expect(zoomIn.x + zoomIn.width).toBeLessThan(400); + for (let control of [rotate, out, slider, zoomIn, download]) { + expect(control.y).toBeGreaterThan(820); } + expect(download.x + download.width).toBeGreaterThan(1400); + // Nothing the viewer draws in Download's row lies to its right. + let beside = await open.evaluate( + (element, top) => + [...element.querySelectorAll("button, input, a")].filter(control => { + let box = control.getBoundingClientRect(); + return box.bottom > top && control.getAttribute("aria-label") !== "Download image"; + }).map(control => control.getBoundingClientRect().right), + download.y, + ); + expect(Math.max(...beside)).toBeLessThan(download.x); + let footer = await open.locator("footer").evaluate(element => + [...element.querySelectorAll("button, input, a")].map(control => + control.getAttribute("aria-label") + ) + ); + expect(footer).toEqual(["Rotate", "Zoom out", "Zoom", "Zoom in", "Download image"]); + // The arrows are at the window's edges, halfway down. - let previous = (await open.getByRole("button", { name: "Previous image" }).boundingBox())!; - let next = (await open.getByRole("button", { name: "Next image" }).boundingBox())!; + let previous = (await open.getByRole("button", { name: "Previous", exact: true }).boundingBox())!; + let next = (await open.getByRole("button", { name: "Next", exact: true }).boundingBox())!; expect(previous.x).toBeLessThan(40); expect(next.x + next.width).toBeGreaterThan(1400); expect(previous.y + previous.height / 2).toBeCloseTo(450, -1); - // It draws a larger copy than the message did. - await expect(open.getByRole("img", { name: "Second" })).toHaveAttribute("src", /&w=1024$/); + // It draws a larger copy than the message did, fitted inside the window with a margin. + let picture = open.getByRole("img", { name: "Second" }); + await expect(picture).toHaveAttribute("src", /&w=1024$/); + await expect.poll(async () => (await picture.boundingBox())?.width ?? 0).toBeGreaterThan(500); + let shown = (await picture.boundingBox())!; + expect(shown.x).toBeGreaterThan(previous.x + previous.width); + expect(shown.x + shown.width).toBeLessThan(next.x); await page.keyboard.press("ArrowRight"); - await expect(open.getByRole("status")).toHaveText("Image 3 of 3"); - await expect(header.locator(".chat-viewer-file")).toHaveText("Third"); - await open.getByRole("button", { name: "Previous image" }).click(); - await expect(open.getByRole("status")).toHaveText("Image 2 of 3"); - // Tab stays inside it. - // Tab stays inside it, wrapping through its focus guards back to its first control. - for (let step = 0; step < 8; step++) { + await expect(open.getByRole("status")).toHaveText("3 of 3"); + await expect(open.locator(".image-viewer-detail-text")).toHaveText( + "3 hours ago in Weekly review – Third", + ); + await expect(open.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); + await open.getByRole("button", { name: "Previous", exact: true }).click(); + await expect(open.getByRole("status")).toHaveText("2 of 3"); + // Tab stays inside it, wrapping back to its first control. + for (let step = 0; step < 10; step++) { await page.keyboard.press("Tab"); await expect.poll(() => open.evaluate(element => element.contains(document.activeElement))) .toBe(true); @@ -284,6 +326,232 @@ test("the viewer is Slack's: full screen, who sent it and when along the top, th await expect(tiles.nth(1)).toBeFocused(); }); +test("every control of the viewer is named and says its name on hover", async ({ baseURL, join, room, seed }) => { + let stored = await pictures(baseURL, room, 2); + await seed("# Named\n", { transcript: [agent("two", stored)] }); + let page = await join("ana"); + await page.setViewportSize({ width: 1280, height: 900 }); + await openChat(page); + await message(page, "two").locator('[data-chat-image="tile"]').first().click(); + let open = viewer(page); + await expect(open.getByRole("img", { name: "Picture 1" })).toBeVisible(); + // A Planner's picture is chopin's. + await expect(open.locator(".image-viewer-name")).toHaveText("chopin"); + let tooltip = page.locator("[data-icon-tooltip]"); + for ( + let [name, said] of [ + ["Close", "Close"], + ["Next", "Next"], + ["Rotate", "Rotate"], + ["Zoom in", "Zoom in"], + ["Download image", "Download image"], + ] + ) { + await open.getByRole("button", { name: name!, exact: true }).hover(); + await expect(tooltip, name).toHaveText(said!); + await expect(tooltip, name).toBeVisible(); + // Drawn inside the viewer, above it, not under it on the page. + expect(await tooltip.evaluate(element => !!element.closest("[data-image-viewer]"))).toBe(true); + } + await open.getByRole("slider", { name: "Zoom" }).hover(); + await expect(tooltip).toHaveText("Zoom"); + await expect(tooltip).toBeVisible(); +}); + +test("a picture turns a quarter at a time, and zooms by the slider, −, +, the keys, the wheel and a double-click, then pans under a drag", async ({ baseURL, join, room, seed }) => { + let stored = await pictures(baseURL, room, 1); + await seed("# Zoom\n", { transcript: [agent("one", stored)] }); + let page = await join("ana"); + await page.setViewportSize({ width: 1280, height: 900 }); + await openChat(page); + await message(page, "one").locator("[data-chat-image-frame]").click(); + let open = viewer(page); + let picture = open.getByRole("img", { name: "Picture 1" }); + await expect(picture).toHaveAttribute("data-shown", ""); + // Its box while zoom and turn ease to where they go, so each is read once it is there. + let size = async () => { + let box = (await picture.boundingBox())!; + return { width: Math.round(box.width), height: Math.round(box.height) }; + }; + let settle = async () => { + let last = ""; + await expect.poll(async () => { + let now = JSON.stringify(await size()); + let same = now === last; + last = now; + return same; + }).toBe(true); + }; + // 600 by 400 fits whole: it is not enlarged, and the slider is at its start. + await expect.poll(size).toEqual({ width: 600, height: 400 }); + let slider = open.getByRole("slider", { name: "Zoom" }); + await expect(slider).toHaveValue("0"); + await expect(slider).toHaveAttribute("aria-valuetext", "100%"); + await expect(open.getByRole("button", { name: "Zoom out" })).toBeDisabled(); + + // Rotate turns it a quarter clockwise: it stands 400 wide and 600 tall. + await open.getByRole("button", { name: "Rotate" }).click(); + await expect(picture).toHaveAttribute("data-rotation", "90"); + await expect.poll(size).toEqual({ width: 400, height: 600 }); + for (let turn of ["180", "270", "0"]) { + await open.getByRole("button", { name: "Rotate" }).click(); + await expect(picture).toHaveAttribute("data-rotation", turn); + } + await expect.poll(size).toEqual({ width: 600, height: 400 }); + + // + zooms in an eighth of the slider; − back. + await open.getByRole("button", { name: "Zoom in" }).click(); + await expect(slider).toHaveValue("13"); + await expect.poll(async () => (await size()).width).toBeGreaterThan(600); + await open.getByRole("button", { name: "Zoom out" }).click(); + await expect(slider).toHaveValue("0"); + // The keys: + and =, then 0 back to fitted. + await page.keyboard.press("+"); + await page.keyboard.press("="); + await expect(slider).toHaveValue("25"); + await page.keyboard.press("0"); + await expect(slider).toHaveValue("0"); + // The slider itself. + await slider.fill("100"); + await expect(slider).toHaveAttribute("aria-valuetext", "800%"); + await expect.poll(size).toEqual({ width: 4800, height: 3200 }); + await slider.fill("0"); + await expect.poll(size).toEqual({ width: 600, height: 400 }); + // The wheel zooms where the pointer is. + let box = (await open.locator(".image-viewer-stage").boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, -400); + await expect.poll(async () => Number(await slider.inputValue())).toBeGreaterThan(0); + await page.keyboard.press("0"); + // A double-click on the picture toggles fitted and twice its own size. + await picture.dblclick(); + await expect(slider).toHaveValue("33"); + await expect.poll(size).toEqual({ width: 1200, height: 800 }); + // Twice its size it still fits across, so a drag cannot move it sideways. + let centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + let drag = async (dx: number, dy: number) => { + await page.mouse.move(centre.x, centre.y); + await page.mouse.down(); + await page.mouse.move(centre.x + dx, centre.y + dy, { steps: 6 }); + await page.mouse.up(); + }; + let before = (await picture.boundingBox())!; + await drag(120, 0); + expect(Math.round((await picture.boundingBox())!.x)).toBe(Math.round(before.x)); + // At its most, a drag moves it as far as the pointer went, and does not close the viewer. + await slider.fill("100"); + await expect.poll(size).toEqual({ width: 4800, height: 3200 }); + await settle(); + before = (await picture.boundingBox())!; + await drag(120, 60); + await expect(open).toBeVisible(); + let after = (await picture.boundingBox())!; + expect(Math.round(after.x - before.x)).toBe(120); + expect(Math.round(after.y - before.y)).toBe(60); + await page.keyboard.press("0"); + await expect.poll(size).toEqual({ width: 600, height: 400 }); + await picture.dblclick(); + await expect(slider).toHaveValue("33"); + await picture.dblclick(); + await expect(slider).toHaveValue("0"); +}); + +test("Download saves the stored original under its name, and for a file too", async ({ baseURL, join, room, seed }) => { + let stored = await pictures(baseURL, room, 1); + await seed("# Save\n", { transcript: [agent("one", stored)] }); + let page = await join("ana"); + await page.setViewportSize({ width: 1280, height: 900 }); + await openChat(page); + await message(page, "one").locator("[data-chat-image-frame]").click(); + let open = viewer(page); + let [saved] = await Promise.all([ + page.waitForEvent("download"), + open.getByRole("button", { name: "Download image" }).click(), + ]); + expect(saved.suggestedFilename()).toBe("picture-1.png"); + let original = await page.request.get(stored[0]!.src); + expect(Buffer.compare( + await original.body(), + await (await saved.createReadStream()).toArray().then(parts => Buffer.concat(parts)), + )).toBe(0); +}); + +test("a message's images and files are one viewer: Previous and Next and the arrow keys step through all of them, a text file is shown as its text, and Download saves it", async ({ join }) => { + let page = await join("ana"); + await page.setViewportSize({ width: 1440, height: 900 }); + await openChat(page); + let csv = { + name: "latency-by-day.csv", + mimeType: "text/csv", + buffer: Buffer.from("day,p95\nmon,40\n"), + }; + let second = { + name: "second.png", + mimeType: "image/png", + buffer: png(40, 30, (x, y) => [x * 6, y * 8, 200]), + }; + await choose( + page, + { name: "first.png", mimeType: "image/png", buffer: png(60, 40) }, + second, + NOTES, + csv, + ); + for (let name of ["first.png", "second.png", "notes.md", "latency-by-day.csv"]) { + await expect(chip(page, name)).toHaveAttribute("data-state", "done"); + } + let input = chatInput(chat(page)); + await fillChat(input, "Pictures and the numbers"); + await input.press("Enter"); + let sent = chat(page).locator("[data-chat-message-id]").filter({ + hasText: "Pictures and the numbers", + }); + let tiles = sent.locator('[data-chat-image="tile"]'); + await expect(tiles).toHaveCount(2); + await expect(tiles.nth(1)).toHaveAttribute("data-load", "shown"); + let files = sent.getByRole("group", { name: "Files sent with this message" }); + let notes = files.getByRole("button", { name: /^notes\.md/ }); + + // Pressing a file opens it where it is among the message's four, as its text. + await notes.click(); + let open = viewer(page); + await expect(open.getByRole("status")).toHaveText("3 of 4"); + await expect(open.locator(".image-viewer-detail-text")).toHaveText(/ – notes\.md$/); + await expect(open.locator("[data-image-viewer-file='text']")).toContainText("# Notes"); + // Rotate and zoom are a picture's: a file has none, and Download stays alone at the end. + for (let name of ["Rotate", "Zoom in", "Zoom out"]) { + await expect(open.getByRole("button", { name })).toHaveCount(0); + } + await expect(open.getByRole("slider")).toHaveCount(0); + let [saved] = await Promise.all([ + page.waitForEvent("download"), + open.getByRole("button", { name: "Download file" }).click(), + ]); + expect(saved.suggestedFilename()).toBe("notes.md"); + expect( + await (await saved.createReadStream()).toArray().then(parts => Buffer.concat(parts).toString()), + ).toBe(NOTES.buffer.toString()); + + // The keys and the arrows step through all of them, images first as they are drawn, and stop at the ends. + await page.keyboard.press("ArrowRight"); + await expect(open.getByRole("status")).toHaveText("4 of 4"); + await expect(open.locator("[data-image-viewer-file='text']")).toContainText("mon,40"); + await expect(open.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); + await page.keyboard.press("ArrowRight"); + await expect(open.getByRole("status")).toHaveText("4 of 4"); + await open.getByRole("button", { name: "Previous", exact: true }).click(); + await open.getByRole("button", { name: "Previous", exact: true }).click(); + await expect(open.getByRole("status")).toHaveText("2 of 4"); + await expect(open.getByRole("img", { name: "second.png" })).toBeVisible(); + await expect(open.getByRole("button", { name: "Rotate" })).toBeVisible(); + await page.keyboard.press("ArrowLeft"); + await expect(open.getByRole("status")).toHaveText("1 of 4"); + await expect(open.getByRole("button", { name: "Previous", exact: true })).toBeDisabled(); + await page.keyboard.press("Escape"); + await expect(open).toHaveCount(0); + await expect(notes).toBeFocused(); +}); + test("an image that cannot load is Slack's placeholder in the box the image would have taken, with what happened and its name", async ({ baseURL, join, page, room, seed }) => { let [picture] = await pictures(baseURL, room, 1); let release!: () => void; @@ -488,7 +756,7 @@ test("a 20 MB photo 12000 pixels wide is sent and shown from its thumbnails, nev await expect(large).toBeVisible(); await expect.poll(() => large.evaluate(element => (element as HTMLImageElement).naturalWidth)) .toBeGreaterThan(shown.width); - await expect(open.locator(".chat-viewer-note")).toHaveText( + await expect(open.locator(".image-viewer-note")).toHaveText( /^Shown smaller\. The original was \d+(\.\d)? MB, 12000 × 8000\.$/, ); let [saved] = await Promise.all([ @@ -527,16 +795,59 @@ test("on a phone the images, the grid, the files and the viewer fit the screen", await message(page, "grid").locator('[data-chat-image="tile"]').first().tap(); let open = viewer(page); - await expect(open.getByRole("status")).toHaveText("Image 1 of 3"); + await expect(open.getByRole("status")).toHaveText("1 of 3"); await expect.poll(async () => { let box = (await open.boundingBox())!; return [box.width, box.height]; }).toEqual([390, 844]); - let next = (await open.getByRole("button", { name: "Next image" }).boundingBox())!; - expect(next.x + next.width).toBeLessThanOrEqual(390); - expect(next.y + next.height).toBeLessThanOrEqual(844); - await open.getByRole("button", { name: "Next image" }).tap(); - await expect(open.getByRole("status")).toHaveText("Image 2 of 3"); + // Every control is on the screen and a finger's size, Download still alone at the bottom end. + for ( + let name of ["Close", "Previous", "Next", "Rotate", "Zoom out", "Zoom in", "Download image"] + ) { + let control = (await open.getByRole("button", { name, exact: true }).boundingBox())!; + expect(control.x, name).toBeGreaterThanOrEqual(0); + expect(control.x + control.width, name).toBeLessThanOrEqual(390); + expect(control.y + control.height, name).toBeLessThanOrEqual(844); + expect(control.width, name).toBeGreaterThanOrEqual(44); + } + let slider = (await open.getByRole("slider", { name: "Zoom" }).boundingBox())!; + expect(slider.x + slider.width).toBeLessThanOrEqual(390); + let download = (await open.getByRole("button", { name: "Download image" }).boundingBox())!; + expect(download.x + download.width).toBeGreaterThan(340); + // The picture takes the screen's width. + let picture = open.getByRole("img", { name: "Picture 1" }); + await expect(picture).toHaveAttribute("data-shown", ""); + await expect.poll(async () => Math.round((await picture.boundingBox())!.width)).toBe(390); + + // Two fingers spread apart zoom it in. + let centre = await picture.evaluate(element => { + let box = element.getBoundingClientRect(); + return { x: box.left + box.width / 2, y: box.top + box.height / 2 }; + }); + await open.locator(".image-viewer-stage").evaluate((stage, { x, y }) => { + let fire = (type: string, id: number, dx: number) => + stage.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + clientX: x + dx, + clientY: y, + isPrimary: id === 1, + pointerId: id, + pointerType: "touch", + }), + ); + fire("pointerdown", 1, -40); + fire("pointerdown", 2, 40); + for (let step = 1; step <= 5; step++) fire("pointermove", 2, 40 + step * 16); + fire("pointerup", 1, -40); + fire("pointerup", 2, 120); + }, centre); + await expect.poll(async () => Number(await open.getByRole("slider").inputValue())) + .toBeGreaterThan(0); + + await open.getByRole("button", { name: "Next", exact: true }).tap(); + await expect(open.getByRole("status")).toHaveText("2 of 3"); }); test("the composer's files keep their place on a phone", async ({ join }) => { diff --git a/e2e/design/chat-media.e2e.ts b/e2e/design/chat-media.e2e.ts index bc07353db..672a10106 100644 --- a/e2e/design/chat-media.e2e.ts +++ b/e2e/design/chat-media.e2e.ts @@ -215,17 +215,27 @@ for (let mode of ["light", "dark"] as const) { await token(page, "--color-inset", "background-color"), ]); - // The viewer is Slack's dark ground in Chopin's ink, with the page's colour for its words. + // The viewer is Slack's dark ground under the blurred picture, its words in the page's colour + // and its secondary line in the light grey that keeps AA over a white picture's blur. await frame.click(); - let viewer = page.locator('[data-slot="dialog-content"]'); - await expect(viewer.locator("[data-chat-viewer-header]")).toBeVisible(); + let viewer = page.locator("[data-image-viewer]"); + await expect(viewer.locator(".image-viewer-name")).toBeVisible(); expect(await css(viewer, "background-color", "color")).toEqual([ - await token(page, "--color-text-primary", "background-color"), + await token(page, "--color-gray-900", "background-color"), await token(page, "--color-page"), ]); - expect(await css(viewer.locator(".chat-viewer-name"), "color")).toEqual([ + expect(await css(viewer.locator(".image-viewer-name"), "color")).toEqual([ await token(page, "--color-page"), ]); + expect(await css(viewer.locator(".image-viewer-detail"), "color")).toEqual([ + await token(page, "--color-gray-300"), + ]); + expect( + await css( + viewer.getByRole("button", { name: "Download image" }).locator("[data-nucleo-icon]"), + "color", + ), + ).toEqual([await token(page, "--color-page")]); await page.keyboard.press("Escape"); await expect(viewer).toHaveCount(0); }); diff --git a/e2e/harness.e2e.ts b/e2e/harness.e2e.ts index 9e5d5d09e..7a9ae52d9 100644 --- a/e2e/harness.e2e.ts +++ b/e2e/harness.e2e.ts @@ -823,10 +823,10 @@ test("images the Planner makes reach Chat as stored, generated images only reade // Three images are a gallery, whose tiles carry no attribution: each image is told as generated // in the lightbox, as it is shown. await chat.getByRole("button", { name: `Open image: ${pictures[0]!.alt}`, exact: true }).click(); - let box = opened.getByRole("dialog"); + let box = opened.locator("[data-image-viewer]"); for (let [index, picture] of pictures.entries()) { - if (index > 0) await box.getByRole("button", { name: "Next image" }).click(); - await expect(box.getByRole("status")).toHaveText(`Image ${index + 1} of ${pictures.length}`); + if (index > 0) await box.getByRole("button", { name: "Next", exact: true }).click(); + await expect(box.getByRole("status")).toHaveText(`${index + 1} of ${pictures.length}`); await expect(box.getByRole("img", { name: picture.alt, exact: true })).toBeVisible(); await expect(box.getByText("Generated", { exact: true })).toBeVisible(); } diff --git a/e2e/images.e2e.ts b/e2e/images.e2e.ts index 866e50b14..d908b125d 100644 --- a/e2e/images.e2e.ts +++ b/e2e/images.e2e.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; +import { seedChannelImage, titleChannel } from "./database"; import { content, expect, ready, test, written } from "./room"; import type { Page, WebSocketRoute } from "@playwright/test"; @@ -33,12 +34,15 @@ async function width(page: Page) { } test("images open a keyboard-accessible lightbox and return focus on close", async ({ + baseURL, join, page, + room, seed, }, testInfo) => { await page.setViewportSize({ width: 1280, height: 900 }); await loadImage(page); + await titleChannel(Number(new URL(baseURL!).port), room, "Prototype review"); await seed(SOURCE); await join("ana"); await expect.poll(() => @@ -48,20 +52,24 @@ test("images open a keyboard-accessible lightbox and return focus on close", asy await image(page).click(); let preview = page.getByRole("dialog", { name: "Image preview", exact: true }); await expect(preview).toBeVisible(); - await expect(preview.getByRole("img", { name: "An open working document", exact: true })) - .toBeVisible(); - await expect(preview.getByRole("button", { name: "Close image preview", exact: true })) - .toBeFocused(); - let fitted = await preview.getByRole("img").evaluate(element => - element.getBoundingClientRect().width + let shown = preview.getByRole("img", { name: "An open working document", exact: true }); + await expect(shown).toBeVisible(); + // A document's picture has no sender: the document's title, then the picture's name. + await expect(preview.locator(".image-viewer-name")).toHaveText("Prototype review"); + await expect(preview.locator(".image-viewer-detail-text")).toHaveText( + "An open working document", ); + await expect(preview).toHaveAccessibleDescription(/Prototype review/); + // One picture has no Previous or Next and no count. + await expect(preview.getByRole("button", { name: "Next", exact: true })).toHaveCount(0); + await expect(preview.getByRole("status")).toHaveCount(0); + let fitted = await shown.evaluate(element => element.getBoundingClientRect().width); await preview.getByRole("button", { name: "Zoom in", exact: true }).click(); - await expect(preview.getByRole("button", { name: "Zoom out", exact: true })).toBeVisible(); - await expect.poll(() => - preview.getByRole("img").evaluate(element => element.getBoundingClientRect().width) - ) + await expect(preview.getByRole("button", { name: "Zoom out", exact: true })).toBeEnabled(); + await expect.poll(() => shown.evaluate(element => element.getBoundingClientRect().width)) .toBeGreaterThan(fitted); await preview.getByRole("button", { name: "Zoom out", exact: true }).click(); + await expect(preview.getByRole("button", { name: "Zoom out", exact: true })).toBeDisabled(); await page.screenshot({ path: testInfo.outputPath("image-lightbox.png") }); await page.keyboard.press("Escape"); await expect(preview).toHaveCount(0); @@ -69,7 +77,41 @@ test("images open a keyboard-accessible lightbox and return focus on close", asy .toBeFocused(); await page.keyboard.press("Enter"); await expect(preview).toBeVisible(); - await preview.getByRole("button", { name: "Close image preview", exact: true }).click(); + // Focus starts on the viewer, and Tab reaches its controls without leaving it. + await expect(preview).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(preview.getByRole("button", { name: "Close", exact: true })).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(preview.getByRole("button", { name: "Download image", exact: true })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(preview.getByRole("button", { name: "Close", exact: true })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(preview).toHaveCount(0); + await expect(content(page).getByRole("button", { name: "View image", exact: true })) + .toBeFocused(); +}); + +test("a document's own upload downloads as the stored file under the picture's name, and fades in", async ({ baseURL, join, page, room, seed }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + let bytes = readFileSync(new URL("./fixtures/raster-18x6.png", import.meta.url)); + let src = await seedChannelImage(Number(new URL(baseURL!).port), room, bytes, "image/png"); + await seed(`# Floor plan\n\n![Ground floor](${src})\n`); + await join("ana"); + let picture = content(page).getByRole("img", { name: "Ground floor", exact: true }); + await expect(picture).toBeVisible(); + // So small a picture is under its own resize corner, so it is opened from its button. + await content(page).getByRole("button", { name: "View image", exact: true }).focus(); + await page.keyboard.press("Enter"); + let preview = page.getByRole("dialog", { name: "Image preview", exact: true }); + await expect(preview).toHaveCSS("animation-name", "image-viewer-in"); + let [saved] = await Promise.all([ + page.waitForEvent("download"), + preview.getByRole("button", { name: "Download image", exact: true }).click(), + ]); + expect(saved.suggestedFilename()).toBe("Ground floor.png"); + let body = await (await saved.createReadStream()).toArray().then(parts => Buffer.concat(parts)); + expect(body.equals(bytes)).toBe(true); + await page.keyboard.press("Escape"); await expect(preview).toHaveCount(0); }); @@ -218,11 +260,12 @@ test("keyboard resizing is undoable and stored widths stay inside narrow documen await image(page).click(); let preview = page.getByRole("dialog", { name: "Image preview", exact: true }); await expect(preview).toBeVisible(); - expect( - await preview.getByRole("img").evaluate(element => { + await expect(preview.getByRole("img")).toHaveAttribute("data-shown", ""); + await expect.poll(() => + preview.getByRole("img").evaluate(element => { let box = element.getBoundingClientRect(); return box.x >= 0 && box.right <= innerWidth; - }), + }) ).toBe(true); await page.keyboard.press("Escape"); }); diff --git a/packages/editor/package.json b/packages/editor/package.json index 42d7676f2..d5f225b96 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -43,6 +43,10 @@ "types": "./src/widgets/image-lightbox.tsx", "default": "./src/widgets/image-lightbox.tsx" }, + "./image-place": { + "types": "./src/widgets/image-place.ts", + "default": "./src/widgets/image-place.ts" + }, "./people": { "types": "./src/people.ts", "default": "./src/people.ts" diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index d05343091..65775b729 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -3012,121 +3012,6 @@ margin-block-start: 0.25rem; } -.plan-image-lightbox { - position: fixed; - inset: 0; - inline-size: 100%; - block-size: 100dvh; - max-inline-size: none; - max-block-size: none; - margin: 0; - padding: calc(env(safe-area-inset-top, 0px) + 4rem) calc(env(safe-area-inset-right, 0px) + 1.5rem) - calc(env(safe-area-inset-bottom, 0px) + 1.5rem) calc(env(safe-area-inset-left, 0px) + 1.5rem); - border: 0; - background: var(--color-text-primary); - color: var(--color-page); - font-family: var(--font-sans); -} - -/* A closed dialog stays hidden: only an open one is a grid. */ -.plan-image-lightbox[open] { - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - gap: 1rem; -} - -@media (prefers-reduced-motion: no-preference) { - .plan-image-lightbox[open] { - animation: plan-source-in var(--duration-base) var(--ease-out); - } -} - -.plan-image-lightbox-toolbar { - position: absolute; - inset-block-start: calc(env(safe-area-inset-top, 0px) + 1rem); - inset-inline-end: calc(env(safe-area-inset-right, 0px) + 1rem); - display: flex; - gap: 0.5rem; -} - -.plan-image-lightbox-scroll { - display: flex; - align-items: center; - justify-content: center; - inline-size: 100%; - min-block-size: 0; - overflow: auto; - overscroll-behavior: contain; -} - -.plan-image-lightbox-scroll img { - display: block; - max-inline-size: 100%; - max-block-size: 100%; - object-fit: contain; -} - -.plan-image-lightbox-scroll[data-zoomed] { - display: block; -} - -.plan-image-lightbox-scroll[data-zoomed] img { - max-inline-size: none; - max-block-size: none; - block-size: auto; - margin-inline: auto; -} - -.plan-image-lightbox-broken { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - color: color-mix(in srgb, var(--color-page) 70%, transparent); - font-size: var(--text-sm); -} - -.plan-image-lightbox-footer { - display: flex; - align-items: center; - gap: 0.75rem; - inline-size: min(100%, 48rem); - margin-inline: auto; - font-size: var(--text-sm); - line-height: var(--text-sm--line-height); -} - -.plan-image-lightbox-text { - flex: 1; - min-inline-size: 0; - max-block-size: 30dvh; - overflow-y: auto; - overflow-wrap: anywhere; -} - -.plan-image-lightbox-text p { - margin: 0; -} - -.plan-image-lightbox-caption { - color: color-mix(in srgb, var(--color-page) 70%, transparent); - font-size: var(--text-xs); - line-height: var(--text-xs--line-height); -} - -.plan-image-lightbox-generated { - margin-inline-start: 0.5rem; - color: color-mix(in srgb, var(--color-page) 70%, transparent); - font-size: var(--text-xs); -} - -.plan-image-lightbox-count { - flex-shrink: 0; - color: color-mix(in srgb, var(--color-page) 70%, transparent); - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; -} - /* * A dense diagram's full-screen reading. It rises from the bottom edge and * settles; reduced motion only fades it in. The drawing keeps its authored diff --git a/packages/editor/src/widgets/image-lightbox-state.test.ts b/packages/editor/src/widgets/image-lightbox-state.test.ts index ed9563ed3..ff7b4a87b 100644 --- a/packages/editor/src/widgets/image-lightbox-state.test.ts +++ b/packages/editor/src/widgets/image-lightbox-state.test.ts @@ -1,6 +1,25 @@ import { expect, test } from "bun:test"; -import { arrowStep, stepImage, wrapTab } from "./image-lightbox-state"; +import { + arrowStep, + clampPan, + detailLine, + downloadName, + fileNameOf, + fitScale, + pinch, + relativeTime, + scaleOf, + shownSize, + sliderOf, + stepImage, + toggleZoom, + wrapTab, + zoomAt, + zoomBounds, + zoomKey, + zoomStep, +} from "./image-lightbox-state"; test("stepping stops at the first and the last image instead of wrapping", () => { expect(stepImage(0, -1, 3)).toBe(0); @@ -27,3 +46,106 @@ test("focus wraps at both ends of the lightbox's controls and is otherwise left expect(wrapTab(-1, 3, true)).toBe(2); expect(wrapTab(0, 0, false)).toBeUndefined(); }); + +test("a picture is fitted inside the stage, turned a quarter swaps its sides, and a small one is never enlarged", () => { + let wide = { width: 2000, height: 1000 }; + let stage = { width: 1000, height: 800 }; + expect(fitScale(wide, stage, 0)).toBe(0.5); + // Turned, it is 1000 wide and 2000 tall, so its height decides. + expect(fitScale(wide, stage, 1)).toBe(0.4); + expect(fitScale(wide, stage, 2)).toBe(0.5); + expect(fitScale({ width: 100, height: 50 }, stage, 0)).toBe(1); + expect(shownSize(wide, 0.4, 1)).toEqual({ width: 400, height: 800 }); +}); + +test("the slider runs from fitted to its most on a logarithmic scale, and −/+ move it an eighth", () => { + let bounds = zoomBounds(0.25); + expect(bounds).toEqual({ min: 0.25, max: 2 }); + expect(zoomBounds(1)).toEqual({ min: 1, max: 8 }); + expect(sliderOf(0.25, bounds)).toBe(0); + expect(sliderOf(2, bounds)).toBe(100); + // The picture's own pixels sit two thirds along: 0.25 → 1 is two doublings of three. + expect(sliderOf(1, bounds)).toBe(67); + expect(scaleOf(50, bounds)).toBeCloseTo(Math.SQRT2 * 0.5, 5); + expect(zoomStep(0.25, -1, bounds)).toBe(0.25); + expect(sliderOf(zoomStep(0.25, 1, bounds), bounds)).toBe(13); + // Steps add up exactly, however the slider rounds what it shows. + expect(sliderOf(zoomStep(zoomStep(0.25, 1, bounds), 1, bounds), bounds)).toBe(25); + expect(zoomStep(2, 1, bounds)).toBe(2); +}); + +test("a double-click goes to the picture's own pixels from fitted, and back to fitted from any zoom", () => { + let large = zoomBounds(0.25); + expect(toggleZoom(0.25, large)).toBe(1); + expect(toggleZoom(1, large)).toBe(0.25); + expect(toggleZoom(0.6, large)).toBe(0.25); + // A picture already at its own size doubles instead. + expect(toggleZoom(1, zoomBounds(1))).toBe(2); +}); + +test("the keys zoom: + and = in, - out, 0 back to fitted", () => { + expect(zoomKey("+")).toBe(1); + expect(zoomKey("=")).toBe(1); + expect(zoomKey("-")).toBe(-1); + expect(zoomKey("0")).toBe("fit"); + expect(zoomKey("a")).toBeUndefined(); +}); + +test("a zoomed picture pans only as far as its edges, and a fitted one not at all", () => { + let stage = { width: 1000, height: 800 }; + expect(clampPan({ x: 900, y: -900 }, { width: 2000, height: 1000 }, stage)).toEqual({ + x: 500, + y: -100, + }); + expect(clampPan({ x: 40, y: 40 }, { width: 500, height: 400 }, stage)).toEqual({ x: 0, y: 0 }); +}); + +test("zooming about a point keeps that point of the picture under it", () => { + // Doubling about a point 100px right of centre moves the picture 100px left. + expect(zoomAt({ x: 0, y: 0 }, { x: 100, y: 0 }, 1, 2)).toEqual({ x: -100, y: 0 }); + expect(zoomAt({ x: 50, y: 20 }, { x: 0, y: 0 }, 2, 1)).toEqual({ x: 25, y: 10 }); +}); + +test("a pinch asks for the ratio of the fingers' spread, about the point between them", () => { + let moved = pinch([{ x: 0, y: 0 }, { x: 100, y: 0 }], [{ x: -50, y: 0 }, { x: 150, y: 0 }]); + expect(moved).toEqual({ ratio: 2, centre: { x: 50, y: 0 } }); +}); + +test("when is said as Slack says it: minutes, hours and days ago, then the date", () => { + let now = Date.UTC(2026, 10, 12, 15, 0, 0); + expect(relativeTime(now - 10_000, now)).toBe("just now"); + expect(relativeTime(now - 5 * 60_000, now)).toBe("5 minutes ago"); + expect(relativeTime(now - 3 * 3_600_000, now)).toBe("3 hours ago"); + expect(relativeTime(now - 26 * 3_600_000, now)).toBe("yesterday"); + expect(relativeTime(now - 3 * 86_400_000, now)).toBe("3 days ago"); + expect(relativeTime(Date.UTC(2026, 9, 1, 12), now)).toBe("Oct 1, 2026"); +}); + +test("the line under the name says when, where and which file, leaving out what is not known", () => { + expect(detailLine({ file: "chart.png", place: "Weekly plan", time: "3 hours ago" })).toBe( + "3 hours ago in Weekly plan – chart.png", + ); + expect(detailLine({ file: "chart.png", place: "a thread in Weekly plan", time: "just now" })) + .toBe("just now in a thread in Weekly plan – chart.png"); + expect(detailLine({ file: "chart.png", place: "Weekly plan" })).toBe( + "In Weekly plan – chart.png", + ); + expect(detailLine({ file: "chart.png", time: "3 hours ago" })).toBe("3 hours ago – chart.png"); + expect(detailLine({ file: "chart.png" })).toBe("chart.png"); +}); + +test("a file's name is the last part of its address's path, and a data address has none", () => { + expect(fileNameOf("/api/images/abc.png?document=1")).toBe("abc.png"); + expect(fileNameOf("https://example.test/a/Weekly%20chart.webp#top")).toBe("Weekly chart.webp"); + expect(fileNameOf("data:image/png;base64,AAAA")).toBeUndefined(); + expect(fileNameOf("blob:https://example.test/1234")).toBeUndefined(); + expect(fileNameOf("https://example.test/")).toBeUndefined(); +}); + +test("a download keeps its name, takes the extension its type says where it has none, and loses path characters", () => { + expect(downloadName("chart.png", "image/png")).toBe("chart.png"); + expect(downloadName("Weekly chart", "image/jpeg")).toBe("Weekly chart.jpg"); + expect(downloadName("notes", "text/plain; charset=utf-8")).toBe("notes.txt"); + expect(downloadName("a/b:c", undefined)).toBe("a-b-c"); + expect(downloadName("", "image/webp")).toBe("image.webp"); +}); diff --git a/packages/editor/src/widgets/image-lightbox-state.ts b/packages/editor/src/widgets/image-lightbox-state.ts index 45cb3b1ef..3d0c5ae76 100644 --- a/packages/editor/src/widgets/image-lightbox-state.ts +++ b/packages/editor/src/widgets/image-lightbox-state.ts @@ -13,3 +13,156 @@ export function wrapTab(active: number, count: number, backwards: boolean): numb if (!backwards && active === count - 1) return 0; return undefined; } + +export type Size = { width: number; height: number }; +export type Point = { x: number; y: number }; + +/** The scale that fits a picture, turned `turns` quarters, inside a box; a small one is never enlarged. */ +export function fitScale(natural: Size, box: Size, turns: number): number { + let quarter = turns % 2 === 1; + let width = quarter ? natural.height : natural.width; + let height = quarter ? natural.width : natural.height; + if (width <= 0 || height <= 0 || box.width <= 0 || box.height <= 0) return 1; + return Math.min(1, box.width / width, box.height / height); +} + +export type ZoomBounds = { min: number; max: number }; + +/** From fitted to eight times that, and never less than twice the picture's own pixels. */ +export function zoomBounds(fit: number): ZoomBounds { + return { min: fit, max: Math.max(fit * 8, 2) }; +} + +export function clampZoom(scale: number, bounds: ZoomBounds): number { + return Math.min(Math.max(scale, bounds.min), bounds.max); +} + +/** The slider runs 0 to 100 on a logarithmic scale, so each step looks the same size. */ +function position(scale: number, bounds: ZoomBounds): number { + if (bounds.max <= bounds.min) return 0; + return Math.log(clampZoom(scale, bounds) / bounds.min) / Math.log(bounds.max / bounds.min) * 100; +} + +export function sliderOf(scale: number, bounds: ZoomBounds): number { + return Math.round(position(scale, bounds)); +} + +export function scaleOf(slider: number, bounds: ZoomBounds): number { + let at = Math.min(Math.max(slider, 0), 100) / 100; + return bounds.min * (bounds.max / bounds.min) ** at; +} + +/** One press of − or +: an eighth of the slider. */ +export function zoomStep(scale: number, direction: -1 | 1, bounds: ZoomBounds): number { + return scaleOf(position(scale, bounds) + direction * 12.5, bounds); +} + +/** Double-click: back to fitted from any zoom, else to the picture's own pixels (or twice fitted). */ +export function toggleZoom(scale: number, bounds: ZoomBounds): number { + if (scale > bounds.min * 1.01) return bounds.min; + return clampZoom(bounds.min < 0.99 ? 1 : bounds.min * 2, bounds); +} + +export function zoomKey(key: string): -1 | 1 | "fit" | undefined { + if (key === "+" || key === "=") return 1; + if (key === "-" || key === "_") return -1; + if (key === "0") return "fit"; + return undefined; +} + +/** Keeps a zoomed picture over the stage: it moves only as far as its edges allow. */ +export function clampPan(pan: Point, shown: Size, stage: Size): Point { + let x = Math.max(0, (shown.width - stage.width) / 2); + let y = Math.max(0, (shown.height - stage.height) / 2); + return { + x: Math.min(Math.max(pan.x, -x), x) || 0, + y: Math.min(Math.max(pan.y, -y), y) || 0, + }; +} + +/** The pan that keeps the point under the pointer (from the stage's centre) still while zooming. */ +export function zoomAt(pan: Point, point: Point, from: number, to: number): Point { + let ratio = to / from; + return { x: point.x - (point.x - pan.x) * ratio, y: point.y - (point.y - pan.y) * ratio }; +} + +/** The box a picture turned `turns` quarters takes at `scale`. */ +export function shownSize(natural: Size, scale: number, turns: number): Size { + let quarter = turns % 2 === 1; + return { + width: (quarter ? natural.height : natural.width) * scale, + height: (quarter ? natural.width : natural.height) * scale, + }; +} + +let relative: Intl.RelativeTimeFormat | undefined; +let dated: Intl.DateTimeFormat | undefined; + +/** Slack's "3 hours ago": minutes, hours and days, then the date. */ +export function relativeTime(at: number, now: number): string { + let seconds = Math.round((now - at) / 1000); + if (seconds < 45) return "just now"; + relative ??= new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + let minutes = Math.round(seconds / 60); + if (minutes < 60) return relative.format(-minutes, "minute"); + let hours = Math.round(minutes / 60); + if (hours < 24) return relative.format(-hours, "hour"); + let days = Math.round(hours / 24); + if (days < 7) return relative.format(-days, "day"); + dated ??= new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }); + return dated.format(new Date(at)); +} + +/** The muted line under the name: "3 hours ago in Weekly plan – chart.png". */ +export function detailLine( + { file, place, time }: { file: string; place?: string; time?: string }, +): string { + let where = place ? (time ? `in ${place}` : `In ${place}`) : undefined; + let lead = [time, where].filter(Boolean).join(" "); + return lead ? `${lead} – ${file}` : file; +} + +/** The last part of an address's path, as a file's name, or undefined for an address with none. */ +export function fileNameOf(src: string): string | undefined { + if (/^(data|blob):/i.test(src)) return undefined; + let path = src.split(/[?#]/, 1)[0] ?? ""; + let last = path.split("/").pop() ?? ""; + try { + last = decodeURIComponent(last); + } catch { + // A malformed escape keeps the name as written. + } + return last.trim() || undefined; +} + +const EXTENSIONS: Record = { + jpeg: "jpg", + "svg+xml": "svg", + plain: "txt", + markdown: "md", + "x-icon": "ico", + "vnd.microsoft.icon": "ico", +}; + +/** A download's name: the name it has, with the extension its type says where it has none. */ +export function downloadName(name: string, type?: string): string { + let cleaned = [...name].map(character => character < " " ? "-" : character).join("") + .replace(/[\\/:*?"<>|]+/g, "-").replace(/-{2,}/g, "-").trim().slice(0, 120) || "image"; + if (/\.[a-z0-9]{1,5}$/i.test(cleaned)) return cleaned; + let subtype = type?.split(";")[0]?.split("/")[1]?.trim().toLowerCase(); + let extension = subtype ? EXTENSIONS[subtype] ?? subtype : undefined; + return extension && /^[a-z0-9]{1,5}$/.test(extension) ? `${cleaned}.${extension}` : cleaned; +} + +/** How a two-finger pinch moved: the scale it asks for and the point it is about. */ +export function pinch( + before: readonly [Point, Point], + after: readonly [Point, Point], +): { ratio: number; centre: Point } { + let distance = ([a, b]: readonly [Point, Point]) => Math.hypot(a.x - b.x, a.y - b.y); + let start = distance(before); + return { + ratio: start > 0 ? distance(after) / start : 1, + centre: { x: (after[0].x + after[1].x) / 2, y: (after[0].y + after[1].y) / 2 }, + }; +} diff --git a/packages/editor/src/widgets/image-lightbox.css b/packages/editor/src/widgets/image-lightbox.css new file mode 100644 index 000000000..07e746434 --- /dev/null +++ b/packages/editor/src/widgets/image-lightbox.css @@ -0,0 +1,536 @@ +/* + * The image viewer, laid out as Slack's: the whole window over a dimmed, heavily blurred copy of + * the picture, who and where along the top with Close at its end, the picture in the middle with a + * margin, rotate and zoom at the bottom start and Download alone at the bottom end. Its ground is + * dark whatever the picture is, so its words and controls are the page's colour on it: the blur is + * dimmed to at most the luminance where white text still reads at 7:1 over a white picture. + */ + +.image-viewer { + --image-viewer-ink: var(--color-page); + --image-viewer-muted: var(--color-gray-300); + --image-viewer-control: color-mix(in oklch, var(--color-page) 12%, transparent); + --image-viewer-control-hover: color-mix(in oklch, var(--color-page) 22%, transparent); + /* Previous and Next can sit over the picture itself, so they carry a dark ground of their own. */ + --image-viewer-step: color-mix(in oklch, var(--color-gray-900) 62%, transparent); + --image-viewer-step-hover: color-mix(in oklch, var(--color-gray-900) 78%, transparent); + --image-viewer-track: color-mix(in oklch, var(--color-page) 45%, transparent); + position: fixed; + inset: 0; + inline-size: 100%; + block-size: 100dvh; + max-inline-size: none; + max-block-size: none; + margin: 0; + padding: 0; + overflow: hidden; + border: 0; + background: var(--color-gray-900); + color: var(--image-viewer-ink); + font-family: var(--font-sans); + overscroll-behavior: contain; +} + +/* A closed dialog stays hidden: only an open one is a grid. */ +.image-viewer[open] { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.image-viewer::backdrop { + background: transparent; +} + +.image-viewer:focus { + outline: none; +} + +.image-viewer-backdrop { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +/* Larger than the window, so the blur's soft edge is never inside it. */ +.image-viewer-backdrop img { + position: absolute; + inset: -10%; + inline-size: 120%; + block-size: 120%; + max-inline-size: none; + object-fit: cover; + filter: blur(64px) brightness(0.55) saturate(1.2); +} + +.image-viewer-backdrop::after { + content: ""; + position: absolute; + inset: 0; + background: color-mix(in oklch, var(--color-gray-900) 40%, transparent); +} + +.image-viewer-header, +.image-viewer-stage, +.image-viewer-footer { + position: relative; + z-index: 1; + min-inline-size: 0; +} + +.image-viewer-header { + display: flex; + align-items: center; + gap: calc(var(--spacing) * 3); + min-block-size: calc(var(--spacing) * 16); + padding: calc(env(safe-area-inset-top, 0px) + var(--spacing) * 3) + calc(env(safe-area-inset-right, 0px) + var(--spacing) * 3) calc(var(--spacing) * 3) + calc(env(safe-area-inset-left, 0px) + var(--spacing) * 4); +} + +.image-viewer-face { + display: flex; + flex: none; +} + +.image-viewer-who { + flex: 1; + min-inline-size: 0; +} + +.image-viewer-who p { + margin: 0; +} + +.image-viewer-name { + overflow: hidden; + font-size: var(--text-sm); + font-weight: 700; + line-height: var(--text-sm--line-height); + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-viewer-detail { + display: flex; + align-items: center; + gap: calc(var(--spacing) * 2); + min-inline-size: 0; +} + +.image-viewer-detail, +.image-viewer-note { + color: var(--image-viewer-muted); + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); +} + +.image-viewer-detail-text { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-viewer-generated { + display: inline-flex; + flex: none; + align-items: center; + gap: var(--spacing); +} + +.image-viewer-control { + display: inline-grid; + flex: none; + place-items: center; + inline-size: calc(var(--spacing) * 9); + block-size: calc(var(--spacing) * 9); + padding: 0; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--image-viewer-ink); + cursor: pointer; + transition: + background-color var(--duration-fast) var(--ease-out), + opacity var(--duration-fast) var(--ease-out); +} + +/* Icons here take the viewer's ink, not the page's quiet icon grey. */ +.image-viewer + :is(.image-viewer-control, .image-viewer-generated, .image-viewer-broken) + [data-nucleo-icon] { + color: inherit; +} + +.image-viewer-control:hover:not(:disabled) { + background: var(--image-viewer-control-hover); +} + +.image-viewer-control:focus-visible, +.image-viewer-slider:focus-visible, +.image-viewer-text:focus-visible { + outline: var(--focus-ring-width) solid var(--image-viewer-ink); + outline-offset: 1px; +} + +.image-viewer-control:disabled { + opacity: 0.4; + cursor: default; +} + +.image-viewer-stage { + display: grid; + place-items: center; + padding: calc(var(--spacing) * 4) calc(var(--spacing) * 8); + overflow: hidden; + touch-action: none; +} + +.image-viewer-stage[data-many] { + padding-inline: calc(var(--spacing) * 18); +} + +.image-viewer-stage[data-zoomed] { + cursor: grab; +} + +.image-viewer-stage[data-zoomed][data-gesture] { + cursor: grabbing; +} + +/* Placed by the script: its size is the zoom, its transform the pan and the turn. */ +.image-viewer-image { + position: absolute; + inset-block-start: 50%; + inset-inline-start: 50%; + max-inline-size: none; + visibility: hidden; + box-shadow: var(--shadow-overlay); + user-select: none; + -webkit-user-drag: none; + transition: + transform var(--duration-base) var(--ease-out), + width var(--duration-base) var(--ease-out), + height var(--duration-base) var(--ease-out); +} + +.image-viewer-image[data-shown] { + visibility: visible; +} + +.image-viewer-stage[data-gesture] .image-viewer-image { + transition: none; +} + +.image-viewer-broken { + display: flex; + flex-direction: column; + align-items: center; + gap: calc(var(--spacing) * 2); + color: var(--image-viewer-muted); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} + +/* A text file is a page of its words; it scrolls, and is read by touch, unlike a picture. */ +.image-viewer-sheet { + display: flex; + flex-direction: column; + inline-size: min(100%, 52rem); + min-block-size: 0; + max-block-size: 100%; + overflow: hidden; + border-radius: var(--radius-lg); + background: var(--color-page); + box-shadow: var(--shadow-overlay); + color: var(--color-text-primary); + touch-action: auto; +} + +.image-viewer-text { + flex: 1; + margin: 0; + padding: calc(var(--spacing) * 4) calc(var(--spacing) * 5); + overflow: auto; + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + white-space: pre-wrap; + overflow-wrap: anywhere; + tab-size: 4; + overscroll-behavior: contain; +} + +.image-viewer-sheet-quiet { + margin: 0; + padding: calc(var(--spacing) * 3) calc(var(--spacing) * 5); + color: var(--color-text-tertiary); + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); +} + +/* Any other file: Slack's card, its type's icon, its name and its type and size. */ +.image-viewer-card { + display: flex; + align-items: center; + gap: calc(var(--spacing) * 3); + inline-size: min(100%, 24rem); + padding: calc(var(--spacing) * 3); + border-radius: var(--radius-xl); + background: var(--color-page); + box-shadow: var(--shadow-overlay); + color: var(--color-text-primary); +} + +.image-viewer-card-icon { + display: flex; + flex: none; +} + +.image-viewer-card-text { + display: flex; + flex-direction: column; + min-inline-size: 0; +} + +.image-viewer-card-name { + overflow: hidden; + font-size: var(--text-sm); + font-weight: 700; + line-height: var(--text-sm--line-height); + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-viewer-card-detail { + display: flex; + gap: calc(var(--spacing) * 2); + color: var(--color-text-tertiary); + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); +} + +/* Previous and Next at the window's sides, halfway down; one with nowhere to go is not drawn. */ +.image-viewer-step { + position: absolute; + inset-block-start: 50%; + z-index: 2; + inline-size: calc(var(--spacing) * 10); + block-size: calc(var(--spacing) * 10); + translate: 0 -50%; + border-radius: var(--radius-full); + background: var(--image-viewer-step); +} + +.image-viewer-step:hover:not(:disabled) { + background: var(--image-viewer-step-hover); +} + +.image-viewer-step[data-image-viewer-step="previous"] { + inset-inline-start: calc(env(safe-area-inset-left, 0px) + var(--spacing) * 4); +} + +.image-viewer-step[data-image-viewer-step="next"] { + inset-inline-end: calc(env(safe-area-inset-right, 0px) + var(--spacing) * 4); +} + +.image-viewer-step:disabled { + opacity: 0; +} + +.image-viewer-footer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: calc(var(--spacing) * 3); + padding: calc(var(--spacing) * 3) calc(env(safe-area-inset-right, 0px) + var(--spacing) * 4) + calc(env(safe-area-inset-bottom, 0px) + var(--spacing) * 3) + calc(env(safe-area-inset-left, 0px) + var(--spacing) * 4); +} + +.image-viewer-tools { + display: flex; + align-items: center; + gap: calc(var(--spacing) * 2); + justify-self: start; +} + +.image-viewer-group { + display: inline-flex; + align-items: center; + padding: calc(var(--spacing) * 0.5); + border-radius: var(--radius-lg); + background: var(--image-viewer-control); +} + +.image-viewer-slider { + inline-size: calc(var(--spacing) * 24); + block-size: calc(var(--spacing) * 6); + margin: 0 var(--spacing); + background: transparent; + cursor: pointer; + appearance: none; +} + +.image-viewer-slider:disabled { + opacity: 0.4; + cursor: default; +} + +.image-viewer-slider::-webkit-slider-runnable-track { + block-size: calc(var(--spacing) * 1); + border-radius: var(--radius-full); + background: var(--image-viewer-track); +} + +.image-viewer-slider::-moz-range-track { + block-size: calc(var(--spacing) * 1); + border-radius: var(--radius-full); + background: var(--image-viewer-track); +} + +.image-viewer-slider::-webkit-slider-thumb { + inline-size: calc(var(--spacing) * 3.5); + block-size: calc(var(--spacing) * 3.5); + margin-block-start: calc(var(--spacing) * -1.25); + border: 0; + border-radius: var(--radius-full); + background: var(--image-viewer-ink); + box-shadow: var(--shadow-resting-strong); + appearance: none; +} + +.image-viewer-slider::-moz-range-thumb { + inline-size: calc(var(--spacing) * 3.5); + block-size: calc(var(--spacing) * 3.5); + border: 0; + border-radius: var(--radius-full); + background: var(--image-viewer-ink); + box-shadow: var(--shadow-resting-strong); +} + +.image-viewer-caption { + display: flex; + justify-content: center; + gap: calc(var(--spacing) * 3); + min-inline-size: 0; + max-inline-size: 40rem; + margin: 0; + color: var(--image-viewer-muted); + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); +} + +.image-viewer-caption-text { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-viewer-count { + flex: none; + font-variant-numeric: tabular-nums; +} + +.image-viewer-end { + display: flex; + justify-self: end; +} + +@media (pointer: coarse) { + .image-viewer-control { + inline-size: calc(var(--spacing) * 11); + block-size: calc(var(--spacing) * 11); + } +} + +/* A phone: the picture takes the width, and the controls keep to its thumbs' reach at the foot. */ +@media (max-width: 600px) { + .image-viewer-header { + padding-inline-start: calc(env(safe-area-inset-left, 0px) + var(--spacing) * 3); + } + + .image-viewer-stage, + .image-viewer-stage[data-many] { + padding: var(--spacing) 0; + } + + .image-viewer-footer { + grid-template-columns: minmax(0, 1fr) auto auto; + gap: calc(var(--spacing) * 2); + padding-inline: calc(env(safe-area-inset-left, 0px) + var(--spacing) * 2) + calc(env(safe-area-inset-right, 0px) + var(--spacing) * 2); + } + + .image-viewer-tools { + gap: var(--spacing); + } + + .image-viewer-slider { + inline-size: calc(var(--spacing) * 16); + } + + .image-viewer-caption-text { + display: none; + } + + .image-viewer-step[data-image-viewer-step="previous"] { + inset-inline-start: calc(env(safe-area-inset-left, 0px) + var(--spacing) * 2); + } + + .image-viewer-step[data-image-viewer-step="next"] { + inset-inline-end: calc(env(safe-area-inset-right, 0px) + var(--spacing) * 2); + } +} + +/* A calm fade with the picture settling from slightly smaller; it leaves the same way, quicker. */ +@media (prefers-reduced-motion: no-preference) { + .image-viewer[open] { + animation: image-viewer-in var(--duration-base) var(--ease-out); + } + + .image-viewer[open] .image-viewer-stage > * { + animation: image-viewer-settle var(--duration-base) var(--ease-out); + } + + .image-viewer[data-closing] { + animation: image-viewer-out var(--duration-fast) var(--ease-out) forwards; + } + + .image-viewer[data-closing] .image-viewer-stage > * { + animation: image-viewer-leave var(--duration-fast) var(--ease-out) forwards; + } +} + +@media (prefers-reduced-motion: reduce) { + .image-viewer-control, + .image-viewer-image { + transition: none; + } +} + +@keyframes image-viewer-in { + from { + opacity: 0; + } +} + +@keyframes image-viewer-out { + to { + opacity: 0; + } +} + +@keyframes image-viewer-settle { + from { + scale: 0.96; + } +} + +@keyframes image-viewer-leave { + to { + scale: 0.98; + } +} diff --git a/packages/editor/src/widgets/image-lightbox.tsx b/packages/editor/src/widgets/image-lightbox.tsx index 5089bf07e..81213d0be 100644 --- a/packages/editor/src/widgets/image-lightbox.tsx +++ b/packages/editor/src/widgets/image-lightbox.tsx @@ -1,49 +1,287 @@ +/** + * The viewer every picture opens in, Chat's and the document's, drawn as Slack's image viewer is. + * + * The whole window, over a dimmed and heavily blurred copy of the picture; who sent it (or the + * document it is in), where and when, and its name along the top, with Close at the top end; the + * picture in the middle, fitted with a margin; rotate, zoom out, the zoom slider and zoom in at the + * bottom start; Download alone at the bottom end. Several pictures, or a message's files with them, + * step with Previous and Next at the sides and the arrow keys. A file shows as its text or as a + * card. It is loaded only when something opens it. + */ + import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; -import { ChevronIcon, CloseIcon, ImageOffIcon, MinusIcon, PlusIcon } from "@chopin/icons"; +import { + ChevronIcon, + CloseIcon, + DownloadIcon, + FileIcon, + ImageOffIcon, + MinusIcon, + PlusIcon, + RotateIcon, + SparkleIcon, +} from "@chopin/icons"; + +import { + arrowStep, + clampPan, + clampZoom, + detailLine, + downloadName, + fileNameOf, + fitScale, + pinch, + relativeTime, + scaleOf, + shownSize, + sliderOf, + stepImage, + toggleZoom, + wrapTab, + zoomAt, + zoomBounds, + zoomKey, + zoomStep, +} from "./image-lightbox-state"; +import "./image-lightbox.css"; -import { arrowStep, stepImage, wrapTab } from "./image-lightbox-state"; +import type { Point, Size } from "./image-lightbox-state"; export type LightboxImage = { + kind?: "image"; + /** The address drawn: a large copy, where the original is larger still. */ src: string; alt: string; caption?: string; generated?: boolean; + /** The name along the top, where the alt text is not it. */ + name?: string; + /** The address Download saves, where it is not the one drawn. */ + original?: string; + /** The name Download saves it under. */ + download?: string; + /** A line under the name, such as that a smaller copy is shown. */ + note?: string; }; -const FOCUSABLE = "button:not([disabled]), a[href], [tabindex]:not([tabindex='-1'])"; +export type LightboxFile = { + kind: "file"; + name: string; + href: string; + /** Read and shown as text; anything else is a card of its name, type and size. */ + text?: boolean; + type?: string; + size?: string; + icon?: ReactNode; +}; + +export type LightboxItem = LightboxImage | LightboxFile; + +/** Who sent what is shown: their face, their name as written, and when, in milliseconds. */ +export type LightboxSender = { name: string; face?: ReactNode; at?: number }; + +type View = { scale?: number; pan: Point; turns: number }; + +const FOCUSABLE = + "button:not([disabled]), a[href], input:not([disabled]), [tabindex]:not([tabindex='-1'])"; +const FIT: View = { pan: { x: 0, y: 0 }, turns: 0 }; +/** As long as the closing fade in `image-lightbox.css`. */ +const CLOSING_MS = 120; +/** Text past this is left to the download. */ +const TEXT_LIMIT = 200_000; + +function labelOf(item: LightboxItem): string { + if (item.kind === "file") return item.name; + return item.name?.trim() || item.alt.trim() || fileNameOf(item.src) + || (item.generated ? "Generated image" : "Image"); +} + +function click(href: string, name?: string) { + let link = document.createElement("a"); + link.href = href; + if (name === undefined) { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } else link.download = name; + document.body.append(link); + link.click(); + link.remove(); +} + +function foreign(href: string): boolean { + let url = new URL(href, location.href); + return /^https?:$/.test(url.protocol) && url.origin !== location.origin; +} + +/** + * Saves the original under its name: read as bytes, so a signed-in upload and a data or blob + * address save as files, and a picture on another site whose host refuses the read opens in a tab. + */ +async function save(href: string, name: string): Promise<"saved" | "opened"> { + try { + let response = await fetch(href, { referrerPolicy: "no-referrer" }); + if (!response.ok) throw new Error(`The file answered ${response.status}.`); + let blob = await response.blob(); + let object = URL.createObjectURL(blob); + click(object, downloadName(name, blob.type)); + setTimeout(() => URL.revokeObjectURL(object), 40_000); + return "saved"; + } catch (error) { + if (!foreign(href)) throw error; + click(href); + return "opened"; + } +} + +function reducedMotion(): boolean { + return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function FileView({ item }: { item: LightboxFile }) { + let [read, setRead] = useState<{ href: string; text?: string; failed?: boolean }>(); + useEffect(() => { + if (!item.text) return; + let controller = new AbortController(); + fetch(item.href, { referrerPolicy: "no-referrer", signal: controller.signal }) + .then(response => { + if (!response.ok) throw new Error(`The file answered ${response.status}.`); + return response.text(); + }) + .then( + text => setRead({ href: item.href, text }), + () => { + if (!controller.signal.aborted) setRead({ href: item.href, failed: true }); + }, + ); + return () => controller.abort(); + }, [item.href, item.text]); + let current = read?.href === item.href ? read : undefined; + + if (item.text && !current?.failed) { + let text = current?.text; + return ( +

+ {text === undefined + ?

Loading {item.name}

+ : ( +
+							{text.length > TEXT_LIMIT ? text.slice(0, TEXT_LIMIT) : text}
+						
+ )} + {text !== undefined && text.length > TEXT_LIMIT && ( +

Download the file to read the rest.

+ )} +
+ ); + } + return ( +
+ + + {item.name} + {(item.type || item.size) && ( + + {item.type && {item.type}} + {item.size && {item.size}} + + )} + {current?.failed && ( + This file could not be shown here. + )} + +
+ ); +} export function ImageLightbox( - { actions, images, onClose, start = 0 }: { - actions?: (image: LightboxImage, index: number) => ReactNode; - images: readonly LightboxImage[]; + { images, onClose, place, sender, start = 0 }: { + images: readonly LightboxItem[]; onClose: () => void; + /** The document's title, or "a thread in" it. */ + place?: string; + sender?: LightboxSender; start?: number; }, ) { let dialog = useRef(null); - let close = useRef(null); + let stage = useRef(null); let previous = useRef(null); let next = useRef(null); let [index, setIndex] = useState(() => stepImage(start, 0, images.length)); - let [zoomed, setZoomed] = useState(false); - let [naturalWidth, setNaturalWidth] = useState(); + let [view, setView] = useState(FIT); + let [sizes, setSizes] = useState>>({}); + let [box, setBox] = useState<{ fit: Size; stage: Size }>(); let [failed, setFailed] = useState>(() => new Set()); - let image = images[index]; + let [gesture, setGesture] = useState(false); + let [closing, setClosing] = useState(false); + let [said, setSaid] = useState(""); + let [now] = useState(() => Date.now()); + let closed = useRef(false); + let item = images[index]; let many = images.length > 1; - let broken = image ? failed.has(image.src) : false; + let picture = item && item.kind !== "file" ? item : undefined; + let broken = picture ? failed.has(picture.src) : false; + let natural = picture && !broken ? sizes[picture.src] : undefined; + + let fit = natural && box ? fitScale(natural, box.fit, view.turns) : 1; + let bounds = zoomBounds(fit); + let scale = clampZoom(view.scale ?? fit, bounds); + let shown = natural ? shownSize(natural, scale, view.turns) : undefined; + let pan = shown && box ? clampPan(view.pan, shown, box.stage) : { x: 0, y: 0 }; + let zoomed = scale > bounds.min * 1.001; + let zoomable = !!natural && bounds.max > bounds.min; + + let close = () => { + if (closed.current) return; + if (reducedMotion()) { + closed.current = true; + onClose(); + return; + } + setClosing(true); + setTimeout(() => { + if (closed.current) return; + closed.current = true; + onClose(); + }, CLOSING_MS); + }; useLayoutEffect(() => { let element = dialog.current; let opener = document.activeElement; element?.showModal(); - close.current?.focus(); + element?.focus(); return () => { if (element?.open) element.close(); - if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); + if (opener instanceof HTMLElement && opener.isConnected) { + opener.focus({ preventScroll: true }); + } + }; + }, []); + + useLayoutEffect(() => { + let element = stage.current; + if (!element) return; + let measure = () => { + let style = getComputedStyle(element); + let across = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + let down = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); + let width = element.clientWidth; + let height = element.clientHeight; + setBox({ + fit: { width: Math.max(width - across, 1), height: Math.max(height - down, 1) }, + stage: { width, height }, + }); }; + measure(); + let observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); }, []); let show = (delta: -1 | 1) => { @@ -55,19 +293,59 @@ export function ImageLightbox( previous.current?.focus(); } setIndex(to); - setZoomed(false); - setNaturalWidth(undefined); + setView(FIT); + setSaid(""); + }; + + /** Zooms about a point given from the stage's centre, from the scale the view has by then. */ + let zoomTo = (to: number | ((from: number) => number), point: Point = { x: 0, y: 0 }) => { + setView(current => { + let from = clampZoom(current.scale ?? fit, bounds); + let target = clampZoom(typeof to === "function" ? to(from) : to, bounds); + if (target <= bounds.min) return { ...current, scale: target, pan: { x: 0, y: 0 } }; + let moved = zoomAt(current.pan, point, from, target); + let size = natural && box ? shownSize(natural, target, current.turns) : undefined; + return { + ...current, + scale: target, + pan: size && box ? clampPan(moved, size, box.stage) : moved, + }; + }); + }; + + /** Moves a zoomed picture by a drag's step, as far as its edges allow. */ + let panBy = (dx: number, dy: number) => { + setView(current => { + let size = natural && box + ? shownSize(natural, clampZoom(current.scale ?? fit, bounds), current.turns) + : undefined; + let moved = { x: current.pan.x + dx, y: current.pan.y + dy }; + return { ...current, pan: size && box ? clampPan(moved, size, box.stage) : moved }; + }); }; - // Captured before anything else on the page reads them. - let latest = useRef(show); - latest.current = show; + let rotate = () => setView(current => ({ turns: current.turns + 1, pan: { x: 0, y: 0 } })); + + // Read by listeners that are added once, so each sees this render's values. + let latest = useRef({ show, zoomTo, scale, bounds, zoomable, setGesture }); + latest.current = { show, zoomTo, scale, bounds, zoomable, setGesture }; + useEffect(() => { let onKeyDown = (event: KeyboardEvent) => { + let target = event.target as Element | null; + let field = target instanceof HTMLInputElement; + let plain = !event.altKey && !event.ctrlKey && !event.metaKey; let step = arrowStep(event.key); - if (step && !event.altKey && !event.ctrlKey && !event.metaKey) { + if (step && plain && !field) { + event.preventDefault(); + latest.current.show(step); + return; + } + let zoom = zoomKey(event.key); + if (zoom && plain && latest.current.zoomable) { event.preventDefault(); - latest.current(step); + let { bounds, scale } = latest.current; + latest.current.zoomTo(zoom === "fit" ? bounds.min : zoomStep(scale, zoom, bounds)); return; } if (event.key !== "Tab") return; @@ -85,57 +363,188 @@ export function ImageLightbox( return () => document.removeEventListener("keydown", onKeyDown, true); }, []); - if (!image) return null; - let text = image.alt || image.caption || image.generated; + // The wheel and a trackpad's pinch zoom about the pointer; the page behind never scrolls. + useEffect(() => { + let element = stage.current; + if (!element) return; + let settle: ReturnType | undefined; + let onWheel = (event: WheelEvent) => { + event.preventDefault(); + let { setGesture, zoomable, zoomTo } = latest.current; + if (!zoomable) return; + let lines = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1; + let factor = Math.exp(-event.deltaY * lines * (event.ctrlKey ? 0.01 : 0.002)); + let rect = element.getBoundingClientRect(); + // The picture follows the wheel at once; easing each tick would trail behind it. + setGesture(true); + clearTimeout(settle); + settle = setTimeout(() => latest.current.setGesture(false), 150); + zoomTo(from => from * factor, { + x: event.clientX - rect.left - rect.width / 2, + y: event.clientY - rect.top - rect.height / 2, + }); + }; + element.addEventListener("wheel", onWheel, { passive: false }); + return () => { + clearTimeout(settle); + element.removeEventListener("wheel", onWheel); + }; + }, []); + + // Fingers and a dragging mouse: one pans a zoomed picture, two pinch. + let pointers = useRef(new Map()); + let moved = useRef(false); + let fromCentre = (point: Point): Point => { + let rect = stage.current!.getBoundingClientRect(); + return { x: point.x - rect.left - rect.width / 2, y: point.y - rect.top - rect.height / 2 }; + }; + + if (!item) return null; + let label = labelOf(item); + let time = sender?.at === undefined ? undefined : relativeTime(sender.at, now); + let heading = sender?.name ?? place ?? label; + let detail = sender + ? detailLine({ file: label, place, time }) + : place + ? label + : undefined; + let download = item.kind === "file" + ? { href: item.href, name: item.name } + : { + href: item.original ?? item.src, + name: item.download ?? item.name ?? (item.alt.trim() || fileNameOf(item.src) || "image"), + }; + let noun = item.kind === "file" ? "file" : "image"; return createPortal( { event.preventDefault(); + close(); + }} + onClose={event => { + // A close the dialog's own remount already undid (React's development double effect) is not one. + if (closed.current || event.currentTarget.open) return; + closed.current = true; onClose(); }} onClick={event => { - if (event.target === event.currentTarget) onClose(); + // The viewer is portalled, so React would carry its events to whatever opened it. + event.stopPropagation(); + let target = event.target as HTMLElement; + if (target.closest("[data-image-viewer-ground]") === target) close(); }} + onKeyDown={event => event.stopPropagation()} > -
- {actions?.(image, index)} - + {picture && !broken && ( + + )} +
+ {sender?.face && } +
+

{heading}

+ {detail && ( +

+ {detail} + {picture?.generated && ( + + + )} +

+ )} + {picture?.note &&

{picture.note}

} +
-
+
{ - if (event.target === event.currentTarget) onClose(); + onClickCapture={event => { + // The click that ends a drag is the drag's, not a press on the ground. + if (moved.current) { + event.stopPropagation(); + moved.current = false; + } + }} + onPointerDown={event => { + if (!natural || event.button !== 0) return; + // A mouse has one pointer; one whose release fell outside the window is gone. + if (event.pointerType === "mouse") pointers.current.clear(); + pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); + moved.current = false; + if (pointers.current.size === 2 || zoomed) setGesture(true); + }} + onPointerMove={event => { + let last = pointers.current.get(event.pointerId); + if (!last) return; + let point = { x: event.clientX, y: event.clientY }; + if (pointers.current.size === 2) { + let [first, second] = [...pointers.current.entries()]; + let before = [first![1], second![1]] as const; + pointers.current.set(event.pointerId, point); + let after = [ + pointers.current.get(first![0])!, + pointers.current.get(second![0])!, + ] as const; + let { centre, ratio } = pinch(before, after); + moved.current = true; + zoomTo(from => from * ratio, fromCentre(centre)); + return; + } + pointers.current.set(event.pointerId, point); + if (!zoomed) return; + let dx = point.x - last.x; + let dy = point.y - last.y; + if (dx === 0 && dy === 0) return; + // Held only once it drags, so a press that does not move stays a click on what it pressed. + if (!event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.setPointerCapture(event.pointerId); + } + moved.current = true; + panBy(dx, dy); + }} + onPointerUp={event => { + pointers.current.delete(event.pointerId); + if (pointers.current.size === 0) setGesture(false); + }} + onPointerCancel={event => { + pointers.current.delete(event.pointerId); + if (pointers.current.size === 0) setGesture(false); }} > - {broken + {item.kind === "file" + ? + : broken ? (
- {(text || many) && ( -
- {many && ( - + {many && ( + <> + + + + )} +
+
+ {picture && ( + <> + + + + + + zoomTo(scaleOf(Number(event.currentTarget.value), bounds))} + step={1} + type="range" + value={sliderOf(scale, bounds)} + /> + + + )} -
- {(image.alt || image.generated) && ( -

- {image.alt} - {image.generated && ( - Generated - )} -

- )} - {image.caption &&

{image.caption}

} -
- {many && ( - - Image {index + 1} of {images.length} - +
+

+ {picture?.caption && picture.caption !== label && ( + {picture.caption} )} {many && ( - + + {index + 1} of {images.length} + )} +

+
+
- )} +
+ {said}
, document.body, ); diff --git a/packages/editor/src/widgets/image-place.ts b/packages/editor/src/widgets/image-place.ts new file mode 100644 index 000000000..0606e4c18 --- /dev/null +++ b/packages/editor/src/widgets/image-place.ts @@ -0,0 +1,7 @@ +import { createContext } from "react"; + +/** + * Where an image is, for the image viewer's header: the document's title, or "a thread in" it. + * Its own module, so the page that provides it does not load the viewer. + */ +export const ImagePlace = createContext(undefined); diff --git a/packages/editor/src/widgets/image.tsx b/packages/editor/src/widgets/image.tsx index 39ee3b100..c527c9db4 100644 --- a/packages/editor/src/widgets/image.tsx +++ b/packages/editor/src/widgets/image.tsx @@ -1,6 +1,6 @@ /** Images load with the browser session, without sending document referrers. */ -import { useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useContext, useEffect, useRef, useState } from "react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { readOnly$ } from "@mdxeditor/editor"; import { useCellValue } from "@mdxeditor/gurx"; @@ -8,16 +8,21 @@ import { useCellValue } from "@mdxeditor/gurx"; import { ImageIcon, PencilIcon, RestartIcon } from "@chopin/icons"; import { ImageEditor } from "./image-editor"; -import { ImageLightbox } from "./image-lightbox"; +import { ImagePlace } from "./image-place"; import { useImageResize } from "./image-resize"; import type { ImageNode } from "@chopin/dialect"; +const ImageLightbox = lazy(() => + import("./image-lightbox").then(({ ImageLightbox }) => ({ default: ImageLightbox })) +); + function Image( { alt, src, width, nodeKey }: { alt: string; src: string; width: number; nodeKey: string }, ) { let [editor] = useLexicalComposerContext(); let disabled = useCellValue(readOnly$); + let place = useContext(ImagePlace); let [failed, setFailed] = useState(false); let [editing, setEditing] = useState(false); let [viewing, setViewing] = useState(false); @@ -127,13 +132,16 @@ function Image( /> )} {viewing && ( - { - setViewing(false); - requestAnimationFrame(() => viewButton.current?.focus()); - }} - /> + + { + setViewing(false); + requestAnimationFrame(() => viewButton.current?.focus()); + }} + place={place} + /> + )} ); diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 68fb40e93..211a6aef5 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -101,6 +101,7 @@ export { ProgressHalfIcon, ReactionAddIcon, RestartIcon, + RotateIcon, SignInIcon, SirenIcon, SparkleIcon, diff --git a/packages/icons/src/line.tsx b/packages/icons/src/line.tsx index 26263c97b..6ed0f6a7e 100644 --- a/packages/icons/src/line.tsx +++ b/packages/icons/src/line.tsx @@ -385,6 +385,16 @@ export function RestartIcon(props: IconProps) { ); } +/** Turn a quarter: a clockwise arc, as a viewer's rotate button draws it. */ +export function RotateIcon(props: IconProps) { + return ( + + + + + ); +} + export function MinusIcon(props: IconProps) { return ( diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index de30dff7c..dc887a250 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -374,6 +374,13 @@ "unresolved JSX props > RestartIcon > ", 1 ], + [ + "dynamic", + "*", + "props", + "unresolved JSX props > RotateIcon > ", + 1 + ], [ "dynamic", "*", @@ -501,7 +508,7 @@ 1 ] ], - "sourceHash": "03bac4455624293f63cbe1b6aa26c28919399780493de7ce59ddc489364faf6b" + "sourceHash": "7ede216ca263e151d26b03ceb7b9e50ee71b09003e6e39c58e68d3538b1b7ed4" }, { "file": "packages/icons/src/warning.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index 6956ba6de..1088dd4f7 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -368,7 +368,7 @@ 1 ] ], - "sourceHash": "8da85d1ad64e4ebb9f654f21f35d114dd42c0e2df71b2b67917dff26a2ee6619" + "sourceHash": "482f70b069e4b054f2742d2a70da39d292aff4061a858d27c41eb32860184e8b" }, { "file": "apps/web/src/chat/markdown.tsx", From ef9d362bfc9c2602a2c3e2c3e152ce30845ba6ff Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 15:32:52 -0800 Subject: [PATCH 2/2] Give the image viewer the theme's one focus ring, white on its dark ground The viewer's stylesheet drew its own focus outline and suppressed the dialog's, which apps/web/src/focus.test.ts refuses: focus geometry belongs to the theme. The viewer now uses the theme's rule like everything else. On its dark ground petrol is under 3:1, so the theme gives the viewer's controls the page's colour for `--focus-ring-color`, beside the boundary offset it already sets. The dialog itself no longer takes focus; Close does on opening, as the old lightbox did, and Tab and Shift+Tab still wrap inside the viewer. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun test apps/web/src (1662 pass, 2 skip), bun test packages/editor (1004 pass), bun test scripts packages/icons packages/visuals (284 pass) Assistant-verification: bun run types, bun run ci Assistant-verification: chat-media, chat-images, chat-images-gallery, chat-attachments, images, decision-option-previews on 8634eefaf (95 passed after the images spec's colour serialization fix) Assistant-verification: bun run design:behavior e2e/design/chat-media.e2e.ts e2e/design/cursors.e2e.ts (22 passed) Assistant-verification: bun run build (initial JS 259,958 B raw / 82,484 B gzip, initial CSS 184,636 B raw / 32,692 B gzip) User-preference: the image click view should look something like Slack's, but remove everything to the right of the download button on the bottom right hand corner Co-authored-by: Alex Lavaee --- apps/web/DESIGN.md | 2 +- apps/web/src/theme.css | 5 +++++ e2e/images.e2e.ts | 10 ++++++---- packages/editor/src/widgets/image-lightbox.css | 11 ----------- packages/editor/src/widgets/image-lightbox.tsx | 5 +++-- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 6f8428580..b70976d69 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -222,7 +222,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Chat images:** Every image in a Chat message — an agent turn's image part, one written in Markdown, or an image a person attached and sent — is drawn one way, the way Slack draws images, through assistant-ui's copied `Image` and `ImageGallery` elements (recorded in `components/sources.json` with their extension-point patches; see `docs/assistant-ui.md` and `THIRD_PARTY_NOTICES.md`), restyled from outside by Chopin's own unlayered rules (`chat/image.css`) to Slack's proportions in Chopin's colours, and driven by Chopin's state through props with none of assistant-ui's runtime. A single image is a block under the message: its name (the alt text, an attachment's file name, a missing alt reading _Image_ or _Generated image_, with a small _Generated_ label and sparkle where the Planner made it) on a line of its own in the compact role and tertiary ink 4px above it, as Slack's file meta line is, whole however long; then the picture in a frame with a 1px hairline (`--color-button-outline-edge`) and 8px corners, fitted whole inside Slack's 480 by 360 and never drawn larger than it is; then any caption in tertiary ink. Its shape is held from the first paint where it is known (stated, or read from a data address's own bytes, turned a quarter where the file says so), so nothing moves when it arrives; one whose shape is not known yet holds Slack's quiet 360 by 240 box on the inset ground, whose pulsing glyph holds still under reduced motion. An upload is read from the server's copies at Slack's thumbnail widths (`?w=480`, or the narrowest of 360 to 1024 that fills the box on the screen it is on), never as its whole original; another address is drawn as written. The image's own toolbar sits on it, 10px into its top-right corner, as Slack's does, and the message row's toolbar stays on the row: the page's surface with a hairline, a 2px inset, 8px corners and the resting shadow, holding 32px icon buttons with 4px corners for _Open image_, _Copy image_ (a check for a moment), _Download image_, and, for an address on another site, _Open image in a new tab_; it fades in over 100ms on hover or keyboard focus, is held back while the image loads, and stands beside an image narrower than itself rather than over it. Pressing the picture opens the viewer. An address on another site is always drawn from its own address: once a pointer or focus reaches the image, the page probes it once without credentials or referrer to learn whether its host lets a page read its bytes; until then, and where the host does not, copy reads _Copy link_ and download becomes an _Open image_ link in a new tab without opener or referrer; where it does, copy and download work on the bytes and say _Couldn't copy image_ or _Couldn't download image_ if the read then fails. Chopin's server never fetches an address a person wrote. A path on this site that is not an upload is read without credentials and drawn only if a raster image answers. Several images of one message (from an agent turn, sent attachments, or written together in Markdown side by side or in consecutive image-only paragraphs) are Slack's grid, at most 480px wide with 4px between tiles of 8px corners on the inset ground: two side by side as squares, three as one tall tile beside two stacked squares, four or more as two rows of two whose fourth says _+N_ for the rest, which the viewer holds. Each tile is a button named _Open image: ``_ drawn from the copy for its size. An image that cannot load is Slack's placeholder in the box the image would have taken, never smaller than 160 by 96 so it can say so: the inset ground, a 24px off-image glyph and a plain sentence, _Couldn't load image_ for a broken address or a non-raster answer, _Image can't be shown_ for an address Chat never loads (sign-in and sign-out paths, cross-document uploads or attachments, SVG, script), with the image's name on its line above; it is one `role="img"` named by the reason and the name (_Image can't be shown: Bar chart of weekly signups_) and has no toolbar. A tile that cannot load says the same in its own square. No image is ever said to be too large: an image too large to store or send is stored as a smaller copy of itself (see _Files and dictation in the composer_ and [Storage](../../docs/storage.md)). A turn's image that could not be stored for another reason appears inline in order as a refusal line with an off-image glyph, spanning the grid's columns when among tiles. Reference images (`![alt][name]`) resolve from their first definition, Markdown image titles become captions, and linked images lift out of their link so pressing the picture opens the viewer while an image-only link keeps an accessible name from the alt text or URL. - **Chat files:** A file sent with a message is Slack's file card: assistant-ui's received-files tile (`MessageAttachments`, copied, with a recorded patch for a file's own icon, its type and its actions) restyled from outside (`chat/media/media.css`) to a card at most 400px wide and 64px tall, on the page's surface with a 1px hairline and 12px corners and 12px padding, stacked 8px apart. It holds a 40px rounded square in the colour of its type with the type's 24px glyph from Chopin's icons in the page's colour (PDF the danger graphic, a document petrol, a spreadsheet success green, slides warning amber, code secondary ink, text the icon grey, an archive warning ink, an image plum, a video deep plum, audio lime, anything else tertiary ink; the type is read from the name, since Chat keeps every text file as plain text), the name in bold primary ink, and under it the type in words (_PDF_, _CSV_, _TypeScript_, _Markdown_, _File_) and the size, as spaced siblings in tertiary ink. Pressing the card opens it in the message's viewer (see _Image viewer_), as its text. On hover or focus its own toolbar, like an image's, fades in at its end: _Download_ and _Copy link_; on a touch screen it is always there. - **Chat image viewer:** All showable images of one message, and the files sent with it, share one viewer, in the order they are drawn (images first, then files): the editor's _Image viewer_, loaded only when one opens. Its header is who sent the message (their face and their handle as written, _chopin_ with its mark for a picture the Planner made), then in the muted line how long ago (_3 hours ago_), where (_in Weekly review_, _in a thread in Weekly review_ for a thread's messages) and the file's name, and, where its sender's browser sent a smaller copy, _Shown smaller. The original was 22.7 MB, 12000 × 8000._ The picture is drawn from the largest copy (1024px); Download saves the stored file, never a copy, under the name the message's own Download gives it. A text file shows as its text on a page-coloured sheet; Rotate and zoom are a picture's and are not drawn for a file. Copy stays on the image in the message: the viewer keeps Slack's single Download. -- **Image viewer:** Every picture opens in one viewer laid out as Slack's (`packages/editor/src/widgets/image-lightbox.tsx`, with its own stylesheet, loaded only when a picture opens): Chat images and files, document images and decision-card visuals. It fills the window over the picture itself, blurred 64px and dimmed (brightness 55% and 40% of `--color-gray-900` over it, on a `--color-gray-900` ground), so its words and controls are the page's colour whatever the picture is: the name in bold, the muted line in `--color-gray-300`, measured at 5:1 or more over a white picture's blur and 14:1 over a dark one. Along the top, who sent it (Chat) or the document's title (a document's picture has no sender) and, muted, when, where and its name; _Close_ at the top end. The picture is centred and fitted with a 32px margin (72px at the sides when there are arrows, none on a phone), never enlarged past its own pixels. Along the foot, at the start, _Rotate_ (a quarter clockwise) in its own quiet group, then _Zoom out_, the _Zoom_ slider (logarithmic, from fitted to eight times that) and _Zoom in_; at the end _Download_ alone, with nothing to its right. +, = and - zoom, 0 fits; the wheel and a trackpad's or a phone's pinch zoom about the pointer; a zoomed picture pans under a drag only as far as its edges; a double-click goes between fitted and the picture's own pixels. Several pictures or files step with round _Previous_ and _Next_ buttons on a dark ground of their own halfway down the window's sides (an arrow with nowhere to go is not drawn, and stays named and disabled) and with ArrowLeft and ArrowRight, and _2 of 5_ is said as a status at the foot. Every control is named and says its name in the shared tooltip, drawn inside the viewer; controls are 36px, 44px on a touch screen; safe areas pad every edge. Focus starts on the viewer, is trapped and wraps, Escape or pressing the ground closes it, and focus returns to what opened it. It opens with a 200ms fade as the picture settles from 96%, and closes in 120ms; reduced motion shows and hides it at once. Download reads the original as bytes (a signed-in upload or a data address saves as a file under its name); a picture on another site whose host refuses the read opens in a new tab without opener or referrer. An image that cannot load keeps its sentence in the middle. +- **Image viewer:** Every picture opens in one viewer laid out as Slack's (`packages/editor/src/widgets/image-lightbox.tsx`, with its own stylesheet, loaded only when a picture opens): Chat images and files, document images and decision-card visuals. It fills the window over the picture itself, blurred 64px and dimmed (brightness 55% and 40% of `--color-gray-900` over it, on a `--color-gray-900` ground), so its words and controls are the page's colour whatever the picture is: the name in bold, the muted line in `--color-gray-300`, measured at 5:1 or more over a white picture's blur and 14:1 over a dark one. Along the top, who sent it (Chat) or the document's title (a document's picture has no sender) and, muted, when, where and its name; _Close_ at the top end. The picture is centred and fitted with a 32px margin (72px at the sides when there are arrows, none on a phone), never enlarged past its own pixels. Along the foot, at the start, _Rotate_ (a quarter clockwise) in its own quiet group, then _Zoom out_, the _Zoom_ slider (logarithmic, from fitted to eight times that) and _Zoom in_; at the end _Download_ alone, with nothing to its right. +, = and - zoom, 0 fits; the wheel and a trackpad's or a phone's pinch zoom about the pointer; a zoomed picture pans under a drag only as far as its edges; a double-click goes between fitted and the picture's own pixels. Several pictures or files step with round _Previous_ and _Next_ buttons on a dark ground of their own halfway down the window's sides (an arrow with nowhere to go is not drawn, and stays named and disabled) and with ArrowLeft and ArrowRight, and _2 of 5_ is said as a status at the foot. Every control is named and says its name in the shared tooltip, drawn inside the viewer; controls are 36px, 44px on a touch screen; safe areas pad every edge. Focus starts on _Close_, is trapped and wraps, its ring white on the dark ground (the theme gives the viewer's controls `--color-page` for `--focus-ring-color`), Escape or pressing the ground closes it, and focus returns to what opened it. It opens with a 200ms fade as the picture settles from 96%, and closes in 120ms; reduced motion shows and hides it at once. Download reads the original as bytes (a signed-in upload or a data address saves as a file under its name); a picture on another site whose host refuses the read opens in a new tab without opener or referrer. An image that cannot load keeps its sentence in the middle. - **Decision decks:** Adjacent decisions stack as a deck. The top card is live; up to four cards behind it show as blank, inert edges, each nearer one showing more of itself. A capsule pager under the stack (previous, the top card's place, next) and Left and Right arrows page through the deck and stop at either end. Next drops the top card a short way as it fades and the card behind rises into its place with its content fading in; previous slides it back. Answered decisions settle to the back and keep an open card's full layout, with the chosen option selected. Moves animate transform and opacity, plus a clip and the leaving card's height so a taller card never covers the pager. Reduced motion crossfades without movement. Deck order is each reader's own. On desktop, Decisions shows open decisions as the same deck when two or more wait, with resolved decisions listed below in an accordion that opens by default; phones keep the single-pane list and save bar. A blank document whose Planner asks decisions opens Decisions once per visit, unless the reader is editing the document. - **Unfold:** `UnfoldSection` uses Base UI Accordion for plain disclosure; card previews use the shared Unfold primitives. Both use the Chevron icon from the icon system. Native `details` and `summary` elements are not used. A button with `aria-expanded` and `aria-controls` folds a mounted panel open through its grid row while the content fades and tips down from its top edge, with the shared ease-out: card previews take the linger duration, plain accordions the accordion duration. A second press reverses from the current frame. Closed content is inert and hidden once the fold ends. Reduced motion snaps the height and keeps a short crossfade. A card preview never replaces the card's own open action. diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index 8348b3d82..24453a7e6 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -2434,6 +2434,11 @@ textarea.question-written-field { --focus-ring-offset: -2px; } +/* The image viewer's controls sit on a dark ground, where petrol is under 3:1: theirs is white. */ +:is(.image-viewer-control, .image-viewer-slider) { + --focus-ring-color: var(--color-page); +} + :where(a, button, input, select, summary, textarea, [role="button"], [tabindex]):is(:focus-visible, [data-force-state="focus"]) { outline: var(--focus-ring-width) solid var(--focus-ring-color); outline-offset: var(--focus-ring-offset); diff --git a/e2e/images.e2e.ts b/e2e/images.e2e.ts index d908b125d..964e38943 100644 --- a/e2e/images.e2e.ts +++ b/e2e/images.e2e.ts @@ -77,10 +77,12 @@ test("images open a keyboard-accessible lightbox and return focus on close", asy .toBeFocused(); await page.keyboard.press("Enter"); await expect(preview).toBeVisible(); - // Focus starts on the viewer, and Tab reaches its controls without leaving it. - await expect(preview).toBeFocused(); - await page.keyboard.press("Tab"); - await expect(preview.getByRole("button", { name: "Close", exact: true })).toBeFocused(); + // Focus starts on Close, and Tab reaches the controls without leaving the viewer. On its dark + // ground the focus ring is the page's colour, not petrol. + let close = preview.getByRole("button", { name: "Close", exact: true }); + await expect(close).toBeFocused(); + await expect(close).toHaveCSS("outline-style", "solid"); + await expect(close).toHaveCSS("outline-color", "oklch(1 0 0)"); await page.keyboard.press("Shift+Tab"); await expect(preview.getByRole("button", { name: "Download image", exact: true })).toBeFocused(); await page.keyboard.press("Tab"); diff --git a/packages/editor/src/widgets/image-lightbox.css b/packages/editor/src/widgets/image-lightbox.css index 07e746434..37e037028 100644 --- a/packages/editor/src/widgets/image-lightbox.css +++ b/packages/editor/src/widgets/image-lightbox.css @@ -41,10 +41,6 @@ background: transparent; } -.image-viewer:focus { - outline: none; -} - .image-viewer-backdrop { position: absolute; inset: 0; @@ -167,13 +163,6 @@ background: var(--image-viewer-control-hover); } -.image-viewer-control:focus-visible, -.image-viewer-slider:focus-visible, -.image-viewer-text:focus-visible { - outline: var(--focus-ring-width) solid var(--image-viewer-ink); - outline-offset: 1px; -} - .image-viewer-control:disabled { opacity: 0.4; cursor: default; diff --git a/packages/editor/src/widgets/image-lightbox.tsx b/packages/editor/src/widgets/image-lightbox.tsx index 81213d0be..ea4583b20 100644 --- a/packages/editor/src/widgets/image-lightbox.tsx +++ b/packages/editor/src/widgets/image-lightbox.tsx @@ -210,6 +210,7 @@ export function ImageLightbox( ) { let dialog = useRef(null); let stage = useRef(null); + let closer = useRef(null); let previous = useRef(null); let next = useRef(null); let [index, setIndex] = useState(() => stepImage(start, 0, images.length)); @@ -255,7 +256,7 @@ export function ImageLightbox( let element = dialog.current; let opener = document.activeElement; element?.showModal(); - element?.focus(); + closer.current?.focus(); return () => { if (element?.open) element.close(); if (opener instanceof HTMLElement && opener.isConnected) { @@ -425,7 +426,6 @@ export function ImageLightbox( data-closing={closing ? "" : undefined} data-focus-boundary="" data-image-viewer="" - tabIndex={-1} onCancel={event => { event.preventDefault(); close(); @@ -468,6 +468,7 @@ export function ImageLightbox( {picture?.note &&

{picture.note}

}