feat: a comment or a change gets a balloon beside its own line

ReviewMarginLayer draws the cards in a strip right of the page, at the height
of the line each belongs to, with a dashed leader running through the gap
under it. A balloon is pushed down by the one above (balloonStack, measured
per card, unit-tested) and the column belongs to its page, so it never runs
past the sheet. The strip is only reserved where the margin has something to
show; the smoke test guards a card's content against its box.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:03 +02:00
1 parent 4b66631b56
commit de71830922
9 files changed
+572 -19

No files matched your search

+1
View File
@@ -1496,6 +1496,7 @@
{pageDecor}
{lineNumbering}
{foldMarks}
commentAuthor={docProps.author}
bind:extraHfSections
{zoom}
onZoom={setZoom}
+58 -2
View File
@@ -107,8 +107,9 @@ a 177-page document. Word and LibreOffice draw only the visible pages; we can't.
forever; with one shared scroller that loop never settles.
- The editor's own view **moves** to pane 0's new host when the layout changes
(`$effect` on `hosts[0]`), rather than being rebuilt from the document.
- `min-width: 0` on the row and its scroller: a flex item's automatic minimum is its
content, so without it the grid widens the whole app instead of scrolling.
- `min-width: 0` on the row and its scroller, in every mode: a flex item's automatic
minimum is its content, so without it a canvas wider than the window — the grid, or a
page beside the balloon strip — widens the app and the toolbar ends short of it.
- No ruler here: it would repeat per column and eat into the page's own box.
- **`sectionPaper` stays unrounded.** `pageBreaks` builds its grid from the height this
publishes as `--pb-section-page`, so rounding A4's 1122.52px to 1123 ran every page
@@ -258,6 +259,61 @@ mounted: with nothing marked it renders nothing. It sits inside `.paper`, so the
export and print paths clone it and the bar prints — see `export/reviewPrint.ts` for what
the vector path has to rebuild instead.
## What the review views may show (`reviewItems.ts`)
Bars, balloons, the leader line, both panes and the two navigation pairs read
`visibleComments` / `visibleCommentRanges` / `visibleRevisions` instead of the document's
own `comments()` / `revisions()`, so the markup settings (`storage/markup`) are read in one
place and cannot drift between the five views. A **resolved** comment is not this rule's
business — each caller handles it as it did. The text itself is hidden by two levers that
have to agree: `.paper[data-hide-*]` in `editor.css` takes the mark's text and tint off the
page, and `trackChanges`' `plain` option drops the decoration that carries the underline,
the strikethrough and the author colour — CSS alone would lose against `pm-rev-a<n>`.
An anchor inside hidden text has no box, so `ReviewMarginLayer` drops a card whose line has
collapsed; the mode change dispatches `FORCE_PAGE_RECALC`, since hiding text reflows pages.
## Margin balloons (`ReviewMarginLayer.svelte`, `storage/markup.svelte.ts`)
What both word processors draw beside the page: one card per unresolved comment and per
recorded change, at the height of the line it belongs to, with a dashed leader to it.
Accept/reject and edit/resolve/remove happen in the balloon — `CommentCard.svelte` and
`RevisionCard.svelte` are the same cards the two panes build their rows from.
- **It sits outside `.paper`**, as a sibling inside `.paper-scaler`, carrying that same
`transform: scale()` so it zooms with the page. Inside `.paper` it could not: that box
is `overflow-x: clip`, and its width is the one `.tiptap` and the pagination measure.
- The strip is `REVIEW_MARGIN_CM` (gap + balloon) and is **only reserved when the document
has markup the margin shows** — a kind switched to the pane, or a mode that hides it,
leaves `.paper-scaler` exactly the page, centred as before.
`scaledCanvasWidth` (`Editor.svelte`) adds it; `scaledWidth` stays the page, which is
what the `Ruler` is sized from.
- **The leader runs in the gap under the anchor's line** (`coordsAtPos(...).bottom`, not the
line's middle) and eases into the card's left edge at the anchor's own height where the
card reaches that far — so a balloon sitting at its line is joined by one straight line
that crosses no letters. `ConnectorLayer` draws its leader to the pane the same way.
- A balloon wants its anchor's line and is pushed down where the one above it reaches
(`utils/balloonStack.ts`, unit-tested). Its height is **measured**, not computed — a card
grows when its text is edited — so a `ResizeObserver` feeds the stack.
- **The column belongs to its page, not to the document.** Balloons are grouped by the page
their anchor is on and stacked inside that page's band; a stack that would end past the
sheet is pulled back up (second pass in `stackTops`). The cards of one page also share its
height as a `max-height`, so a comment of a few paragraphs scrolls in its own card instead
of pushing the ones below it off the page — `MIN_BALLOON_H` is where the shrinking stops
and the column is allowed to run over.
- Its x comes from the anchor's own page box, so a narrower section's page keeps its
balloons at its own right edge. Not mounted in the page grid: `.page-cell` is one page
wide and clips.
- The **⋯** menu and the reply box are part of the shared card, so a balloon and a pane row
answer a comment the same way; both grow the card, and the stack follows the measurement.
- **The card is authored for the pane's 260px and lives here in `BALLOON_W_CM`**, so everything in
it has to wrap: the action row is `flex-wrap: wrap` (unwrapped, German labels pushed
"Bearbeiten" clean out of the balloon) and the balloon carries `overflow-wrap: anywhere`
plus `overflow-x: hidden`. `tests/smoke/run.mjs` guards it — a comment in the longer
locale, one unbreakable word and one comment far too long for its share, then every
descendant's rect against its card's and every card's against the page.
- The printout carries the margin bar and the comment list instead (`export/reviewPrint.ts`);
`@media print` hides this layer.
## Reviewing leader (`ConnectorLayer.svelte`)
The dashed leader both word processors draw from an annotated range to its balloon, here
+3 -1
View File
@@ -63,7 +63,9 @@
if (clip && (c.bottom < clip.top || c.top > clip.bottom || c.left < clip.left || c.left > clip.right)) return;
const r = card.getBoundingClientRect();
const ax = c.left - box.left;
const ay = (c.top + c.bottom) / 2 - box.top;
// The bottom of the line box: the leader runs through the gap between two lines,
// where it crosses no letters, as both word processors draw it.
const ay = c.bottom - box.top;
const cx = r.left - box.left;
const cy = r.top - box.top + Math.min(14, r.height / 2);
if (cx <= ax) return;
+28 -14
View File
@@ -28,7 +28,9 @@ import PageSheetLayer from './PageSheetLayer.svelte';
import LineNumberLayer from './LineNumberLayer.svelte';
import FoldMarkLayer from './FoldMarkLayer.svelte';
import ChangeBarLayer from './ChangeBarLayer.svelte';
import { markupView } from '../storage/markup.svelte';
import ReviewMarginLayer, { REVIEW_MARGIN_CM } from './ReviewMarginLayer.svelte';
import { visibleComments, visibleRevisions } from './reviewItems';
import { changesInMargin, commentsInMargin, markupView } from '../storage/markup.svelte';
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering';
import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
import Ruler from './Ruler.svelte';
@@ -58,7 +60,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
let {
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, splitView = false, pageColumns = 1, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR, lineNumbering = DEFAULT_LINE_NUMBERING, foldMarks = false,
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR, lineNumbering = DEFAULT_LINE_NUMBERING, foldMarks = false, commentAuthor = '',
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false,
headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = false,
@@ -76,6 +78,8 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
documentEpoch?: number;
/** A right-to-left page: the body's base direction, so its columns fill from the right. */
pageRtl?: boolean;
/** Comments and changes as balloons beside the page, and the author a new one takes. */
commentAuthor?: string;
/** Automatic hyphenation; the browser needs the document language to pick its patterns. */
hyphenate?: boolean; documentLanguage?: string;
/** How the page-number field counts (format + start value). */
@@ -429,6 +433,16 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
let scaledWidth = $state(0);
let scaledHeight = $state(0);
// The balloon strip is only reserved where there is something to put in it, so a
// document without review markup keeps the page centred exactly as before.
let hasReviewMarkup = $derived.by(() => {
if (tick < 0 || !editor) return false;
const doc = editor.state.doc;
return (changesInMargin() && visibleRevisions(doc).length > 0)
|| (commentsInMargin() && visibleComments(doc).some((c) => !c.resolved));
});
let reviewArea = $derived(hasReviewMarkup ? cmToPx(REVIEW_MARGIN_CM) : 0);
function recomputeScaledSize() {
const paper = papers[0];
if (!paper) return;
@@ -788,6 +802,11 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// Throttle the value actually written to the DOM to one update per animation frame,
// so rapid slider events don't trigger 50+ layout/paint cycles per second.
let appliedZoom = $state(untrack(() => zoom));
// The scroller's footprint holds page + strip; .paper-scaler's `margin: 0 auto` then
// centres the pair, so the page sits left of centre as it does in LibreOffice.
let scaledCanvasWidth = $derived(scaledWidth + Math.round(reviewArea * appliedZoom / 100));
let zoomRaf: number | null = null;
$effect(() => {
@@ -1457,8 +1476,11 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
{/if}
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledCanvasWidth}px; height: ${scaledHeight}px;` : ''}>
{@render paper(i, 0, 0)}
{#if hasReviewMarkup}
<ReviewMarginLayer {editor} {tick} {pageBoxes} {paperWidth} zoom={appliedZoom} author={commentAuthor} />
{/if}
</div>
{@render chrome(i)}
</div>
@@ -1607,11 +1629,14 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
<style>
/* The pane column. Unsplit it holds the one scroller and changes nothing. */
/* `min-width: 0` lets it be narrower than its canvas — see the same line on
`.editor` in editor.css. */
.editor-panes {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
}
/* The lower pane sits below the toolbar island, so it needs none of its clearance. */
@@ -1620,17 +1645,6 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
border-top: 1px solid var(--color-border);
}
/* The page grid: one scroller holding a canvas of page cells. `min-width: 0` lets
it be narrower than its canvas — a flex item's automatic minimum is its content,
so without it the grid widens the whole app instead of scrolling. */
.editor-panes.grid {
min-width: 0;
}
.editor-panes.grid :global(.editor) {
min-width: 0;
}
/* A cell is a window of exactly one page onto its own view of the document. */
.grid-canvas {
position: relative;
+319
View File
@@ -0,0 +1,319 @@
<script lang="ts" module>
// Page edge → balloon, and the balloon itself: the strip Editor.svelte reserves beside
// the page for this layer.
export const BALLOON_GAP_CM = 0.5;
// Wide enough that author, date and time share one line, as they do in the balloon
// both word processors draw — roughly a third of the page, which is Word's own strip.
export const BALLOON_W_CM = 6.5;
export const REVIEW_MARGIN_CM = BALLOON_GAP_CM + BALLOON_W_CM;
</script>
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { untrack } from 'svelte';
import { cmToPx } from '../storage/pageMargins';
import { commentIdAt, type CommentRange } from '../editor/extensions/comment';
import { revisionIdAt, authorColorIndex, REVISION_AUTHOR_COLORS, type Revision } from '../editor/extensions/trackChanges';
import { selectRange, visibleComments, visibleRevisions } from './reviewItems';
import { changesInMargin, commentsInMargin } from '../storage/markup.svelte';
import { stackTops } from '../utils/balloonStack';
import CommentCard from './CommentCard.svelte';
import RevisionCard from './RevisionCard.svelte';
// The margin balloons both word processors draw beside the page: one card per comment
// and per recorded change, at the height of the line it belongs to, with a dashed
// leader to it. It sits OUTSIDE .paper — that box clips horizontally and its width is
// the one pagination measures — but carries the same scale, so it zooms with the page.
let { editor, tick, pageBoxes, paperWidth, zoom, author }: {
editor: Editor | null;
tick: number;
/** One box per page (Editor.svelte): a section on its own paper differs in size. */
pageBoxes: { top: number; left: number; height: number; width: number }[];
paperWidth: number;
zoom: number;
author: string;
} = $props();
const COMMENT_COLOR = 'rgba(200, 140, 0, 0.9)';
const STACK_GAP_PX = 8;
// Below this a card shows nothing of its comment, so it stops shrinking and the column
// runs past the page instead.
const MIN_BALLOON_H = 56;
type Balloon = {
key: string;
color: string;
active: boolean;
/** The page its line is on: its card stacks in that page's band, beside it. */
page: number;
/** Where its line is, and where the leader leaves the text. */
want: number;
anchorX: number;
anchorY: number;
left: number;
comment?: CommentRange;
revision?: Revision;
};
let balloons = $state<Balloon[]>([]);
let heights = $state<number[]>([]);
let host = $state<HTMLElement | null>(null);
let els: (HTMLElement | null)[] = [];
let scheduled = false;
const schedule = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => { scheduled = false; measure(); });
};
$effect(() => {
// Re-measure on every edit, every selection move, each pagination settle — and when
// a kind moves out of the margin or back into it.
void tick;
void pageBoxes;
void zoom;
void commentsInMargin();
void changesInMargin();
schedule();
});
$effect(() => {
const el = host;
if (!el) return;
const scaler = el.parentElement;
scaler?.addEventListener('pm-pagecount', schedule);
return () => scaler?.removeEventListener('pm-pagecount', schedule);
});
// A card's height is its content's, and editing one changes it — so it is measured,
// not computed, and the stack follows.
$effect(() => {
const list = balloons;
const read = () => els.slice(0, list.length).map((e) => e?.offsetHeight ?? 0);
const ro = new ResizeObserver(() => { heights = read(); });
for (const e of els.slice(0, list.length)) if (e) ro.observe(e);
untrack(() => { heights = read(); });
return () => ro.disconnect();
});
// One entry per page, in page order: the cards beside a page stack among themselves.
let groups = $derived.by(() => {
const by = new Map<number, number[]>();
balloons.forEach((b, i) => {
const g = by.get(b.page);
if (g) g.push(i);
else by.set(b.page, [i]);
});
return [...by.entries()].map(([page, idx]) => ({ box: pageBoxes[page], idx }));
});
// The cards beside one page share its height, so a comment of any length still leaves
// room for the ones under it; what does not fit scrolls inside the card.
let caps = $derived.by(() => {
const out = new Array<number>(balloons.length).fill(0);
for (const { box, idx } of groups) {
if (!box) continue;
const share = (box.height - (idx.length - 1) * STACK_GAP_PX) / idx.length;
for (const i of idx) out[i] = Math.max(MIN_BALLOON_H, Math.floor(share));
}
return out;
});
// Stacked inside the page band: a card pushed down by the ones above it stops at the
// sheet's bottom edge, where both word processors also end the column.
let tops = $derived.by(() => {
const out = new Array<number>(balloons.length).fill(0);
for (const { box, idx } of groups) {
const got = stackTops(
idx.map((i) => balloons[i].want),
idx.map((i) => heights[i] ?? 0),
STACK_GAP_PX,
box ? box.top : -Infinity,
box ? box.top + box.height : Infinity,
);
idx.forEach((i, k) => { out[i] = got[k]; });
}
return out;
});
// The active one is pulled into view, as the pane does with its card.
$effect(() => {
const i = balloons.findIndex((b) => b.active);
if (i >= 0) { void tops; els[i]?.scrollIntoView({ block: 'nearest' }); }
});
function measure(): void {
const view = editor?.view;
if (!view || !host?.isConnected) { balloons = []; return; }
const state = view.state;
const open = commentsInMargin() ? visibleComments(state.doc).filter((c) => !c.resolved) : [];
const seen = new Set<string>();
const revs = changesInMargin()
? visibleRevisions(state.doc).filter((r) => !seen.has(r.id) && seen.add(r.id)) : [];
if (!open.length && !revs.length) { balloons = []; return; }
const order = authorColorIndex(revs);
const activeComment = commentIdAt(state);
const activeRevision = revisionIdAt(state);
const dom = view.dom as HTMLElement;
const rect = dom.getBoundingClientRect();
// Client rects are in zoom-scaled screen space; page boxes and this layer's own
// coordinates are unscaled document px, so divide the scale back out.
const scale = dom.offsetWidth ? rect.width / dom.offsetWidth : 1;
const out: Balloon[] = [];
const add = (from: number, to: number, part: Omit<Balloon, 'page' | 'want' | 'anchorX' | 'anchorY' | 'left'>) => {
let want: number;
let anchorX: number;
let anchorY: number;
try {
const a = view.coordsAtPos(from);
const b = view.coordsAtPos(to, -1);
// An anchor inside text the display mode hides has no box of its own; its card
// would otherwise stack at the top of the page, pointing nowhere.
if (a.bottom - a.top < 1) return;
want = (a.top - rect.top) / scale;
anchorX = (b.left - rect.left) / scale;
// The bottom of the line box, not its middle: both word processors run the leader
// through the gap between two lines, where it crosses no letters.
anchorY = (b.bottom - rect.top) / scale;
} catch { return; }
// The page the line sits on: a narrower section's page is centred in the sheet,
// so the balloon follows that page's own right edge.
const found = pageBoxes.findIndex((p) => want >= p.top && want < p.top + p.height);
const page = found < 0 ? pageBoxes.length - 1 : found;
const box = pageBoxes[page];
const left = (box ? box.left + box.width : paperWidth) + cmToPx(BALLOON_GAP_CM);
out.push({ ...part, page, want, anchorX, anchorY, left });
};
for (const c of open) {
add(c.from, c.to, { key: `c${c.id}`, color: COMMENT_COLOR, active: c.id === activeComment, comment: c });
}
for (const r of revs) {
add(r.from, r.to, {
key: `r${r.id}`,
color: REVISION_AUTHOR_COLORS[order.get(r.author) ?? 0],
active: r.id === activeRevision,
revision: r,
});
}
out.sort((a, b) => a.want - b.want);
balloons = out;
}
// Clicking a balloon selects the range it belongs to, as clicking a pane card does.
function select(b: Balloon): void {
const from = b.comment?.from ?? b.revision?.from;
const to = b.comment?.to ?? b.revision?.to;
if (!editor || from === undefined || to === undefined) return;
selectRange(editor, from, to);
}
// The leader: along the gap under the anchor's line, then eased into the balloon's own
// left edge. It meets the card at the anchor's own height wherever the card reaches
// that far, so a balloon sitting at its line is joined by one straight line.
let leaders = $derived(balloons.map((b, i) => {
const top = tops[i] ?? b.want;
const h = heights[i] ?? 0;
const y = b.anchorY;
const to = h ? Math.min(Math.max(y, top + 10), top + h - 10) : top + 10;
const turn = Math.max(b.anchorX + 6, b.left - 30);
const c = Math.max(0, (b.left - turn) / 2);
return {
d: `M ${b.anchorX} ${y} H ${turn} C ${turn + c} ${y} ${b.left - c} ${to} ${b.left} ${to}`,
color: b.color,
active: b.active,
};
}));
let width = $derived(paperWidth + cmToPx(REVIEW_MARGIN_CM));
let height = $derived(pageBoxes.length ? pageBoxes[pageBoxes.length - 1].top + pageBoxes[pageBoxes.length - 1].height : 0);
</script>
<div
class="review-margin-layer"
bind:this={host}
style="width: {width}px; height: {height}px; transform: scale({zoom / 100});"
>
{#if balloons.length}
<svg width={width} height={height} aria-hidden="true">
{#each leaders as l}
<path
d={l.d}
fill="none"
stroke={l.color}
stroke-width={l.active ? 1.4 : 1}
stroke-dasharray="3 3"
stroke-linecap="round"
opacity={l.active ? 1 : 0.7}
/>
{/each}
</svg>
{/if}
{#each balloons as b, i (b.key)}
<div
bind:this={els[i]}
class="balloon"
class:active={b.active}
style="top: {tops[i] ?? b.want}px; left: {b.left}px; width: {cmToPx(BALLOON_W_CM)}px; border-color: {b.color};"
style:max-height={caps[i] ? `${caps[i]}px` : null}
>
{#if b.comment}
<CommentCard {editor} c={b.comment} {author} onSelect={() => select(b)} />
{:else if b.revision}
<RevisionCard {editor} r={b.revision} {author} color={b.color} onSelect={() => select(b)} />
{/if}
</div>
{/each}
</div>
<style>
/* A sibling of .paper inside .paper-scaler, sharing its scale and its origin — the
page boxes are measured in the same unscaled document px. */
.review-margin-layer {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
pointer-events: none;
}
svg {
position: absolute;
top: 0;
left: 0;
}
.balloon {
position: absolute;
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid;
border-left-width: 3px;
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-sans);
font-size: 11px;
line-height: 1.35;
pointer-events: auto;
/* Nothing may leave the card sideways: a long word in a comment, a wide button row,
an author name without spaces. Down the card a comment past its share scrolls. */
overflow-x: hidden;
overflow-y: auto;
overflow-wrap: anywhere;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.balloon.active {
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
}
/* The date beside the author is the balloon's second line of information, not its
first: smaller than the name it follows. */
.balloon :global(.meta span) {
font-size: 10px;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
// Where the margin balloons end up (ReviewMarginLayer.svelte). Each one wants to sit at
// its anchor's line; two anchors a line apart carry balloons far taller than a line, so
// a balloon that would overlap its predecessor is pushed down, as in both word processors.
/** Tops for balloons in document order: none closer than `gap`, all inside the band. */
export function stackTops(
desired: number[],
heights: number[],
gap: number,
bandTop = -Infinity,
bandBottom = Infinity,
): number[] {
const out: number[] = [];
let floor = bandTop;
for (const [i, want] of desired.entries()) {
const top = Math.max(want, floor);
out.push(top);
floor = top + (heights[i] ?? 0) + gap;
}
// More cards than the band holds: they keep their distance and run past its end —
// overlapping them would hide the ones underneath. The caller caps heights so this
// stays the rare case.
const total = heights.slice(0, out.length).reduce((s, h) => s + (h ?? 0), 0) + Math.max(0, out.length - 1) * gap;
if (total > bandBottom - bandTop) return out;
// Back up again: the column may not hang past the band, and pulling the last card up
// pulls its predecessors with it.
let ceiling = bandBottom;
for (let i = out.length - 1; i >= 0; i--) {
out[i] = Math.min(out[i], ceiling - (heights[i] ?? 0));
ceiling = out[i] - gap;
}
return out;
}
+13 -1
View File
@@ -4,6 +4,10 @@
by App.svelte); extra bottom padding clears the fixed status bar. */
padding: calc(var(--toolbar-overlay-h, 0px) + 1.25rem) 1rem 3rem;
overflow: auto;
/* A flex item's automatic minimum is its content: without this a canvas wider than
the window — a page grid, or a page beside the balloon strip — widens the whole app
instead of scrolling here, and the toolbar above ends short of it. */
min-width: 0;
/* Positioning context for the floating table toolbar (TableToolbar.svelte),
which is an absolutely-positioned child that scrolls with the content. */
position: relative;
@@ -11,8 +15,10 @@
/* Reserves the SCALED footprint of the transformed .paper (a CSS transform takes
no layout space of its own), so the scrollbars and horizontal centering reflect
the zoomed page. Its width/height are sized in JS (Editor.svelte). */
the zoomed page. Its width/height are sized in JS (Editor.svelte). It is also the
origin the margin balloons position in (ReviewMarginLayer.svelte). */
.paper-scaler {
position: relative;
margin: 0 auto;
}
@@ -1112,6 +1118,12 @@
height: auto;
}
/* The printout carries the margin bar and the comment list instead
(export/reviewPrint.ts); the balloons are a screen view. */
.review-margin-layer {
display: none;
}
.paper {
width: 100%;
max-width: none;