mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
editor: estimate text from the layout it will wrap into
This commit is contained in:
@@ -179,8 +179,10 @@ describe("height map", () => {
|
||||
(node) => node.type.name === "image"
|
||||
) as ProsemirrorNode;
|
||||
|
||||
expect(map.estimate(image)).toBe(500);
|
||||
map.setWidth(500);
|
||||
// the editor defaults to an 850px measure, so a 1000px image already
|
||||
// scales; a narrower one scales further
|
||||
expect(map.estimate(image)).toBe(425);
|
||||
map.setMetrics({ width: 500 });
|
||||
expect(map.estimate(image)).toBe(250);
|
||||
editor.destroy();
|
||||
});
|
||||
@@ -199,6 +201,40 @@ describe("height map", () => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test("text is estimated from the lines it wraps to", () => {
|
||||
const editor = createEditor(para("word ".repeat(200)));
|
||||
const map = new HeightMap();
|
||||
const [paragraph] = blocks(editor);
|
||||
|
||||
const wide = map.estimate(paragraph);
|
||||
map.setMetrics({ width: 300 });
|
||||
const narrow = map.estimate(paragraph);
|
||||
|
||||
// the same text in a narrower measure wraps to more lines
|
||||
expect(narrow).toBeGreaterThan(wide);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test("a heading is estimated at its own size", () => {
|
||||
const text = "a fairly long heading that will wrap ".repeat(4);
|
||||
const editor = createEditor(
|
||||
para(text) + `<h1 data-block-id="${id()}">${text}</h1>`
|
||||
);
|
||||
const map = new HeightMap();
|
||||
const [paragraph, heading] = blocks(editor);
|
||||
|
||||
expect(heading.type.name).toBe("heading");
|
||||
expect(map.estimate(heading)).toBeGreaterThan(map.estimate(paragraph));
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test("new layout metrics mark the map for recalibration", () => {
|
||||
const map = new HeightMap();
|
||||
expect(map.needsRecalibration).toBe(false);
|
||||
map.setMetrics({ width: 400 });
|
||||
expect(map.needsRecalibration).toBe(true);
|
||||
});
|
||||
|
||||
test("a measured height wins over any estimate", () => {
|
||||
const editor = createEditor(para("one") + para("two"));
|
||||
const map = new HeightMap();
|
||||
|
||||
@@ -57,6 +57,28 @@ const TABLE_TYPE = "table";
|
||||
/** A table row is at least this tall, however little its cells hold. */
|
||||
const MIN_ROW_HEIGHT = 32;
|
||||
|
||||
/**
|
||||
* Layout the estimates assume until the editor can be measured. The width
|
||||
* matches the editor's own max-width so a first estimate is close even though
|
||||
* the element has not been laid out yet.
|
||||
*/
|
||||
const DEFAULT_METRICS: Metrics = { width: 850, fontSize: 16, lineHeight: 24 };
|
||||
|
||||
/** Average glyph width as a fraction of font size, for proportional text. */
|
||||
const AVERAGE_CHAR_WIDTH = 0.5;
|
||||
|
||||
/** How much larger each heading level renders than body text. */
|
||||
const HEADING_SCALE: Record<number, number> = {
|
||||
1: 2,
|
||||
2: 1.5,
|
||||
3: 1.25,
|
||||
4: 1.1,
|
||||
5: 1,
|
||||
6: 1
|
||||
};
|
||||
|
||||
export type Metrics = { width: number; fontSize: number; lineHeight: number };
|
||||
|
||||
type Samples = { height: number; content: number };
|
||||
|
||||
export class HeightMap {
|
||||
@@ -65,16 +87,29 @@ export class HeightMap {
|
||||
private global: Samples = { height: 0, content: 0 };
|
||||
private stale = false;
|
||||
private estimates = new WeakMap<ProsemirrorNode, number>();
|
||||
private width = 0;
|
||||
private metrics: Metrics = { ...DEFAULT_METRICS };
|
||||
|
||||
/**
|
||||
* The width content is laid out in. An image wider than the editor is scaled
|
||||
* down to fit, and its height with it.
|
||||
* The layout text is wrapped in. Nothing has been measured on a first load,
|
||||
* so estimates are built from the editor's own font and width instead of a
|
||||
* fixed guess: an 800-character paragraph is however many lines it wraps to.
|
||||
*/
|
||||
setWidth(width: number): void {
|
||||
if (!Number.isFinite(width) || width <= 0 || width === this.width) return;
|
||||
this.width = width;
|
||||
setMetrics(metrics: Partial<Metrics>): void {
|
||||
let changed = false;
|
||||
for (const key of ["width", "fontSize", "lineHeight"] as const) {
|
||||
const value = metrics[key];
|
||||
if (!Number.isFinite(value) || !value || value <= 0) continue;
|
||||
if (Math.abs((value as number) - this.metrics[key]) < 1) continue;
|
||||
this.metrics[key] = value as number;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
this.estimates = new WeakMap();
|
||||
this.stale = true;
|
||||
}
|
||||
|
||||
private get width(): number {
|
||||
return this.metrics.width;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,11 +159,37 @@ export class HeightMap {
|
||||
return total || base;
|
||||
}
|
||||
|
||||
return this.text(node, base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text is estimated from the lines it wraps to. A measured ratio for the type
|
||||
* wins once there is one, since it accounts for margins and font quirks the
|
||||
* line model cannot see.
|
||||
*/
|
||||
private text(node: ProsemirrorNode, base: number): number {
|
||||
// `content.size` is O(1) and proportional to how much text a node holds,
|
||||
// unlike `textContent`, which would copy every character of every page.
|
||||
const content = node.content.size;
|
||||
if (!content) return base;
|
||||
return Math.max(base, Math.round(content * this.ratioFor(node.type.name)));
|
||||
|
||||
const sample = this.samples.get(node.type.name);
|
||||
if (sample && sample.content > 0)
|
||||
return Math.max(
|
||||
base,
|
||||
Math.round(content * (sample.height / sample.content))
|
||||
);
|
||||
|
||||
const scale =
|
||||
node.type.name === "heading"
|
||||
? HEADING_SCALE[Number(node.attrs.level)] ?? 1
|
||||
: 1;
|
||||
const charsPerLine = Math.max(
|
||||
20,
|
||||
this.metrics.width / (this.metrics.fontSize * scale * AVERAGE_CHAR_WIDTH)
|
||||
);
|
||||
const lines = Math.max(1, Math.ceil(content / charsPerLine));
|
||||
return Math.max(base, Math.round(lines * this.metrics.lineHeight * scale));
|
||||
}
|
||||
|
||||
/** Images and embeds know their own size; scale it if it must fit. */
|
||||
|
||||
@@ -451,7 +451,16 @@ export function virtualizationPlugin(
|
||||
*/
|
||||
const recordRenderedHeights = () => {
|
||||
if (!heightMap) return;
|
||||
heightMap.setWidth(editorView.dom.clientWidth);
|
||||
// Read the layout the note is actually rendered in, so estimates for
|
||||
// everything still off screen match what the reader will see.
|
||||
const style = getComputedStyle(editorView.dom);
|
||||
const fontSize = parseFloat(style.fontSize);
|
||||
const lineHeight = parseFloat(style.lineHeight);
|
||||
heightMap.setMetrics({
|
||||
width: editorView.dom.clientWidth,
|
||||
fontSize,
|
||||
lineHeight: Number.isFinite(lineHeight) ? lineHeight : fontSize * 1.5
|
||||
});
|
||||
const children = editorView.dom.children;
|
||||
const doc = editorView.state.doc;
|
||||
const count = Math.min(children.length, doc.childCount);
|
||||
|
||||
Reference in New Issue
Block a user