Skip to content

Commit 838b2cf

Browse files
Replace static editor bottom padding with dynamic scroll-past-end
Remove the fixed 70dvh bottom padding and replace it with a CodeMirror extension that progressively adds a spacer element as content grows. Padding starts at 30% viewport fill and caps at 70%. The editor now fills the full visible area via a flex chain, and clicking the spacer places the cursor at end of document. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 90ef888 commit 838b2cf

4 files changed

Lines changed: 136 additions & 7 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { EditorSelection } from "@codemirror/state";
2+
import {
3+
EditorView,
4+
type PluginValue,
5+
ViewPlugin,
6+
type ViewUpdate,
7+
} from "@codemirror/view";
8+
9+
import { findEditorScrollContainer } from "@/features/editor/lib/view-utils";
10+
11+
type ScrollPastEndOptions = {
12+
/** Content-to-viewport ratio at which padding begins (default: 0.5 = 50%). */
13+
startRatio?: number;
14+
/** Step size: padding increases every this fraction of growth (default: 0.05 = 5%). */
15+
step?: number;
16+
/** Maximum padding as a fraction of viewport height (default: 0.7 = 70%). */
17+
maxPaddingRatio?: number;
18+
};
19+
20+
class ScrollPastEndPlugin implements PluginValue {
21+
private currentHeight = 0;
22+
private spacer: HTMLDivElement | null = null;
23+
24+
constructor(
25+
private readonly view: EditorView,
26+
private readonly startRatio: number,
27+
private readonly step: number,
28+
private readonly maxPaddingRatio: number,
29+
) {
30+
this.measure();
31+
}
32+
33+
update(update: ViewUpdate): void {
34+
if (update.geometryChanged || update.docChanged) {
35+
this.measure();
36+
}
37+
}
38+
39+
private ensureSpacer(): HTMLDivElement | null {
40+
if (this.spacer) {
41+
return this.spacer;
42+
}
43+
44+
const scrollContainer = findEditorScrollContainer(this.view);
45+
if (!scrollContainer) {
46+
return null;
47+
}
48+
49+
this.spacer = document.createElement("div");
50+
this.spacer.setAttribute("aria-hidden", "true");
51+
this.spacer.style.cursor = "text";
52+
this.spacer.addEventListener("mousedown", this.handleSpacerClick);
53+
scrollContainer.append(this.spacer);
54+
return this.spacer;
55+
}
56+
57+
private measure(): void {
58+
const scrollContainer = findEditorScrollContainer(this.view);
59+
if (!scrollContainer) {
60+
return;
61+
}
62+
63+
const viewportHeight = scrollContainer.clientHeight;
64+
if (viewportHeight === 0) {
65+
return;
66+
}
67+
68+
const contentHeight = this.view.contentHeight;
69+
const ratio = contentHeight / viewportHeight;
70+
71+
let height = 0;
72+
if (ratio > this.startRatio) {
73+
const excess = ratio - this.startRatio;
74+
const steps = Math.floor(excess / this.step);
75+
const maxHeight = viewportHeight * this.maxPaddingRatio;
76+
height = Math.min(steps * (maxHeight / 20), maxHeight);
77+
}
78+
79+
if (height !== this.currentHeight) {
80+
this.currentHeight = height;
81+
if (height > 0) {
82+
const spacer = this.ensureSpacer();
83+
if (spacer) {
84+
spacer.style.height = `${height}px`;
85+
}
86+
} else if (this.spacer) {
87+
this.spacer.style.height = "0";
88+
}
89+
}
90+
}
91+
92+
private handleSpacerClick = (event: MouseEvent): void => {
93+
event.preventDefault();
94+
this.view.dispatch({
95+
selection: EditorSelection.cursor(this.view.state.doc.length),
96+
scrollIntoView: false,
97+
});
98+
this.view.focus();
99+
};
100+
101+
destroy(): void {
102+
this.spacer?.removeEventListener("mousedown", this.handleSpacerClick);
103+
this.spacer?.remove();
104+
this.spacer = null;
105+
}
106+
}
107+
108+
export function scrollPastEnd({
109+
startRatio = 0.3,
110+
step = 0.05,
111+
maxPaddingRatio = 0.7,
112+
}: ScrollPastEndOptions = {}) {
113+
return ViewPlugin.fromClass(
114+
class extends ScrollPastEndPlugin {
115+
constructor(view: EditorView) {
116+
super(view, startRatio, step, maxPaddingRatio);
117+
}
118+
},
119+
);
120+
}

app/src/features/editor/lib/note-editor-config.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,28 @@ export const MARKDOWN_HIGHLIGHT_STYLE = HighlightStyle.define([
5757

5858
export const MARKDOWN_EDITOR_THEME = EditorView.theme({
5959
"&": {
60-
minHeight: "100%",
60+
flex: "1",
61+
display: "flex",
62+
flexDirection: "column",
63+
minHeight: "0",
6164
background: "transparent",
6265
cursor: "text",
6366
},
6467
"&.cm-focused": {
6568
outline: "none",
6669
},
6770
".cm-scroller": {
68-
minHeight: "100%",
71+
flex: "1",
72+
display: "flex",
73+
flexDirection: "column",
74+
minHeight: "0",
6975
overflow: "visible",
7076
fontFamily: '"Figtree Variable", sans-serif',
7177
cursor: "text",
7278
},
7379
".cm-content": {
74-
minHeight: "100%",
80+
flex: "1",
81+
minHeight: "0",
7582
color: "var(--editor-text)",
7683
caretColor: "var(--editor-caret)",
7784
cursor: "text",

app/src/features/editor/note-editor.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import { dropImage } from "@/features/editor/extensions/drop-image";
7272
import { pasteImage } from "@/features/editor/extensions/paste-image";
7373
import { pasteLink } from "@/features/editor/extensions/paste-link";
7474
import { scrollCenterOnEnter } from "@/features/editor/extensions/scroll-center-on-enter";
75+
import { scrollPastEnd } from "@/features/editor/extensions/scroll-past-end";
7576
import { deleteTableBackward } from "@/features/editor/extensions/tables/delete-table-boundary";
7677
import {
7778
getHorizontalRuleSelection,
@@ -442,6 +443,7 @@ export const NoteEditor = forwardRef<NoteEditorHandle, NoteEditorProps>(
442443
drawSelection(),
443444
EditorView.lineWrapping,
444445
scrollCenterOnEnter({ viewportPercentage: 5 }),
446+
scrollPastEnd(),
445447
markdownLanguage({
446448
base: markdownLang,
447449
extensions: [
@@ -809,7 +811,7 @@ export const NoteEditor = forwardRef<NoteEditorHandle, NoteEditorProps>(
809811
<>
810812
<div
811813
className={cn(
812-
"comet-editor-shell relative flex min-h-full w-full flex-1",
814+
"comet-editor-shell relative flex min-h-full w-full flex-1 flex-col",
813815
searchHighlightAllMatchesYellow &&
814816
"comet-codemirror-passive-search",
815817
searchQuery &&
@@ -819,7 +821,7 @@ export const NoteEditor = forwardRef<NoteEditorHandle, NoteEditorProps>(
819821
>
820822
<div className="comet-editor-column">
821823
<div
822-
className="comet-codemirror-host min-h-full flex-1"
824+
className="comet-codemirror-host flex min-h-0 flex-1 flex-col"
823825
ref={containerRef}
824826
/>
825827
</div>

app/src/index.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ body {
3030

3131
:root {
3232
--radius: 0.5rem;
33-
--editor-bottom-padding: 70dvh;
3433
}
3534

3635
@theme inline {
@@ -121,12 +120,13 @@ body {
121120

122121
.comet-editor-column {
123122
flex: 1;
123+
display: flex;
124+
flex-direction: column;
124125
width: 100%;
125126
max-width: 100%;
126127
min-width: 0;
127128
position: relative;
128129
box-sizing: border-box;
129-
padding-bottom: var(--editor-bottom-padding);
130130
}
131131

132132
.comet-editor-column::before {

0 commit comments

Comments
 (0)