Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agents/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";

Expand Down
105 changes: 86 additions & 19 deletions app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down Expand Up @@ -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;
}

Expand Down
33 changes: 24 additions & 9 deletions app/components/AttachmentView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<NodeViewWrapper className={`attachment ${selected ? "is-selected" : ""}`} data-kind={kind} data-drag-handle>
<NodeViewWrapper
className={`attachment ${selected ? "is-selected" : ""}`}
data-kind={kind}
data-width={width ?? undefined}
data-align={fullBleed ? undefined : (align ?? undefined)}
style={width && width !== "full" ? { width } : undefined}
data-drag-handle
>
{kind === "image" ? (
<figure className="attachment-image">
<img src={src} alt={alt} loading="lazy" />
<figcaption>
<span className="truncate">{alt}</span>
{size && <span className="shrink-0 text-muted">{size}</span>}
</figcaption>
</figure>
) : (
<a className="attachment-chip" href={src} download={alt} target="_blank" rel="noopener">
Expand Down
84 changes: 84 additions & 0 deletions app/components/BubbleToolbar.tsx
Original file line number Diff line number Diff line change
@@ -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;

/**
Expand All @@ -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) {
Expand Down Expand Up @@ -102,13 +110,77 @@ 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 =
"flex h-[44px] w-[44px] items-center justify-center text-paper transition-colors hover:bg-paper/15 cursor-pointer";

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 (
<>
<button
className={`${on(full)} border-r border-paper/20`}
aria-pressed={full}
onClick={() => setImageLayout(editor, { width: full ? null : "full" })}
title="Full width"
aria-label="Full width"
>
<Icon name="width_full" />
</button>
{ALIGNS.map((a) => (
<button
key={a.value}
className={on(align === a.value)}
aria-pressed={align === a.value}
// Nothing wraps beside a full-bleed image, so choosing an alignment
// drops the width back to natural, where there is room for text.
onClick={() => setImageLayout(editor, { align: a.value, ...(full ? { width: null } : {}) })}
title={a.label}
aria-label={a.label}
>
<Icon name={a.icon} />
</button>
))}
</>
);
}

// 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 };
Expand Down Expand Up @@ -163,6 +235,18 @@ export default function BubbleToolbar({ editor, onNewComment }: { editor: Tiptap
<Icon name="close" />
</button>
</BubbleMenu>

{/* A selected image → width and alignment */}
<BubbleMenu
editor={editor}
pluginKey="bubbleAttachment"
updateDelay={UPDATE_DELAY_MS}
shouldShow={shouldShowAttachment}
options={menuOptions}
className={menuClass}
>
<ImageLayoutButtons editor={editor} />
</BubbleMenu>
</>
);
}
5 changes: 4 additions & 1 deletion app/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,10 @@ export default function Editor({
return (
<>
<div
className={`min-h-full cursor-text ${hidden ? "hidden" : ""} ${revealClass}`}
// doc-frame is the query container a full-bleed image measures against:
// the space beside the comment rail, not the centred 3xl text column
// it lives in and not the viewport (#109).
className={`doc-frame min-h-full cursor-text ${hidden ? "hidden" : ""} ${revealClass}`}
onClick={handleClick}
onAnimationEnd={(e) => {
// Blocks cascade in; the class leaves once the last one has landed.
Expand Down
2 changes: 1 addition & 1 deletion app/components/HeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default function HeaderMenu({
<Popover.Trigger
render={
<button aria-label="Menu" title={title} className="system-trigger header-button shrink-0">
<Icon name={modeIcon ?? "comment"} />
<Icon name={modeIcon ?? "menu"} />
</button>
}
/>
Expand Down
6 changes: 6 additions & 0 deletions app/lib/attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
},

Expand All @@ -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"),
}),
},
];
Expand All @@ -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,
}),
];
},
Expand Down
Loading
Loading