diff --git a/agents/mcp-tools.ts b/agents/mcp-tools.ts index e6e763d2..3485320d 100644 --- a/agents/mcp-tools.ts +++ b/agents/mcp-tools.ts @@ -245,7 +245,7 @@ const docId = z.string().describe("The 8-character document id (from its URL).") const pace = z .enum(["natural", "fast", "instant"]) .optional() - .describe("How the edit is performed: natural (human-paced typing), fast, or instant."); + .describe("How the edit is performed: natural (typed out, with pauses at sentence ends), fast (typed out, no pauses), or instant."); const anchorDesc = "A block anchor from read_document, e.g. k3f0a9x2-a91f0c2d: a persistent block id plus the block's content hash. The id survives edits; a changed hash returns stale_block with the block's current state so you can retry without a full re-read."; diff --git a/app/app.css b/app/app.css index 5e4cf60e..6711755c 100644 --- a/app/app.css +++ b/app/app.css @@ -179,29 +179,91 @@ body { margin: 1rem 0 0.5rem; } -/* Attachments: an image with a caption, or a chip that downloads. The +/* Attachments: an image on its own, or a chip that downloads. The selected state matches the code-block chrome. */ .tiptap .attachment { max-width: 65ch; margin: 0 0 1rem; } +/* Image layout (#109). The width is an inline style, because a percent can + be any value; alignment and full bleed are rules. A left or right aligned + image floats and body text wraps beside it, capped at a third of the column + so the text keeps a readable measure; Paper caps its own floats at a third. */ +.tiptap .attachment[data-align="left"] { + float: left; + max-width: 33.333%; + margin: 0 1.25rem 0.75rem 0; +} +.tiptap .attachment[data-align="right"] { + float: right; + max-width: 33.333%; + margin: 0 0 0.75rem 1.25rem; +} +/* Shrink to the image so the auto margins have something to centre; an + explicit inline width overrides this. */ +.tiptap .attachment[data-align="center"] { + width: fit-content; + margin-inline: auto; +} +/* The editor is a centred max-w-3xl column inside .doc-frame, so full bleed + has to break out of it. Container units measure .doc-frame, which is the + space beside the comment rail; the viewport would be wrong whenever the + rail is showing. The negative inline margin re-centres the wider box. */ +.doc-frame { + container-type: inline-size; + container-name: doc-frame; +} +.tiptap .attachment[data-width="full"] { + max-width: none; + width: min(100cqw - 3rem, 1600px); + margin-inline: calc((100% - min(100cqw - 3rem, 1600px)) / 2); +} +@supports not (width: 1cqw) { + .tiptap .attachment[data-width="full"] { + width: 100%; + margin-inline: 0; + } +} + +/* A float runs beside body text and stops at the next structural boundary, + not at an arbitrary paragraph. Floated attachments are exempt, or each + would push the one before it down instead of sitting where it was put. */ +.tiptap h1, +.tiptap h2, +.tiptap h3, +.tiptap hr, +.tiptap table, +.tiptap pre, +.tiptap blockquote, +.tiptap .attachment:not([data-align="left"]):not([data-align="right"]) { + clear: both; +} + +/* Below the phone breakpoint a 50% float leaves too little text beside it to + read, so alignment drops and every image takes its own line. */ +@media (max-width: 640px) { + .tiptap .attachment[data-align="left"], + .tiptap .attachment[data-align="right"] { + float: none; + width: 100%; + max-width: 100%; + margin: 0 0 1rem; + } +} + .tiptap .attachment.is-selected > * { outline: 2px solid var(--color-ink); outline-offset: 2px; } +/* A tall image should not push everything else off the screen, so it is capped + at 80% of the viewport whatever its width says. */ .tiptap .attachment-image img { display: block; max-width: 100%; + max-height: 80vh; height: auto; border-radius: 0.25rem; } -.tiptap .attachment-image figcaption { - display: flex; - gap: 0.75rem; - margin-top: 0.35rem; - font-size: 0.85rem; - color: var(--color-muted); -} .tiptap .attachment-chip { display: inline-flex; max-width: 100%; @@ -234,28 +296,33 @@ body { margin: 0 0 0.5rem; } -/* Rich-node typographic defaults (imported from the notes app) */ +/* Dropbox Paper's extended-headings scale: 30px/36px at -0.4px, 24px/30px at + -0.2px, 20px/26px, all at weight 600, with Paper's own margins. Sized in em + against the editor's 16px body, not rem: the root here is 14px, which would + shrink the whole scale. The type is Paper's; the face stays the system sans. */ .tiptap h1 { - font-size: 1.875rem; - font-weight: bold; + font-size: 1.875em; + font-weight: 600; line-height: 1.2; - margin: 1.5rem 0 1rem; + letter-spacing: -0.013em; + margin: 30px 0 12px; max-width: 65ch; } .tiptap h2 { - font-size: 1.5rem; - font-weight: bold; - line-height: 1.3; - margin: 1.25rem 0 0.75rem; + font-size: 1.5em; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.008em; + margin: 26px 0 10px; max-width: 65ch; } .tiptap h3 { - font-size: 1.25rem; - font-weight: bold; + font-size: 1.25em; + font-weight: 600; line-height: 1.3; - margin: 1rem 0 0.5rem; + margin: 24px 0 8px; max-width: 65ch; } diff --git a/app/components/AttachmentView.tsx b/app/components/AttachmentView.tsx index 9a8301a2..48ccee32 100644 --- a/app/components/AttachmentView.tsx +++ b/app/components/AttachmentView.tsx @@ -1,26 +1,41 @@ import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import { formatBytes } from "~/shared/attachment-policy"; +import { isFullWidth, type ImageAlign } from "~/shared/image-layout"; import Icon from "~/components/Icon"; /** - * How an attachment block looks in the editor: an image with a caption of - * its name and size, or a chip that downloads the file. Selection matches - * the code-block chrome (see .attachment in app.css). + * How an attachment block looks in the editor: an image on its own, or a chip + * that downloads the file. The filename stays as the image's alt text and is + * not drawn under it. Selection matches the code-block chrome (see .attachment + * in app.css). */ export default function AttachmentView({ node, selected }: NodeViewProps) { - const { kind, src, alt } = node.attrs as { kind: "image" | "file"; src: string; alt: string }; + const { kind, src, alt, width, align } = node.attrs as { + kind: "image" | "file"; + src: string; + alt: string; + width: string | null; + align: ImageAlign | null; + }; const bytes = node.attrs.bytes === null || node.attrs.bytes === undefined ? null : Number(node.attrs.bytes); const size = bytes !== null && Number.isFinite(bytes) ? formatBytes(bytes) : null; + // A percent can be any value, so it cannot be a static rule and is set + // inline; `full` is a stylesheet case because it escapes the column. + const fullBleed = isFullWidth({ width }); + return ( - + {kind === "image" ? (
{alt} -
- {alt} - {size && {size}} -
) : ( diff --git a/app/components/BubbleToolbar.tsx b/app/components/BubbleToolbar.tsx index 80625d5a..223bf91c 100644 --- a/app/components/BubbleToolbar.tsx +++ b/app/components/BubbleToolbar.tsx @@ -1,14 +1,18 @@ import { BubbleMenu } from "@tiptap/react/menus"; +import { useEditorState } from "@tiptap/react"; import { getMarkRange, isMarkActive, type Editor as TiptapEditor } from "@tiptap/core"; import type { EditorState } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; import { processRangeAtCursor } from "~/lib/suggestion-actions"; +import { selectedImageAttachment, setImageLayout } from "~/lib/image-commands"; +import { isFullWidth, type ImageAlign } from "~/shared/image-layout"; import Icon from "~/components/Icon"; type BubbleContext = | { kind: "selection" } | { kind: "suggestion" } | { kind: "annotation" } + | { kind: "attachment" } | null; /** @@ -28,6 +32,10 @@ function getContext(state: EditorState): BubbleContext { } function detectContext(state: EditorState): BubbleContext { + // Before the text checks: a node selection on an atom spans no text, so + // the emptiness and textBetween gates below would both reject it. + if (selectedImageAttachment(state)) return { kind: "attachment" }; + const { from, to, empty } = state.selection; if (empty) { @@ -102,6 +110,11 @@ const shouldShowSuggestion = ({ editor, element, view, state }: ShouldShowProps) return getContext(state)?.kind === "suggestion"; }; +const shouldShowAttachment = ({ editor, element, view, state }: ShouldShowProps) => { + if (!baseChecks(view, element, editor)) return false; + return getContext(state)?.kind === "attachment"; +}; + // 44px square icon buttons at every width: Accept/Reject sit side by side // and a mis-tap on track changes is destructive. const btnClass = @@ -109,6 +122,65 @@ const btnClass = const menuClass = "bubble-menu flex bg-ink shadow-md"; +// Narrower than the 44px square buttons: a mis-tap here is one undo, not a +// lost suggestion. +const stepClass = + "flex h-[44px] min-w-[40px] items-center justify-center px-2 text-[13px] text-paper transition-colors hover:bg-paper/15 cursor-pointer"; + +const ALIGNS: { value: ImageAlign; icon: string; label: string }[] = [ + { value: "left", icon: "format_image_left", label: "Align left" }, + { value: "center", icon: "format_align_center", label: "Align centre" }, + { value: "right", icon: "format_image_right", label: "Align right" }, +]; + +export function ImageLayoutButtons({ editor }: { editor: TiptapEditor }) { + // BubbleMenu renders its children into a portal and does not re-render them + // per transaction, so the pressed state has to subscribe to the editor. + const { width, align } = useEditorState({ + editor, + selector: ({ editor: ed }) => { + const attrs = selectedImageAttachment(ed.state)?.node.attrs ?? {}; + return { + width: (attrs.width ?? null) as string | null, + align: (attrs.align ?? null) as ImageAlign | null, + }; + }, + }); + const on = (active: boolean) => `${stepClass}${active ? " bg-paper/25" : ""}`; + + // An image is its natural size unless the markdown carries a width, so the + // bubble sets no percentages: it toggles full bleed and picks an alignment. + const full = isFullWidth({ width }); + + return ( + <> + + {ALIGNS.map((a) => ( + + ))} + + ); +} + // flip/shift keep the menu on screen when the keyboard or viewport edge // would otherwise cover it. const menuOptions = { placement: "bottom" as const, offset: { mainAxis: 8 }, flip: true, shift: true }; @@ -163,6 +235,18 @@ export default function BubbleToolbar({ editor, onNewComment }: { editor: Tiptap + + {/* A selected image → width and alignment */} + + + ); } diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 294a9a87..f5b98f13 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -423,7 +423,10 @@ export default function Editor({ return ( <>
{ // Blocks cascade in; the class leaves once the last one has landed. diff --git a/app/components/HeaderMenu.tsx b/app/components/HeaderMenu.tsx index e1b0f37a..53c7ba1c 100644 --- a/app/components/HeaderMenu.tsx +++ b/app/components/HeaderMenu.tsx @@ -134,7 +134,7 @@ export default function HeaderMenu({ - + } /> diff --git a/app/lib/attachment.ts b/app/lib/attachment.ts index 5c168dd7..a30e2d06 100644 --- a/app/lib/attachment.ts +++ b/app/lib/attachment.ts @@ -22,6 +22,8 @@ export const Attachment = Node.create({ src: { default: "", rendered: false }, alt: { default: "", rendered: false }, bytes: { default: null, rendered: false }, + width: { default: null, rendered: false }, + align: { default: null, rendered: false }, }; }, @@ -34,6 +36,8 @@ export const Attachment = Node.create({ src: el.getAttribute("data-src") ?? "", alt: el.getAttribute("data-alt") ?? "", bytes: el.getAttribute("data-bytes") ? Number(el.getAttribute("data-bytes")) : null, + width: el.getAttribute("data-width"), + align: el.getAttribute("data-align"), }), }, ]; @@ -47,6 +51,8 @@ export const Attachment = Node.create({ "data-src": node.attrs.src, "data-alt": node.attrs.alt, "data-bytes": node.attrs.bytes ?? undefined, + "data-width": node.attrs.width ?? undefined, + "data-align": node.attrs.align ?? undefined, }), ]; }, diff --git a/app/lib/image-commands.ts b/app/lib/image-commands.ts new file mode 100644 index 00000000..b83b02e7 --- /dev/null +++ b/app/lib/image-commands.ts @@ -0,0 +1,29 @@ +import { NodeSelection } from "@tiptap/pm/state"; +import type { Editor } from "@tiptap/core"; +import type { EditorState } from "@tiptap/pm/state"; +import type { Node as PmNode } from "@tiptap/pm/model"; +import type { ImageLayout } from "~/shared/image-layout"; + +/** + * The image attachment a node selection is on, or null. File attachments are + * excluded: a download chip has no width or alignment to set (#109). + */ +export function selectedImageAttachment(state: EditorState): { node: PmNode; pos: number } | null { + const selection = state.selection; + if (!(selection instanceof NodeSelection)) return null; + const node = selection.node; + if (node.type.name !== "attachment" || node.attrs.kind !== "image") return null; + return { node, pos: selection.from }; +} + +/** + * Writes width, alignment, or both onto the selected image. One call per + * choice, so each is a single Yjs update rather than a stream of them. + */ +export function setImageLayout(editor: Editor, patch: Partial): boolean { + const selected = selectedImageAttachment(editor.state); + if (!selected) return false; + const tr = editor.state.tr.setNodeMarkup(selected.pos, undefined, { ...selected.node.attrs, ...patch }); + editor.view.dispatch(tr); + return true; +} diff --git a/app/lib/performance-chunks.ts b/app/lib/performance-chunks.ts index 82c9d2b2..4f2276c0 100644 --- a/app/lib/performance-chunks.ts +++ b/app/lib/performance-chunks.ts @@ -11,10 +11,13 @@ const SENTENCE_ENDINGS = new Set([".", "!", "?", "\n"]); * the delay before the *next* chunk — used to simulate an agent typing into * the document instead of pasting it in one shot. * - * - `"natural"`: 2-4 chars/tick, 180-320ms base delay (~10 chars/s, a - * brisk human typist); an extra 300-900ms pause is added after a tick - * ending in ".", "!", "?", or "\n". - * - `"fast"`: 6-12 chars/tick, 20-40ms delay; no sentence pauses. + * - `"natural"`: 2-4 chars/tick, 60-107ms base delay (~36 chars/s); an extra + * 100-300ms pause is added after a tick ending in ".", "!", "?", or "\n". + * - `"fast"`: 6-12 chars/tick, 7-13ms delay; no sentence pauses. + * + * Both paces were tripled on 2026-09-13 at Nicholas's request. "natural" is + * named for its shape, not its rate: 36 chars/s is roughly 430 words a + * minute, well past any typist. * * `rng` defaults to `Math.random` and is injectable so tests can produce * deterministic output (e.g. `() => 0.5`). @@ -27,7 +30,7 @@ export function chunkTyping( const ticks: TypingTick[] = []; const [minChars, maxChars, minDelay, maxDelay] = - pace === "fast" ? [6, 12, 20, 40] : [2, 4, 180, 320]; + pace === "fast" ? [6, 12, 7, 13] : [2, 4, 60, 107]; let cursor = 0; // Carried from a sentence-ending chunk onto the delay of the *next* @@ -55,7 +58,7 @@ export function chunkTyping( const delayMs = minDelay + Math.floor(rng() * (maxDelay - minDelay + 1)) + extraDelayForNext; extraDelayForNext = 0; if (pace === "natural" && SENTENCE_ENDINGS.has(chunk[chunk.length - 1])) { - extraDelayForNext = 300 + Math.floor(rng() * 601); + extraDelayForNext = 100 + Math.floor(rng() * 201); } ticks.push({ chunk, delayMs }); diff --git a/app/lib/useThreads.ts b/app/lib/useThreads.ts index 7688d3c7..c13e5664 100644 --- a/app/lib/useThreads.ts +++ b/app/lib/useThreads.ts @@ -317,9 +317,13 @@ export function useThreads({ const thread: ThreadData = JSON.parse(raw); if (!thread.resolved) { - // Resolving: remove inline markup, mark resolved + // Resolving: remove inline markup, mark resolved, and drop the + // selection. The rail and the sheet both keep the active thread + // visible whatever its state, so without this the card sits there + // until something else is clicked (#111). removeInlineComment(thread); thread.resolved = true; + setActiveThreadId((prev) => (prev === threadId ? null : prev)); } else { // Unresolving: just toggle back (markup already removed) thread.resolved = false; diff --git a/app/root.tsx b/app/root.tsx index ba015426..f9982553 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -50,7 +50,7 @@ export const links: Route.LinksFunction = () => [ // Subset to the icon names actually used — keep this list sorted and in // sync with usages or new glyphs render as raw text. rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=add,add_2,add_comment,alternate_email,attach_file,check,checklist,chevron_left,chevron_right,close,code,comment,computer,content_copy,dark_mode,delete,done_all,download,edit,expand_more,format_bold,format_h1,format_h2,format_h3,format_italic,format_list_bulleted,format_list_numbered,format_paragraph,format_quote,format_size,history,horizontal_rule,ios_share,light_mode,link,login,logout,menu_book,mobile,mode_comment,more_vert,note_add,open_in_new,rate_review,redo,remove_done,robot_2,send,strikethrough_s,table,undo,upload_file&display=block", + href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=add,add_2,add_comment,alternate_email,attach_file,check,checklist,chevron_left,chevron_right,close,code,computer,content_copy,dark_mode,delete,done_all,download,edit,expand_more,format_align_center,format_bold,format_h1,format_h2,format_h3,format_image_left,format_image_right,format_italic,format_list_bulleted,format_list_numbered,format_paragraph,format_quote,format_size,history,horizontal_rule,ios_share,light_mode,link,login,logout,menu,menu_book,mobile,mode_comment,more_vert,note_add,open_in_new,rate_review,redo,remove_done,robot_2,send,strikethrough_s,table,undo,upload_file,width_full&display=block", }, ]; diff --git a/app/shared/epub.ts b/app/shared/epub.ts index 653f5f4a..5fa55502 100644 --- a/app/shared/epub.ts +++ b/app/shared/epub.ts @@ -2,6 +2,7 @@ import MarkdownIt from "markdown-it"; import { zipSync, strToU8 } from "fflate"; import { stripMentionIds } from "./agent-protocol"; import { parseAttachmentUrl } from "./attachment-policy"; +import { parseImageLayout, imageLayoutStyle } from "./image-layout"; import { titleFromMarkdown } from "./doc-url"; /** @@ -59,7 +60,35 @@ export function attachmentImages(markdown: string): { path: string; id: string; return out; } +type EpubToken = { + type: string; + content: string; + children: EpubToken[] | null; + attrSet(name: string, value: string): void; +}; + +/** + * Turns an image's `{width=… align=…}` block into inline CSS on the `` + * and removes the text, so exports carry the layout rather than printing the + * braces. Mirrors `attachmentRule` in rich-markdown.ts (#109). + */ +function imageLayoutRule(state: { tokens: EpubToken[] }): void { + for (const token of state.tokens) { + if (token.type !== "inline" || !token.children) continue; + const kids = token.children; + for (let i = 0; i < kids.length - 1; i++) { + if (kids[i].type !== "image" || kids[i + 1].type !== "text") continue; + const layout = parseImageLayout(kids[i + 1].content); + if (!layout) continue; + const style = imageLayoutStyle(layout); + if (style) kids[i].attrSet("style", style); + kids[i + 1].content = ""; + } + } +} + const md = new MarkdownIt({ html: false, linkify: true, xhtmlOut: true }); +md.core.ruler.after("inline", "imageLayout", imageLayoutRule as never); function escapeXml(text: string): string { return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); @@ -98,10 +127,13 @@ export function epubChapterHtml(markdown: string, images: EpubImage[]): string { */ export const READING_CSS = `body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1em; line-height: 1.6; color: #1a1a1a; margin: 0 auto; padding: 1.5em; max-width: 42em; } p { margin: 0 0 0.6em; } -h1, h2, h3, h4 { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-weight: bold; line-height: 1.2; } -h1 { font-size: 1.875em; margin: 1.5em 0 0.6em; } -h2 { font-size: 1.5em; line-height: 1.3; margin: 1.25em 0 0.5em; } -h3, h4 { font-size: 1.25em; line-height: 1.3; margin: 1em 0 0.4em; } +/* Dropbox Paper's extended-headings scale, as ems of the body size: 30px/36px + at -0.4px, 24px/30px at -0.2px, 20px/26px, all at weight 600. The type is + Paper's; the face stays the system sans. */ +h1, h2, h3, h4 { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-weight: 600; } +h1 { font-size: 1.875em; line-height: 1.2; letter-spacing: -0.013em; margin: 1em 0 0.4em; } +h2 { font-size: 1.5em; line-height: 1.25; letter-spacing: -0.008em; margin: 1.08em 0 0.42em; } +h3, h4 { font-size: 1.25em; line-height: 1.3; margin: 1.2em 0 0.4em; } body > h1:first-child { font-size: 2.5em; font-weight: 500; line-height: 1.1; margin-top: 0; } h1:first-child, h2:first-child, h3:first-child { margin-top: 0; } ul, ol { margin: 0 0 0.6em 1.5em; padding: 0; } @@ -114,7 +146,10 @@ pre { background: #f2f2f2; padding: 0.75em 1em; border-radius: 4px; overflow-x: pre code { background: none; padding: 0; } a { color: inherit; text-decoration: underline; text-decoration-color: #999; } hr { border: 0; border-top: 1px solid #d9d9d9; margin: 1.5em 0; } -img { max-width: 100%; height: auto; } +img { max-width: 100%; max-height: 80vh; height: auto; } +/* A floated image runs beside body text and stops at the next structural + boundary, not at an arbitrary paragraph (#109). */ +h1, h2, h3, hr, table, pre, blockquote { clear: both; } table { border-collapse: collapse; margin: 0 0 0.75em; font-size: 0.95em; } th, td { border: 1px solid #d9d9d9; padding: 0.3em 0.6em; text-align: left; vertical-align: top; } th { font-weight: bold; background: #f7f7f7; } diff --git a/app/shared/image-layout.ts b/app/shared/image-layout.ts new file mode 100644 index 00000000..0c956cbd --- /dev/null +++ b/app/shared/image-layout.ts @@ -0,0 +1,61 @@ +/** + * Width and alignment for an image attachment, carried in the markdown as a + * Pandoc-style attribute block after the image: + * `![cat.png](/doc/attachments/id/cat.png){width=50% align=left}`. + * + * Pure and free of ProseMirror, so the editor's schema and the EPUB and print + * exports can share it (docs/plans/2026-09-13-image-sizing-design.md). + */ + +export type ImageAlign = "left" | "center" | "right"; +export type ImageLayout = { width: string | null; align: ImageAlign | null }; + +const WIDTH_RE = /^(?:full|(?:100|[1-9]\d?)%)$/; +const ALIGNS = new Set(["left", "center", "right"]); +const ATTR_BLOCK_RE = /^\{([^}]*)\}$/; + +/** + * Reads an attribute block. Only `width` and `align` are kept; an unknown key + * or an out-of-range value is dropped rather than carried, so a node never + * holds what it cannot serialise back. Returns null when the text is not + * brace-shaped, which leaves the paragraph as it was. + */ +export function parseImageLayout(text: string): ImageLayout | null { + const block = ATTR_BLOCK_RE.exec(text.trim()); + if (!block) return null; + const layout: ImageLayout = { width: null, align: null }; + for (const [, key, value] of block[1].matchAll(/([a-z]+)=([^\s}]+)/g)) { + if (key === "width" && WIDTH_RE.test(value)) layout.width = value; + else if (key === "align" && ALIGNS.has(value)) layout.align = value as ImageAlign; + } + return layout; +} + +/** The attribute block for a layout, or "" when it carries none. */ +export function serializeImageLayout(layout: Partial): string { + const parts: string[] = []; + if (layout.width) parts.push(`width=${layout.width}`); + if (layout.align) parts.push(`align=${layout.align}`); + return parts.length ? `{${parts.join(" ")}}` : ""; +} + +/** A full-width image has no room to wrap beside it, so it ignores align. */ +export function isFullWidth(layout: Partial): boolean { + return layout.width === "full" || layout.width === "100%"; +} + +/** + * The layout as inline CSS, for the EPUB and print exports where there is no + * stylesheet hook per image. Returns "" when there is nothing to say. + */ +export function imageLayoutStyle(layout: Partial): string { + const rules: string[] = []; + if (layout.width) rules.push(`width: ${layout.width === "full" ? "100%" : layout.width}`); + if (layout.align && !isFullWidth(layout)) { + if (layout.align === "center") rules.push("display: block", "margin-left: auto", "margin-right: auto"); + // A float wider than a third of the column leaves too narrow a measure + // for the text beside it; Dropbox Paper caps its own floats at the same third. + else rules.push(`float: ${layout.align}`, "max-width: 33.333%", `margin-${layout.align === "left" ? "right" : "left"}: 1em`, "margin-bottom: 0.5em"); + } + return rules.length ? `${rules.join("; ")};` : ""; +} diff --git a/app/shared/rich-markdown.ts b/app/shared/rich-markdown.ts index 78772e0b..58ed8ad2 100644 --- a/app/shared/rich-markdown.ts +++ b/app/shared/rich-markdown.ts @@ -17,6 +17,7 @@ import * as Y from "yjs"; import { yXmlFragmentToProseMirrorRootNode } from "@tiptap/y-tiptap"; import { blockHash, formatMention, parseAnchor as parseLegacyAnchor, type DocBlock } from "./agent-protocol"; import { parseAttachmentUrl } from "./attachment-policy"; +import { parseImageLayout, serializeImageLayout, type ImageLayout } from "./image-layout"; /* ---------- Schema (names must match the TipTap extensions) ---------- */ @@ -93,6 +94,8 @@ export const richSchema = new Schema({ src: { default: "" }, alt: { default: "" }, bytes: { default: null as number | string | null }, + width: { default: null as string | null }, + align: { default: null as "left" | "center" | "right" | null }, }, }, // GFM tables. Cells hold inline content only — one line per cell, as @@ -328,10 +331,16 @@ function attachmentRule(state: { tokens: InlineToken[]; Token: TokenCtor }): voi const meaningful = inline.children.filter((c) => !isBlank(c)); const alone = toks[i - 1]?.type === "paragraph_open" && toks[i + 1]?.type === "paragraph_close"; - let attachment: { kind: "image" | "file"; src: string; alt: string } | null = null; - if (alone && meaningful.length === 1 && meaningful[0].type === "image") { + let attachment: ({ kind: "image" | "file"; src: string; alt: string } & Partial) | null = null; + const layout = + alone && meaningful.length === 2 && meaningful[0].type === "image" && meaningful[1].type === "text" + ? parseImageLayout(meaningful[1].content) + : null; + if (alone && meaningful[0]?.type === "image" && (meaningful.length === 1 || layout)) { const parsed = parseAttachmentUrl(meaningful[0].attrGet("src") ?? ""); - if (parsed) attachment = { kind: "image", src: parsed.path, alt: meaningful[0].content }; + if (parsed) { + attachment = { kind: "image", src: parsed.path, alt: meaningful[0].content, ...(layout ?? {}) }; + } } else if ( alone && meaningful.length === 3 && @@ -351,6 +360,8 @@ function attachmentRule(state: { tokens: InlineToken[]; Token: TokenCtor }): voi tok.attrSet("kind", attachment.kind); tok.attrSet("src", attachment.src); tok.attrSet("alt", attachment.alt); + if (attachment.width) tok.attrSet("width", attachment.width); + if (attachment.align) tok.attrSet("align", attachment.align); toks.splice(i - 1, 3, tok); i -= 1; continue; @@ -465,6 +476,8 @@ export const markdownParser = new MarkdownParser(richSchema, makeMarkdownIt() as kind: tok.attrGet("kind") === "image" ? "image" : "file", src: tok.attrGet("src") ?? "", alt: tok.attrGet("alt") ?? "", + width: tok.attrGet("width"), + align: tok.attrGet("align"), }), }, hardbreak: { node: "hardBreak" }, @@ -532,7 +545,8 @@ export const markdownSerializer = new MarkdownSerializer( attachment(state, node) { const alt = String(node.attrs.alt ?? "").replace(/[[\]]/g, "\\$&"); const src = String(node.attrs.src ?? ""); - state.write(`${node.attrs.kind === "image" ? "!" : ""}[${alt}](${src})`); + const layout = serializeImageLayout(node.attrs as ImageLayout); + state.write(`${node.attrs.kind === "image" ? "!" : ""}[${alt}](${src})${layout}`); state.closeBlock(node); }, table(state, node) { diff --git a/docs/markdown-and-criticmarkup.md b/docs/markdown-and-criticmarkup.md index 1facaebd..84064120 100644 --- a/docs/markdown-and-criticmarkup.md +++ b/docs/markdown-and-criticmarkup.md @@ -171,5 +171,9 @@ Versions are taken when typing settles (60s idle), when the size swings by more A file dropped, pasted, or picked into a document is stored in R2 under `/` and lands in the text as an `attachment` block: images render inline, other files as a chip. Its canonical markdown is an image or a link standing alone in a paragraph at the attachment path, `![cat.png](/abcd1234/attachments//cat.png)` or `[report.pdf](/abcd1234/attachments//report.pdf)`. Only that path shape becomes an attachment; any other image stays literal text (a public document embeds no foreign images) and any other link stays a link. `/:id.md` and Download rewrite the paths to absolute URLs on the request's origin; an upload accepts either form. +An image may carry a Pandoc-style attribute block giving its width and its alignment, `![cat.png](/abcd1234/attachments//cat.png){width=50% align=left}`. Both keys are optional. With no `width` the image renders at its natural size, capped by the column; `width` is a percent from 1% to 100% or the token `full`, which fills the page past the text column. `align` is `left`, `center`, or `right`, and left and right float the image so body text wraps beside it until the next heading, rule, table, code block, or blockquote. A floated image is capped at a third of the column whatever its `width` says, so the text beside it keeps a readable measure, and every image is capped at 80% of the viewport height, so a tall one cannot take the screen. Both caps hold the aspect ratio: a 400x3000 image at `width=100%` renders 82px wide, because the height is the binding constraint. A full-width image ignores `align`, having no room to wrap beside it, and below 640px alignment drops and every image takes its own line. Unknown keys and out-of-range values are dropped on parse rather than carried. In the EPUB and on the print page `full` means 100% of the text width, not wider: those pages are a centred 42em column with no canvas outside it. + +The image bubble sets no percentages: it toggles full bleed and picks an alignment, and a width other than `full` comes from the markdown. The filename is the image's `alt`; it is not drawn under the image. + Uploading needs a principal: a Google sign-in for people, the OAuth `/mcp` endpoint with `write` for agents. Viewing needs nothing. Limits and allowed types live in `app/shared/attachment-policy.ts` (20 MB a file, 100 MB and 100 files a document, 500 MB and 200 uploads per account a day; images, PDF, text, CSV, JSON, zip, and office formats, sniffed server-side). Attachments expire with the document. diff --git a/docs/plans/2026-09-13-image-sizing-design.md b/docs/plans/2026-09-13-image-sizing-design.md new file mode 100644 index 00000000..563587d9 --- /dev/null +++ b/docs/plans/2026-09-13-image-sizing-design.md @@ -0,0 +1,141 @@ +# Image sizing and placement + +**Issue:** [#109](https://github.com/arfct/vapor/issues/109). **Branch:** `feat/109-image-sizing`. + +**Goal:** A person selects an image in a document and picks its width and its alignment from a small set, including one width that fills the page past the text column. The choice survives `/:id.md`, the EPUB, the print page, and an agent's read over MCP, because it is written into the markdown rather than held beside it. + +**Relationship to other plans:** extends the [attachments plan](2026-09-05-attachments-plan.md), which listed "image resizing or thumbnails" under Out of scope. Nothing here changes upload, storage, serving, or expiry. It adds two attributes to the `attachment` node and a rendering path for them. + +## Decisions + +### 1. Layout metadata rides in a Pandoc-style attribute block + +The form is an image, alone in a paragraph, followed by a brace block: + +```markdown +![cat.png](/abc/attachments/x1y2/cat.png){width=50% align=left} +![banner.png](/abc/attachments/x1y2/banner.png){width=full} +``` + +`width` is a percent from 1% to 100%, or the token `full`. `align` is `left`, `center`, or `right`. Both are optional and independent. `align` has no visible effect at `width=100%` or `width=full`, where the block already fills its container; it is stored and serialized anyway rather than being stripped, so that lowering the width restores the alignment the person last chose. + +Three other conventions were considered. The **title slot** (`![cat.png](/… "w=50% left")`) and the **Obsidian alt pipe** (`![cat.png|320](/…)`) both render clean in a foreign renderer, which is their whole case: a document pasted into GitHub shows an image and no stray text. Each pays for it by hiding layout data in a field that already means something else, the tooltip and the accessibility string respectively. **Class tokens** (`{.half .left}`) map one to one onto CSS class names in all three stylesheets and cost almost no translation layer, and an unrecognized class is free to ignore. The assumption that fails for all three is that the metadata only has to survive vapor. Pandoc reads `width=50%` natively and honors it; it parses `.half` and does nothing with it, and never sees the other two at all. The brace block is the only form where the size survives a conversion. + +The cost is real and is accepted: GitHub and any strict CommonMark renderer print `{width=50% align=left}` as literal text after the image. + +### 2. Left and right alignment float the image and text wraps + +`align=left` and `align=right` float the image and the following text wraps beside it. `align=center` and an absent `align` both keep the image on its own line, centered and in default flow respectively. This follows Dropbox Paper, which was the stated model. + +The consequence to know before choosing a width: every block in the editor is capped at `max-width: 65ch` ([app/app.css:233](../../app/app.css)), so a floated image at 40% leaves about 39 characters per line beside it and at 50% about 32. Paper's column is wider, which is why the same layout reads better there. Widening vapor's column is a separate decision and is not made here. + +`width=full` and `width=100%` ignore `align` and take their own line; there is no room to wrap beside them. + +Sizing is a set of presets rather than a drag handle. Each choice is one `updateAttributes` call, so it is one Yjs write per click. A drag handle writes per frame, needs throttling, and turns two people sizing the same image into a stream of last-write-wins updates rather than one. + +### 3. Full-bleed is in; galleries and editable captions are not + +Paper also groups images dragged onto the same line into a side-by-side gallery, and gives each image an editable caption field. Both are out of scope here. A gallery needs a new container node in `richSchema`, a new markdown form, drag-to-group in the editor, and gallery rules in three stylesheets. Captions need a decision about where the caption text lives in markdown and whether `attachment` can stay `atom: true`. + +One thing worth recording while it is visible: `alt` is set to the uploaded filename ([app/lib/useAttachments.ts:104](../../app/lib/useAttachments.ts)). It was also drawn under the image as a figcaption, which made the filename read as a caption; that figcaption is now gone and `alt` is only the ``. No vapor document carries real alt text yet. That is a separate issue from this one and is not fixed here. + +## Design + +### The schema gains two nullable attributes + +`richSchema.attachment` ([app/shared/rich-markdown.ts:87](../../app/shared/rich-markdown.ts)) and the TipTap `Attachment` node ([app/lib/attachment.ts](../../app/lib/attachment.ts)) each gain: + +- `width: { default: null }` holding `"50%"` or `"full"` +- `align: { default: null }` holding `"left" | "center" | "right"` + +Both default to `null` rather than to `"100%"` and `"left"`. The serializer emits a brace block only when at least one is non-null, so every document that exists today serializes byte-identical and no round-trip changes. + +### The parser accepts one new shape and drops what it does not know + +`attachmentRule` ([app/shared/rich-markdown.ts:332](../../app/shared/rich-markdown.ts)) currently requires exactly one meaningful child in the paragraph and that it be an image. It gains a second accepted shape: an image followed by a text child matching `/^\{[^}]*\}$/`. Keys are read out of that block, `width` and `align` are kept, and every other key is dropped. + +Three behaviors are chosen deliberately: + +- **Any percent parses.** A hand-typed `{width=37%}` renders at 37% and is not snapped to the nearest preset. The toolbar only ever writes the preset steps, but the raw markdown is editable by anyone with the link and rewriting someone's text silently is worse than honoring it. +- **Unknown keys are dropped, not preserved.** A future `{width=50% caption="x"}` loses the caption on the next save. Preserving unknown keys would mean carrying an opaque bag on the node; that is a cost to pay when there is a second consumer, not before. +- **A non-brace trailing text child changes nothing.** The paragraph fails the attachment test as it does today and falls through to the existing foreign-image path, which turns the image back into literal text. + +`markdown-it` is 15.0.1 with no attrs plugin in the tree ([package.json:43](../../package.json)), so this is a custom core rule beside the existing one, which is how the rest of vapor's markdown extensions are written. + +### Full-bleed breaks out of a centred column with container units + +`.attachment` carries `data-width` and `data-align`. Alignment and full bleed are rules; the width is an inline style, because a percent can be any value and `attr()` is not portable enough to read one. + +An earlier draft of this section said `.tiptap` is a left-aligned padded block rather than a centred column, and that full bleed therefore cost nothing but a dropped `max-width`. That was wrong, and running it is what showed it. The editor sits inside `mx-auto w-full max-w-3xl` ([app/components/Editor.tsx:436](../../app/components/Editor.tsx)), a centred 672px column; blocks only look left-aligned because 65ch exceeds that wrapper at every viewport. A full-bleed image measured 630px, identical to a heading. + +Full bleed breaks out with container query units instead. The editor frame, the flex sibling of the 280px comment rail, is marked `container-type: inline-size` as `.doc-frame`, and the full-bleed rule takes `min(100cqw - 3rem, 1600px)` with a negative inline margin to re-centre it. The viewport would be the wrong unit: it includes the rail. Measured at a 1400px viewport, the frame is 1120px, the text column 630px, and a full-bleed image 1078px centred in the frame with symmetric 21px gaps, ending before the rail begins at 1120px. Reading systems without container units fall back to 100% through an `@supports` guard. + +Floats clear at headings, rules, tables, code blocks, blockquotes, and non-floated attachments. Floated attachments are exempt from the clear, or each would push the one before it down instead of sitting where it was put. Below 640px alignment drops and every image takes its own line. + +`StarterKit` supplies Gapcursor and only `CommentEditor` disables it ([app/components/CommentEditor.tsx:80](../../app/components/CommentEditor.tsx)), so the caret has a target beside a floated atom node. + +### The EPUB needs the rule too, and full-bleed degrades there + +`READING_CSS` ([app/shared/epub.ts:99](../../app/shared/epub.ts)) gains the same width and align rules, and the print page inherits them because it embeds `READING_CSS` and adds only a `@media print` block ([app/shared/epub.ts:226](../../app/shared/epub.ts)). One CSS addition covers both surfaces. + +`full` means 100% of the text width in the EPUB and on the print page, not wider. `READING_CSS` sets `body { margin: 0 auto; max-width: 42em }`, a centered column with no canvas outside it. The documentation states this rather than implying the attribute round-trips to an identical result everywhere. + +Float is emitted in `READING_CSS` as well. Reading systems vary in how they honor `float` on a block, and I have not tested any of them, so a floated image that a given reader lays out on its own line is an accepted outcome and not a bug to chase. + +A bug is introduced if only the CSS changes: `epubChapterHtml` runs a separate plain `MarkdownIt` ([app/shared/epub.ts:62](../../app/shared/epub.ts)) that knows nothing about the brace block, so `{width=50% align=left}` would print as literal text in both the EPUB and the print page. That parser needs the same rule, or a pre-pass that converts the block into a class on the `` before rendering. + +`attachmentImages` ([app/shared/epub.ts:50](../../app/shared/epub.ts)) is unaffected. Its regex captures the URL inside the parens and stops at the closing paren, so a trailing brace block is outside what it matches. + +### The toolbar is a fourth bubble context + +`BubbleToolbar.tsx` already runs a TipTap `BubbleMenu` over a `BubbleContext` union of `selection | suggestion | annotation`. A fourth kind, `attachment`, is detected when the selection is a `NodeSelection` on an `attachment` node whose `kind` is `image`. Its buttons are four widths, plus Full, plus three alignments. The detection runs before the existing text gates: a node selection on an atom spans no text, so both the emptiness check and the `textBetween` check would reject it. + +Two things that only running it revealed. `BubbleMenu` renders its children into a portal and does not re-render them per transaction, so the pressed state has to read the editor through `useEditorState` rather than `editor.state` at render time; without it every button reads unpressed on a node that plainly carries a width. And Material Symbols is loaded as a named subset in [app/root.tsx:53](../../app/root.tsx), so `format_image_left`, `format_image_right`, `format_align_center`, and `width_full` each had to be added to `icon_names` or the ligature renders as raw text. `Icon.tsx` says so in its own doc comment. + +### Resizing is not a tracked change + +`suggestModePlugin` ([app/lib/suggest-mode.ts:51](../../app/lib/suggest-mode.ts)) implements only `handleTextInput` and `handleKeyDown`. It has no `filterTransaction` and no `appendTransaction`, so a programmatic `updateAttributes` passes through untouched and a resize applies directly even while suggest mode is on. This is the right outcome as well as the current one: CriticMarkup has no syntax for an attribute change, so a tracked resize could not be written to markdown. + +Agents get all of this through `replace`, since agents write markdown. No new MCP tool and no new parameters on `attach`. + +## Verified and not verified + +Verified by running it at a 1400px viewport against a local dev instance: the float, the wrap and where it ends, the clear at a heading, full bleed's width against the frame and the rail, the toolbar's buttons and pressed state, a click on Align right reaching `/:id.md` as `{width=50% align=right}`, and the print page carrying inline styles rather than literal braces. Verified by reading the source at `aeeaa3f`: the schema, parser, and serializer shapes; the `65ch` cap and the `.tiptap` container; the rail's width and that it is a flex sibling; `READING_CSS` and the print page sharing it; `attachmentImages`' regex; `epubChapterHtml`'s separate parser; `suggestModePlugin`'s two props; the `BubbleContext` union; `markdown-it` 15.0.1 with no attrs plugin. + +Not verified: no EPUB has been opened in a reading system, so how any of them honours `float` on a block is unknown. The three test files CLAUDE.md names as environment failures (`safe-storage`, `anon-identity`, `use-theme`) still fail here; CLAUDE.md attributes that to Node 26 shipping a global `localStorage`, and this machine runs Node 25.8.1, so that note is one version out of date. + +Dropbox Paper's behavior was taken from Google's synthesis of Dropbox's blog and community threads rather than from Dropbox's documentation, and one of its two claims was wrong. It reported that Paper does not wrap text around images; Nicholas, who uses Paper, corrected that on 2026-09-13 and the design above reflects the correction. Its other claim from the same source, that Paper has no drag-to-resize handles and uses a preset toolbar, is therefore also unconfirmed. Presets were chosen independently for the one-write-per-click reason given in Decision 2, which does not depend on what Paper does, so the toolbar design stands either way. + +## Tasks + +1. **Schema and markdown** (`app/shared/rich-markdown.ts`, `app/lib/attachment.ts`): the two attrs, the extended `attachmentRule`, the serializer's conditional brace block. Round-trip tests in `tests/unit/shared/rich-markdown.test.ts`. +2. **Editor rendering** (`app/components/AttachmentView.tsx`, `app/app.css`): `data-width` and `data-align` on the wrapper, the width, align, and full-bleed rules. +3. **EPUB and print** (`app/shared/epub.ts`): the brace-block rule in the export parser, the width and align rules in `READING_CSS`. Tests in `tests/unit/shared/epub.test.ts` asserting the block does not reach the chapter XHTML. +4. **Toolbar** (`app/components/BubbleToolbar.tsx`): the `attachment` context and its buttons. Component test for the context detection. +5. **Docs**: the attribute form in the attachment section of `docs/markdown-and-criticmarkup.md`, including that `full` means column width in the EPUB and print. + +Tasks 1 and 3 are independent of 2 and 4 and testable without the editor. + +## Reopens when + +- The 65ch column is widened. Wrapped text beside a 50% image is about 32 characters at the current width, which is the weakest part of this design and is fixed by the column, not by the image. +- A second consumer needs a key this design drops. The rule that unknown keys are discarded is cheap to reverse only before documents contain them. +- Galleries are wanted. A container node changes the markdown form for images generally, so it is a redesign of this decision and not an addition to it. + +## Out of scope + +Drag-to-resize handles; side-by-side galleries; editable captions; alt text distinct from the filename; sizing or alignment for non-image attachments, which stay block chips; server-side resizing or thumbnails; `width` and `align` parameters on the MCP `attach` tool. + +## Amendments after review, 2026-09-13 + +Nicholas reviewed the built feature and changed four things. The decisions above are left as they were written; these supersede them. + +- **No width presets in the toolbar.** The bubble offered 25, 50, 75, 100 and full. The percentages are gone; it now toggles full bleed and picks an alignment. An image is its natural size unless the markdown carries a `width`, so the toolbar writes a width only for full bleed and only clears it. Decision 2's argument for presets over a drag handle still holds for the write pattern; it was an argument for one write per click, not for a row of percentages. +- **Full bleed is a toggle.** Pressing it on an already full-bleed image returns the image to its natural width. Aligning a full-bleed image clears the width rather than stepping it to 50%, which is what it did before. +- **Floats cap at a third of the column.** `max-width: 33.333%` in `app.css` and in `imageLayoutStyle`, which covers the EPUB and print paths. This is a cap, not a width: a floated image narrower than a third stays at its natural size, and a hand-written `{width=60% align=right}` renders at a third. Dropbox Paper caps its own floats at the same third. This is the fix for the 65ch problem listed under "Reopens when": the column did not have to widen, the float had to narrow. +- **Every image is capped at 80% of the viewport height**, in `app.css` and in `READING_CSS`, so the EPUB and print pages carry it too. The cap holds the aspect ratio rather than letterboxing, so it can override a stated width: a 400x3000 image at `width=100%` renders 82px wide in a 630px column. +- **No filename under the image.** The `
` is gone. `alt` still carries the filename, so the note above about `alt` doubling as a caption no longer describes what is drawn. + +Headings took Dropbox Paper's extended-headings scale in the same pass, which is not part of this design. The editor sizes them in `em` rather than `rem`: the root font-size is 14px, so `1.875rem` rendered an h1 at 26.25px against Paper's 30px. + +Verified by measurement against a local dev instance at a 630px column: a 400px image with no attributes renders 400px wide; floated left it renders 210px, which is 630/3; centred it renders 400px with its left edge at the column's midpoint minus 200px; an 80px image floated left stays 80px; `{width=60% align=right}` renders 210px. The bubble's buttons read Full width, Align left, Align centre, Align right, and nothing else. diff --git a/tests/unit/components/attachment-view.test.tsx b/tests/unit/components/attachment-view.test.tsx index c933452f..935e4a57 100644 --- a/tests/unit/components/attachment-view.test.tsx +++ b/tests/unit/components/attachment-view.test.tsx @@ -12,12 +12,58 @@ function view(attrs: Record, selected = false) { } describe("AttachmentView", () => { - it("renders an image with its name and size as a caption", () => { - view({ kind: "image", src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", alt: "cat.png", bytes: 2048 }); + it("renders an image alone, with its name as alt text and no caption under it", () => { + const { container } = view({ kind: "image", src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", alt: "cat.png", bytes: 2048 }); const img = screen.getByRole("img") as HTMLImageElement; expect(img.getAttribute("src")).toBe("/abcd1234/attachments/abcdefghijklmnop/cat.png"); - expect(screen.getByText("cat.png")).toBeTruthy(); - expect(screen.getByText("2 KB")).toBeTruthy(); + expect(img.getAttribute("alt")).toBe("cat.png"); + expect(container.querySelector("figcaption")).toBeNull(); + expect(screen.queryByText("cat.png")).toBeNull(); + expect(screen.queryByText("2 KB")).toBeNull(); + }); + + it("puts width and align on the wrapper so the stylesheet can size it (#109)", () => { + const { container } = view({ + kind: "image", + src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", + alt: "cat.png", + bytes: null, + width: "50%", + align: "left", + }); + const wrapper = container.querySelector(".attachment")!; + expect(wrapper.getAttribute("data-width")).toBe("50%"); + expect(wrapper.getAttribute("data-align")).toBe("left"); + // An arbitrary percent cannot be a static CSS rule, so the width is inline. + expect((wrapper as HTMLElement).style.width).toBe("50%"); + }); + + it("leaves the width to the stylesheet at full bleed (#109)", () => { + const { container } = view({ + kind: "image", + src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", + alt: "cat.png", + bytes: null, + width: "full", + align: "left", + }); + const wrapper = container.querySelector(".attachment")! as HTMLElement; + expect(wrapper.getAttribute("data-width")).toBe("full"); + expect(wrapper.style.width).toBe(""); + // Nothing wraps beside a full-bleed image, so it drops the float. + expect(wrapper.hasAttribute("data-align")).toBe(false); + }); + + it("omits the layout attributes when the node carries none (#109)", () => { + const { container } = view({ + kind: "image", + src: "/abcd1234/attachments/abcdefghijklmnop/cat.png", + alt: "cat.png", + bytes: null, + }); + const wrapper = container.querySelector(".attachment")!; + expect(wrapper.hasAttribute("data-width")).toBe(false); + expect(wrapper.hasAttribute("data-align")).toBe(false); }); it("renders other files as a download chip, and hides an unknown size", () => { diff --git a/tests/unit/components/header-menu.test.tsx b/tests/unit/components/header-menu.test.tsx index e5868678..a294910a 100644 --- a/tests/unit/components/header-menu.test.tsx +++ b/tests/unit/components/header-menu.test.tsx @@ -69,6 +69,14 @@ describe("HeaderMenu", () => { expect(screen.queryByText("Sign in")).toBeNull(); }); + it("trigger is the three-line menu glyph in plain Edit, and the mode's glyph otherwise", () => { + const { unmount } = renderWithDocument(createElement(HeaderMenu)); + expect(screen.getByLabelText("Menu").textContent?.trim()).toBe("menu"); + unmount(); + renderWithDocument(createElement(HeaderMenu), { context: { mode: "suggest" } }); + expect(screen.getByLabelText("Menu").textContent?.trim()).toBe("rate_review"); + }); + it("trigger shows the mode when it isn't plain Edit", () => { renderWithDocument(createElement(HeaderMenu), { context: { mode: "suggest" } }); expect(screen.getByLabelText("Menu").getAttribute("title")).toBe("Suggest"); diff --git a/tests/unit/components/image-layout-buttons.test.tsx b/tests/unit/components/image-layout-buttons.test.tsx new file mode 100644 index 00000000..cb7942c1 --- /dev/null +++ b/tests/unit/components/image-layout-buttons.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** + * The image bubble's layout row (#109): what it offers and what each press + * writes. Rendered without BubbleMenu, which only decides where it appears. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { createElement } from "react"; +import { render, fireEvent } from "@testing-library/react"; +import { Editor } from "@tiptap/core"; +import Document from "@tiptap/extension-document"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import { NodeSelection } from "@tiptap/pm/state"; +import { Attachment } from "~/lib/attachment"; +import { ImageLayoutButtons } from "~/components/BubbleToolbar"; + +let editor: Editor | null = null; + +function setup(attrs: Record = {}) { + editor = new Editor({ + extensions: [Document, Paragraph, Text, Attachment], + content: { + type: "doc", + content: [ + { type: "attachment", attrs: { kind: "image", src: "/a/attachments/b/cat.png", alt: "cat.png", ...attrs } }, + { type: "paragraph", content: [{ type: "text", text: "After." }] }, + ], + }, + }); + const { state, view } = editor; + view.dispatch(state.tr.setSelection(NodeSelection.create(state.doc, 0))); + const utils = render(createElement(ImageLayoutButtons, { editor })); + return { editor, ...utils }; +} + +const width = () => editor!.state.doc.firstChild!.attrs.width; +const align = () => editor!.state.doc.firstChild!.attrs.align; + +afterEach(() => { + editor?.destroy(); + editor = null; +}); + +describe("ImageLayoutButtons", () => { + it("offers no width percentages: an image is its natural size unless the markdown says otherwise", () => { + const { container } = setup(); + const labels = [...container.querySelectorAll("button")].map((b) => b.textContent?.trim()); + expect(labels).not.toContain("25"); + expect(labels).not.toContain("50"); + expect(labels).not.toContain("75"); + expect(labels).not.toContain("100"); + }); + + it("offers full width and the three alignments", () => { + const { getByLabelText } = setup(); + for (const label of ["Full width", "Align left", "Align centre", "Align right"]) { + expect(getByLabelText(label)).toBeTruthy(); + } + }); + + it("toggles full width off again, back to the natural width", () => { + const { getByLabelText } = setup(); + fireEvent.click(getByLabelText("Full width")); + expect(width()).toBe("full"); + fireEvent.click(getByLabelText("Full width")); + expect(width()).toBe(null); + }); + + it("returns a full-bleed image to its natural width when it is aligned, not to a preset", () => { + const { getByLabelText } = setup({ width: "full" }); + fireEvent.click(getByLabelText("Align right")); + expect(align()).toBe("right"); + expect(width()).toBe(null); + }); + + it("leaves a width the markdown specified alone when only the alignment changes", () => { + const { getByLabelText } = setup({ width: "60%" }); + fireEvent.click(getByLabelText("Align left")); + expect(width()).toBe("60%"); + expect(align()).toBe("left"); + }); +}); diff --git a/tests/unit/lib/image-commands.test.ts b/tests/unit/lib/image-commands.test.ts new file mode 100644 index 00000000..7fed72bf --- /dev/null +++ b/tests/unit/lib/image-commands.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +/** + * Tests for image-commands: which selection offers the image layout controls, + * and what setting a width or an alignment writes (#109). + */ +import { describe, it, expect, afterEach } from "vitest"; +import { Editor } from "@tiptap/core"; +import Document from "@tiptap/extension-document"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import { NodeSelection } from "@tiptap/pm/state"; +import { Attachment } from "~/lib/attachment"; +import { selectedImageAttachment, setImageLayout } from "~/lib/image-commands"; + +let editor: Editor | null = null; + +function makeEditor(kind: "image" | "file") { + editor = new Editor({ + extensions: [Document, Paragraph, Text, Attachment], + content: { + type: "doc", + content: [ + { type: "attachment", attrs: { kind, src: "/a/attachments/b/cat.png", alt: "cat.png" } }, + { type: "paragraph", content: [{ type: "text", text: "After." }] }, + ], + }, + }); + const { state, view } = editor; + view.dispatch(state.tr.setSelection(NodeSelection.create(state.doc, 0))); + return editor; +} + +afterEach(() => { + editor?.destroy(); + editor = null; +}); + +describe("selectedImageAttachment", () => { + it("finds an image attachment under a node selection", () => { + const found = selectedImageAttachment(makeEditor("image").state); + expect(found?.node.attrs.alt).toBe("cat.png"); + expect(found?.pos).toBe(0); + }); + + it("ignores a file attachment, which has no layout to set", () => { + expect(selectedImageAttachment(makeEditor("file").state)).toBe(null); + }); + + it("ignores a text selection", () => { + const ed = makeEditor("image"); + ed.commands.setTextSelection({ from: 2, to: 6 }); + expect(selectedImageAttachment(ed.state)).toBe(null); + }); +}); + +describe("setImageLayout", () => { + it("writes width and align onto the selected image", () => { + const ed = makeEditor("image"); + setImageLayout(ed, { width: "50%", align: "left" }); + expect(ed.state.doc.firstChild!.attrs.width).toBe("50%"); + expect(ed.state.doc.firstChild!.attrs.align).toBe("left"); + }); + + it("clears an attribute when it is set to null", () => { + const ed = makeEditor("image"); + setImageLayout(ed, { width: "50%", align: "left" }); + setImageLayout(ed, { align: null }); + expect(ed.state.doc.firstChild!.attrs.width).toBe("50%"); + expect(ed.state.doc.firstChild!.attrs.align).toBe(null); + }); + + it("does nothing when the selection is not an image", () => { + const ed = makeEditor("file"); + expect(setImageLayout(ed, { width: "50%" })).toBe(false); + expect(ed.state.doc.firstChild!.attrs.width).toBe(null); + }); +}); diff --git a/tests/unit/lib/performance-chunks.test.ts b/tests/unit/lib/performance-chunks.test.ts index 8004e142..a87e101e 100644 --- a/tests/unit/lib/performance-chunks.test.ts +++ b/tests/unit/lib/performance-chunks.test.ts @@ -7,10 +7,12 @@ describe("chunkTyping", () => { expect(ticks.map((t) => t.chunk).join("")).toBe("Hello world. Bye."); }); - it("pauses after sentence ends", () => { + it("pauses after sentence ends, by more than a whole tick", () => { const ticks = chunkTyping("Hi. Yo", "natural", () => 0.5); const afterDot = ticks.find((t) => t.chunk.startsWith(" Yo") || t.chunk.startsWith("Yo")); - expect(afterDot!.delayMs).toBeGreaterThanOrEqual(300); + // The base delay at rng 0.5 is 84ms; the pause adds 100-300 on top. + expect(afterDot!.delayMs).toBeGreaterThanOrEqual(184); + expect(afterDot!.delayMs).toBeLessThanOrEqual(407); }); it("fast pace uses bigger chunks", () => { @@ -22,25 +24,33 @@ describe("chunkTyping", () => { expect(chunkTyping("", "natural", () => 0.5)).toEqual([]); }); - it("natural pace ticks fall within the 2-6 char, 30-80ms base range", () => { - const ticks = chunkTyping("abcdefghij", "natural", () => 0); - for (const tick of ticks) { - expect(tick.chunk.length).toBeGreaterThanOrEqual(1); - expect(tick.chunk.length).toBeLessThanOrEqual(6); - expect(tick.delayMs).toBeGreaterThanOrEqual(30); + it("natural pace ticks fall within the 2-4 char, 60-107ms base range", () => { + for (const [rng, chars, delay] of [[0, 2, 60], [0.999, 4, 107]] as const) { + for (const tick of chunkTyping("abcdefghij", "natural", () => rng)) { + expect(tick.chunk.length).toBeLessThanOrEqual(chars); + expect(tick.delayMs).toBe(delay); + } } }); - it("fast pace ticks fall within the 8-16 char, 10-20ms base range", () => { - const ticks = chunkTyping("abcdefghijklmnopqrstuvwxyz", "fast", () => 0); - for (const tick of ticks) { - expect(tick.chunk.length).toBeGreaterThanOrEqual(1); - expect(tick.chunk.length).toBeLessThanOrEqual(16); - expect(tick.delayMs).toBeGreaterThanOrEqual(10); - expect(tick.delayMs).toBeLessThanOrEqual(20); + it("fast pace ticks fall within the 6-12 char, 7-13ms base range", () => { + for (const [rng, chars, delay] of [[0, 6, 7], [0.999, 12, 13]] as const) { + for (const tick of chunkTyping("abcdefghijklmnopqrstuvwxyz", "fast", () => rng)) { + expect(tick.chunk.length).toBeLessThanOrEqual(chars); + expect(tick.delayMs).toBe(delay); + } } }); + it("types about three characters per 100ms at natural pace", () => { + const text = "abcdefghij".repeat(30); + const ticks = chunkTyping(text, "natural", () => 0.5); + const totalMs = ticks.reduce((sum, t) => sum + t.delayMs, 0); + // 300 characters at 3 per 84ms is 8.4 seconds; there are no sentence + // endings in the text, so nothing is added on top. + expect(Math.round((text.length / totalMs) * 1000)).toBeGreaterThanOrEqual(30); + }); + it("is deterministic for a fixed rng", () => { const a = chunkTyping("Hello world. Bye.", "natural", () => 0.5); const b = chunkTyping("Hello world. Bye.", "natural", () => 0.5); diff --git a/tests/unit/lib/resolve-thread.test.tsx b/tests/unit/lib/resolve-thread.test.tsx new file mode 100644 index 00000000..f07b25a7 --- /dev/null +++ b/tests/unit/lib/resolve-thread.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +/** + * Resolving a thread defocuses it, so the card leaves the rail and the sheet + * instead of sitting there until something else is clicked (#111). + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import * as Y from "yjs"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { CriticAddition, CriticDeletion, CriticComment, CriticHighlight } from "~/lib/critic-marks"; +import { useThreads } from "~/lib/useThreads"; + +const user = { id: "u-jon", name: "Jon Wiley", color: "#BA68C8", colorLight: "#E1BEE7" }; + +function setup(comments: string[]) { + vi.useFakeTimers(); + const doc = new Y.Doc(); + const editor = new Editor({ + extensions: [StarterKit.configure({ undoRedo: false }), CriticAddition, CriticDeletion, CriticComment, CriticHighlight], + content: "

Hello there

", + }); + const hook = renderHook(() => useThreads({ doc, editor, user })); + for (const text of comments) { + act(() => { + editor + .chain() + .command(({ tr }) => { + tr.insertText(text, 6); + tr.addMark(6, 6 + text.length, editor.schema.marks.criticComment.create()); + return true; + }) + .run(); + }); + act(() => { + vi.advanceTimersByTime(3000); + }); + } + return { editor, ...hook }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("resolveThread (#111)", () => { + it("defocuses the thread it resolves", () => { + const { result, editor } = setup(["needs work"]); + const id = result.current.threads[0].id; + act(() => result.current.setActiveThreadId(id)); + expect(result.current.activeThreadId).toBe(id); + + act(() => result.current.resolveThread(id)); + expect(result.current.threads[0].resolved).toBe(true); + expect(result.current.activeThreadId).toBe(null); + editor.destroy(); + }); + + it("leaves the selection alone when a different thread is resolved", () => { + const { result, editor } = setup(["second one", "first one"]); + const [a, b] = result.current.threads.map((t) => t.id); + act(() => result.current.setActiveThreadId(a)); + + act(() => result.current.resolveThread(b)); + expect(result.current.activeThreadId).toBe(a); + editor.destroy(); + }); + + it("reopening does not change the selection: the card is already in view under Show resolved", () => { + const { result, editor } = setup(["needs work"]); + const id = result.current.threads[0].id; + act(() => result.current.resolveThread(id)); + + act(() => result.current.resolveThread(id)); + expect(result.current.threads[0].resolved).toBe(false); + expect(result.current.activeThreadId).toBe(null); + editor.destroy(); + }); +}); diff --git a/tests/unit/shared/epub.test.ts b/tests/unit/shared/epub.test.ts index bf13c08f..503da6be 100644 --- a/tests/unit/shared/epub.test.ts +++ b/tests/unit/shared/epub.test.ts @@ -16,6 +16,26 @@ Keep it short. `; describe("EPUB export (#100)", () => { + const sized = "![cat.png](/abcd1234/attachments/abcdefghijklmnop/cat.png){width=50% align=left}"; + + it("renders an image's attribute block as layout, not as literal text (#109)", () => { + const html = epubChapterHtml(sized, []); + expect(html).not.toContain("{width=50%"); + expect(html).toContain("width: 50%"); + expect(html).toContain("float: left"); + }); + + it("clears floated images at the next structural boundary (#109)", () => { + expect(READING_CSS).toMatch(/clear: both/); + }); + + it("renders an image with no attribute block unchanged (#109)", () => { + const plain = "![cat.png](/abcd1234/attachments/abcdefghijklmnop/cat.png)"; + const html = epubChapterHtml(plain, []); + expect(html).not.toContain("style="); + expect(html).toContain(" { const text = readingMarkdown(md); expect(text).toContain("Hello there, new text quoted."); @@ -78,10 +98,14 @@ describe("EPUB export (#100)", () => { expect(epubFilename("abcd1234", "no heading here")).toBe("abcd1234.epub"); }); - it("sets the page's type: a sans body, bold sans headings stepping down, a larger lighter title, mono code", () => { + it("sets the page's type: Paper's heading scale in the system font, a larger lighter title, mono code", () => { expect(READING_CSS).toMatch(/body \{ font-family: ui-sans-serif, system-ui/); - expect(READING_CSS).toContain("h1 { font-size: 1.875em;"); - expect(READING_CSS).toContain("h2 { font-size: 1.5em;"); + expect(READING_CSS).toContain("h1, h2, h3, h4 { font-family: ui-sans-serif, system-ui"); + // Paper's extended headings, as ems of the body size: 30/36/-0.4px, 24/30/-0.2px, 20/26px, all 600. + expect(READING_CSS).toContain("h1 { font-size: 1.875em; line-height: 1.2; letter-spacing: -0.013em;"); + expect(READING_CSS).toContain("h2 { font-size: 1.5em; line-height: 1.25; letter-spacing: -0.008em;"); + expect(READING_CSS).toContain("h3, h4 { font-size: 1.25em; line-height: 1.3;"); + expect(READING_CSS).toMatch(/^h1, h2, h3, h4 \{[^}]*font-weight: 600;/m); expect(READING_CSS).toContain("body > h1:first-child { font-size: 2.5em; font-weight: 500;"); expect(READING_CSS).toMatch(/code, pre, kbd \{ font-family: "IBM Plex Mono"/); expect(READING_CSS).not.toContain("Georgia"); @@ -89,6 +113,10 @@ describe("EPUB export (#100)", () => { expect(strFromU8(files["OEBPS/style.css"])).toBe(READING_CSS); }); + it("caps an image at 80% of the page height so a tall one does not take the page (#109)", () => { + expect(READING_CSS).toContain("img { max-width: 100%; max-height: 80vh; height: auto; }"); + }); + it("renders a printable page with the same styles, page rules, and an optional auto-print", () => { const html = printableHtml({ id: "abcd1234", markdown: md, origin: "https://vapor.example", autoPrint: true }); expect(html).toContain("A plan"); diff --git a/tests/unit/shared/image-layout.test.ts b/tests/unit/shared/image-layout.test.ts new file mode 100644 index 00000000..7d0a4989 --- /dev/null +++ b/tests/unit/shared/image-layout.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { imageLayoutStyle } from "~/shared/image-layout"; + +describe("imageLayoutStyle (#109)", () => { + it("caps a floated image at a third of the column so text still has a measure", () => { + expect(imageLayoutStyle({ width: "50%", align: "left" })).toContain("max-width: 33.333%"); + expect(imageLayoutStyle({ width: "50%", align: "right" })).toContain("max-width: 33.333%"); + }); + + it("leaves an unfloated image uncapped", () => { + expect(imageLayoutStyle({ width: "50%", align: "center" })).not.toContain("max-width"); + expect(imageLayoutStyle({ width: "full" })).not.toContain("max-width"); + }); +}); diff --git a/tests/unit/shared/rich-markdown.test.ts b/tests/unit/shared/rich-markdown.test.ts index 885d1273..2759ca1a 100644 --- a/tests/unit/shared/rich-markdown.test.ts +++ b/tests/unit/shared/rich-markdown.test.ts @@ -365,6 +365,51 @@ describe("attachments", () => { expect(yDocToMarkdown(doc)).toBe(`# Title\n\n${image}\n\nAfter.`); expect(getBlocks(doc)[1].id).toMatch(BLOCK_ID_RE); }); + + it("reads width and align from an attribute block after the image", () => { + const sized = `${image}{width=50% align=left}`; + const parsed = parseMarkdown(sized); + if (!parsed.ok) throw new Error(parsed.message); + const node = parsed.doc.firstChild!; + expect(node.type.name).toBe("attachment"); + expect(node.attrs.width).toBe("50%"); + expect(node.attrs.align).toBe("left"); + expect(roundTrip(sized)).toBe(sized); + }); + + it("round-trips width=full and align on their own", () => { + expect(roundTrip(`${image}{width=full}`)).toBe(`${image}{width=full}`); + expect(roundTrip(`${image}{align=center}`)).toBe(`${image}{align=center}`); + }); + + it("honours a percent that is not one of the toolbar presets", () => { + const parsed = parseMarkdown(`${image}{width=37%}`); + if (!parsed.ok) throw new Error(parsed.message); + expect(parsed.doc.firstChild!.attrs.width).toBe("37%"); + }); + + it("drops unknown keys and out-of-range values from the attribute block", () => { + const parsed = parseMarkdown(`${image}{width=200% align=diagonal caption=hi}`); + if (!parsed.ok) throw new Error(parsed.message); + const node = parsed.doc.firstChild!; + expect(node.type.name).toBe("attachment"); + expect(node.attrs.width).toBe(null); + expect(node.attrs.align).toBe(null); + expect(roundTrip(`${image}{width=200% align=diagonal caption=hi}`)).toBe(image); + }); + + it("leaves an image followed by text that is not an attribute block as literal text", () => { + // Not round-tripped: an image turned back into text is escaped by the + // generic text serialiser, which predates the attribute block. + const parsed = parseMarkdown(`${image} and more`); + if (!parsed.ok) throw new Error(parsed.message); + expect(parsed.doc.firstChild!.type.name).toBe("paragraph"); + expect(parsed.doc.textContent).toBe(`${image} and more`); + }); + + it("serialises no attribute block when the node carries no layout", () => { + expect(roundTrip(image)).toBe(image); + }); });