Three things that only showed on screen
Grey letters ran down the bar while dragging a column edge. The editor marks the column under the pointer by putting a small div in each of its cells; drawn along the cell's right edge, that absolutely positioned child sent Chromium down another painting path and the cell's own text came out a second time, squeezed into the two pixels under the bar, one letter to a line. It appeared with the bar and went with it, which named the culprit -- measured, there was no text there at all, only three empty markers, and a forced repaint did not clear it. The marker is display:none now and only says which cells the column is made of; the line is an inset shadow on those cells, matched with :has(). Two suspects were tried and reverted first: the table as its own paint region, and separate borders. Neither changed anything, so neither stayed. Opening a note flashed a long narrow page. The drawing stage starts at the canonical page, 1600px tall, and the measurement that fits it to the pane ran as an ordinary effect -- a frame too late. useLayoutEffect runs between the DOM being built and the browser painting it, which is exactly the gap that flash lived in. The test samples the sheet's height every frame across a note switch, because one frame is all it was. The mark above a note did not stand in the same column as the tools below it. Vertically it was already exact -- measured, symbol centre 20, cap centre 20 -- but it began six pixels right of the first tool, and the title thirty. The mark's box is only as wide as the mark now, so the mark lands on that column, and the title follows nine pixels closer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c0f2184e6
commit
641acaae40
7 files changed
+154
-19
No files matched your search
@@ -8,6 +8,34 @@ not from the output of `docker push`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-17 — Three things that only showed on screen
|
||||||
|
|
||||||
|
**Images** — web `sha256:PENDING`, server `sha256:PENDING`
|
||||||
|
|
||||||
|
No backend source changed this round.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **Grey letters ran down the bar while dragging a column edge.** The editor
|
||||||
|
marks the column under the pointer by putting a small div in each of its
|
||||||
|
cells; drawn along the cell's right edge, that absolutely positioned child
|
||||||
|
sent Chromium down another painting path and the cell's own text came out a
|
||||||
|
second time, squeezed into the two pixels under the bar, one letter to a line.
|
||||||
|
It appeared with the bar and went with it, which is what named the culprit —
|
||||||
|
measured, there was no text there at all, only three empty markers. The marker
|
||||||
|
is never drawn now; it only says which cells the column is made of, and the
|
||||||
|
line is an inset shadow on those cells.
|
||||||
|
- **Opening a note flashed a long narrow page.** The drawing stage starts at the
|
||||||
|
canonical page, 1600px tall, and the measurement that fits it to the pane ran
|
||||||
|
as an ordinary effect — a frame too late. Every note switch drew one frame at
|
||||||
|
the wrong size and then righted itself. Measured before the first paint now.
|
||||||
|
- **The mark above a note did not stand in the same column as the tools below
|
||||||
|
it.** Vertically it was already exact; sideways it began six pixels right of
|
||||||
|
the first tool, and the title thirty. The mark's box is only as wide as the
|
||||||
|
mark, so the mark itself lands on that column, and the title follows nine
|
||||||
|
pixels closer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-08-17 — A chapter with an order of its own
|
## 2026-08-17 — A chapter with an order of its own
|
||||||
|
|
||||||
**Images** — web `sha256:b7c9f8b7…`, server `sha256:a0fd140a…`
|
**Images** — web `sha256:b7c9f8b7…`, server `sha256:a0fd140a…`
|
||||||
|
|||||||
@@ -131,6 +131,35 @@ try {
|
|||||||
);
|
);
|
||||||
check((await p.locator(".ProseMirror").innerText()).includes("Grundtext"), "plain text survived a reload");
|
check((await p.locator(".ProseMirror").innerText()).includes("Grundtext"), "plain text survived a reload");
|
||||||
await p.screenshot({ path: `${OUT}/editor-after-reload.png` });
|
await p.screenshot({ path: `${OUT}/editor-after-reload.png` });
|
||||||
|
|
||||||
|
/* ── the sheet is the right size from the first frame ──
|
||||||
|
* It was not. The stage starts at the canonical page, 1600px tall, and the
|
||||||
|
* measurement that pulls it back to the pane ran an ordinary effect — a frame
|
||||||
|
* too late. Every note opened by flashing a long narrow sheet and then
|
||||||
|
* righting itself. Sampled every frame, because one frame is all it was. */
|
||||||
|
const settled = await p.locator("[data-page-sheet]").first().evaluate((el) => el.getBoundingClientRect().height);
|
||||||
|
await p.evaluate(() => {
|
||||||
|
window.__tall = 0;
|
||||||
|
const tick = () => {
|
||||||
|
const el = document.querySelector("[data-page-sheet]");
|
||||||
|
if (el) window.__tall = Math.max(window.__tall, el.getBoundingClientRect().height);
|
||||||
|
window.__raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
});
|
||||||
|
await p.locator("button").filter({ hasText: "Ziel" }).first().click();
|
||||||
|
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
const tallest = await p.evaluate(() => {
|
||||||
|
cancelAnimationFrame(window.__raf);
|
||||||
|
return window.__tall;
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
tallest <= settled * 1.15,
|
||||||
|
"switching notes never draws the sheet at the wrong size, not even for a frame",
|
||||||
|
`settled ${Math.round(settled)}, tallest ${Math.round(tallest)}`
|
||||||
|
);
|
||||||
|
await reopenNote(p, "Quelle");
|
||||||
/* ── how many pages the writing fills ── */
|
/* ── how many pages the writing fills ── */
|
||||||
// Pages are a reading aid, not a change to the document: the note stays one
|
// Pages are a reading aid, not a change to the document: the note stays one
|
||||||
// body, and Markdown has no page. The height of one is the same 3:4 the canvas
|
// body, and Markdown has no page. The height of one is the same 3:4 the canvas
|
||||||
|
|||||||
@@ -120,6 +120,38 @@ try {
|
|||||||
});
|
});
|
||||||
check(!/ne-page-spacer/.test(body || ""), "and no page furniture reaches the stored note");
|
check(!/ne-page-spacer/.test(body || ""), "and no page furniture reaches the stored note");
|
||||||
check(/table/.test(body || ""), "which does hold the table itself", String(body || "").slice(0, 60));
|
check(/table/.test(body || ""), "which does hold the table itself", String(body || "").slice(0, 60));
|
||||||
|
|
||||||
|
/* ── the edge being dragged is drawn on the cell, not in it ──
|
||||||
|
* The editor marks the column under the pointer with a small div in each of
|
||||||
|
* its cells. Positioned along the cell's right edge it painted the cell's own
|
||||||
|
* text a second time, squeezed into the two pixels under the bar, one letter
|
||||||
|
* to a line — an absolutely positioned child inside a collapsed-border table
|
||||||
|
* cell sends Chromium down another painting path. The div is never drawn now;
|
||||||
|
* it only says which cells the column is made of. */
|
||||||
|
const cell = await p.locator(".ProseMirror table th").first().boundingBox();
|
||||||
|
await p.mouse.move(cell.x + cell.width - 1, cell.y + cell.height / 2);
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
const edge = await p.evaluate(() => {
|
||||||
|
const marks = [...document.querySelectorAll(".column-resize-handle")];
|
||||||
|
const host = marks[0]?.parentElement;
|
||||||
|
return {
|
||||||
|
marks: marks.length,
|
||||||
|
drawn: marks.map((m) => getComputedStyle(m).display),
|
||||||
|
onCell: host ? getComputedStyle(host).boxShadow : "",
|
||||||
|
painted: marks.every((m) => m.getBoundingClientRect().width === 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(edge.marks > 0, "holding over a column edge marks that column", JSON.stringify(edge.marks));
|
||||||
|
check(
|
||||||
|
edge.drawn.every((d) => d === "none") && edge.painted,
|
||||||
|
"the marker itself is never painted",
|
||||||
|
JSON.stringify(edge.drawn)
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
/inset/.test(edge.onCell) && edge.onCell !== "none",
|
||||||
|
"and the line is drawn on the cell that carries it",
|
||||||
|
edge.onCell
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
check(false, "suite ran to the end", e.message);
|
check(false, "suite ran to the end", e.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -225,15 +225,27 @@
|
|||||||
background: color-mix(in srgb, var(--ne-accent) 18%, transparent);
|
background: color-mix(in srgb, var(--ne-accent) 18%, transparent);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
/* Drag handle between columns */
|
/* The edge being dragged, drawn *on the cell* rather than in it.
|
||||||
|
*
|
||||||
|
* The editor marks the column under the pointer by putting a small div in each
|
||||||
|
* cell of it, and the obvious way to show that is to position the div along the
|
||||||
|
* cell's right edge. Which worked, and painted grey letters down the bar: an
|
||||||
|
* absolutely positioned child inside a cell of a `border-collapse: collapse`
|
||||||
|
* table sends Chromium down another painting path, and the cell's own text came
|
||||||
|
* out a second time, squeezed into the two pixels under the bar — one letter to
|
||||||
|
* a line. It appeared with the bar and went with it, which is what named the
|
||||||
|
* culprit.
|
||||||
|
*
|
||||||
|
* So the div is never drawn at all; it only marks which cells belong to the
|
||||||
|
* column, and the line is an inset shadow on those cells. Nothing is positioned,
|
||||||
|
* nothing paints twice, and the result is the same line — a continuous one down
|
||||||
|
* the column, because every cell in it carries its own piece. */
|
||||||
.note-editor .ProseMirror .column-resize-handle {
|
.note-editor .ProseMirror .column-resize-handle {
|
||||||
position: absolute;
|
display: none;
|
||||||
right: -2px;
|
}
|
||||||
top: 0;
|
.note-editor .ProseMirror td:has(> .column-resize-handle),
|
||||||
bottom: -2px;
|
.note-editor .ProseMirror th:has(> .column-resize-handle) {
|
||||||
width: 4px;
|
box-shadow: inset -2px 0 0 var(--ne-accent);
|
||||||
background: var(--ne-accent);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
.note-editor .ProseMirror.resize-cursor {
|
.note-editor .ProseMirror.resize-cursor {
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
|||||||
@@ -188,7 +188,10 @@ export function EditorPane({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: "8px 16px",
|
/* The same inset as the toolbar under it. The mark used to start six
|
||||||
|
pixels right of the first tool below it, so the two rows did not
|
||||||
|
share a left edge and the title read as pushed across. */
|
||||||
|
padding: "8px 12px",
|
||||||
borderBottom: `1px solid ${T.borderLight}`,
|
borderBottom: `1px solid ${T.borderLight}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -204,21 +207,24 @@ export function EditorPane({
|
|||||||
its box and sat on the middle. Measured, the mark ended up 2px high
|
its box and sat on the middle. Measured, the mark ended up 2px high
|
||||||
and dipped below the line the title never crossed. Sharing a baseline
|
and dipped below the line the title never crossed. Sharing a baseline
|
||||||
is what "on one line" means, and it needs no number. */}
|
is what "on one line" means, and it needs no number. */}
|
||||||
<div style={{ display: "flex", alignItems: "baseline", gap: 4, flex: 1, minWidth: 0 }}>
|
<div style={{ display: "flex", alignItems: "baseline", gap: 5, flex: 1, minWidth: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={onPickIcon}
|
onClick={onPickIcon}
|
||||||
data-note-icon={note.icon || "none"}
|
data-note-icon={note.icon || "none"}
|
||||||
title="Choose an icon for this note"
|
title="Choose an icon for this note"
|
||||||
aria-label="Choose an icon for this note"
|
aria-label="Choose an icon for this note"
|
||||||
style={{
|
style={{
|
||||||
/* One square box whatever it holds. An emoji and the drawn glyph
|
/* One box of a fixed width whatever it holds. An emoji and the
|
||||||
have different metrics, and sizing to the content moved the
|
drawn glyph have different metrics, and sizing to the content
|
||||||
title sideways the moment a mark was chosen. Laid out as text
|
moved the title sideways the moment a mark was chosen. Just
|
||||||
|
wide enough for the mark, so the mark itself lands on the
|
||||||
|
column the tools below it stand in; still a full row tall, so
|
||||||
|
there is something to hit with a finger. Laid out as text
|
||||||
rather than as a flex box, because only text has a baseline for
|
rather than as a flex box, because only text has a baseline for
|
||||||
the title to line up with. */
|
the title to line up with. */
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
width: 26,
|
width: 20,
|
||||||
height: 26,
|
height: 26,
|
||||||
lineHeight: "26px",
|
lineHeight: "26px",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx";
|
import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx";
|
||||||
import { EditorToolbar } from "../../editor/EditorToolbar.jsx";
|
import { EditorToolbar } from "../../editor/EditorToolbar.jsx";
|
||||||
import { tableMenuItems } from "../../editor/tableMenu.js";
|
import { tableMenuItems } from "../../editor/tableMenu.js";
|
||||||
@@ -181,8 +181,15 @@ export function NoteEditor({
|
|||||||
};
|
};
|
||||||
}, [note.id, note.drawingData]);
|
}, [note.id, note.drawingData]);
|
||||||
|
|
||||||
// Keep one fixed-aspect drawing stage regardless of device dimensions.
|
/* Keep one fixed-aspect drawing stage regardless of device dimensions.
|
||||||
useEffect(() => {
|
*
|
||||||
|
* Measured *before* the first paint, not after. The stage starts at the
|
||||||
|
* canonical page — 1200 × 1600 — and this runs on every note, because the
|
||||||
|
* editor is built fresh for each one. As an ordinary effect it ran a frame
|
||||||
|
* too late, so opening a note drew one frame of a page sixteen hundred pixels
|
||||||
|
* tall before the measurement pulled it back: the sheet shot up long and
|
||||||
|
* narrow and then righted itself, every single time. */
|
||||||
|
useLayoutEffect(() => {
|
||||||
const fitStage = () => {
|
const fitStage = () => {
|
||||||
const host = stageRef.current;
|
const host = stageRef.current;
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
|||||||
| Suite | Checks | What it holds down |
|
| Suite | Checks | What it holds down |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
|
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
|
||||||
| `editor` | 36 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
|
| `editor` | 37 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
|
||||||
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
||||||
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
||||||
| `drawing` | 18 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
|
| `drawing` | 18 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
|
||||||
@@ -469,7 +469,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
|||||||
| `organise` | 75 | a hand-made order survives a search; a new note lands on top and a deleted one takes only itself; one line goes under another and stays there |
|
| `organise` | 75 | a hand-made order survives a search; a new note lands on top and a deleted one takes only itself; one line goes under another and stays there |
|
||||||
| `chapters` | 60 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog |
|
| `chapters` | 60 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog |
|
||||||
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
|
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
|
||||||
| `tables` | 8 | a long table is broken between its rows, with none left lying across the gap, and no page furniture reaches the stored note |
|
| `tables` | 11 | a long table is broken between its rows, with none left lying across the gap, no page furniture reaches the stored note, and the column edge being dragged is drawn on the cell rather than in it |
|
||||||
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
|
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
|
||||||
|
|
||||||
Each suite creates a throwaway account through the admin API and deletes it
|
Each suite creates a throwaway account through the admin API and deletes it
|
||||||
@@ -533,6 +533,14 @@ sheet's margin, rather than an unexplained void.
|
|||||||
The sheet used to appear only once you started drawing, which made the writing
|
The sheet used to appear only once you started drawing, which made the writing
|
||||||
mode look like text floating in an empty pane.
|
mode look like text floating in an empty pane.
|
||||||
|
|
||||||
|
**The sheet is measured before the first paint**, not after. It starts at the
|
||||||
|
canonical page the drawing lives in — 1200 × 1600 — and is fitted to the pane by
|
||||||
|
a measurement; as an ordinary effect that ran a frame late, so every note opened
|
||||||
|
by drawing one frame of a sheet sixteen hundred pixels tall and then righting
|
||||||
|
itself. A `useLayoutEffect` runs between the DOM being built and the browser
|
||||||
|
painting it, which is exactly the gap that flash lived in. A test samples the
|
||||||
|
sheet's height every frame across a note switch, because one frame is all it was.
|
||||||
|
|
||||||
**Pages are counted, not stored.** The toolbar says *Page 2 of 4* in the same
|
**Pages are counted, not stored.** The toolbar says *Page 2 of 4* in the same
|
||||||
words the PDF reader uses. A page is the same 3:4 the canvas uses, so "page 2"
|
words the PDF reader uses. A page is the same 3:4 the canvas uses, so "page 2"
|
||||||
means the same thing writing or drawing. The note itself stays one continuous
|
means the same thing writing or drawing. The note itself stays one continuous
|
||||||
@@ -571,6 +579,19 @@ Anything else too tall to fit anywhere — a full-page image — is left where i
|
|||||||
and runs over the gap: moving it down only puts the same problem one sheet
|
and runs over the gap: moving it down only puts the same problem one sheet
|
||||||
further on.
|
further on.
|
||||||
|
|
||||||
|
**The column edge you drag is drawn on the cell, not in it.** The editor marks
|
||||||
|
the column under the pointer by putting a small div in each of its cells, and the
|
||||||
|
obvious thing to do is position that div along the cell's right edge. It works,
|
||||||
|
and it paints grey letters down the bar: an absolutely positioned child inside a
|
||||||
|
cell of a `border-collapse: collapse` table sends Chromium down another painting
|
||||||
|
path, and the cell's own text comes out a second time, squeezed into the two
|
||||||
|
pixels under the bar, one letter to a line. It appeared with the bar and went
|
||||||
|
with it — which named the culprit, since the DOM at that spot held nothing but
|
||||||
|
three empty markers. The marker is `display: none` now and only says which cells
|
||||||
|
the column is made of; the line is an inset shadow on those cells, matched with
|
||||||
|
`:has()`. Nothing is positioned, so nothing paints twice, and the line looks the
|
||||||
|
same — continuous down the column, because each cell carries its own piece.
|
||||||
|
|
||||||
Two details that are easy to get wrong and were:
|
Two details that are easy to get wrong and were:
|
||||||
|
|
||||||
- **The spacer's height is not the distance the text moves.** Margins that used
|
- **The spacer's height is not the distance the text moves.** Margins that used
|
||||||
|
|||||||
Reference in new issue
Block a user