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
5 changes: 5 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,11 @@ button:disabled {
line-height: calc(var(--c-buffer-size) * var(--c-buffer-line-height));
}

.zed-gutter[data-wrap="true"] .zed-gutter__line {
/* Height is set inline from measured visual rows when wrap is on. */
min-height: calc(var(--c-buffer-size) * var(--c-buffer-line-height));
}

.zed-gutter__line[data-active="true"] {
color: var(--c-editor-active-line-number);
}
Expand Down
51 changes: 50 additions & 1 deletion components/memo-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Note } from "@/lib/types";
import {
measureWrappedRowCounts,
unitRowCounts,
} from "@/lib/gutter";
import {
DEFAULT_WRAP,
WRAP_STORAGE_KEY,
Expand Down Expand Up @@ -120,6 +124,8 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) {
useState<Appearance>(DEFAULT_APPEARANCE);
const [wrap, setWrap] = useState(DEFAULT_WRAP);
const [caret, setCaret] = useState(0);
const [gutterRows, setGutterRows] = useState<number[]>([1]);
const [gutterLineHeightPx, setGutterLineHeightPx] = useState(0);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipNextSave = useRef(false);
const bodyRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -257,6 +263,38 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) {
const lineCount = Math.max(1, body.split("\n").length);
const currentLine = activeLineNumber(body, caret);

const refreshGutterMetrics = useCallback(() => {
const textarea = bodyRef.current;
if (!textarea) return;

const style = window.getComputedStyle(textarea);
const lineHeightPx = parseFloat(style.lineHeight);
if (Number.isFinite(lineHeightPx) && lineHeightPx > 0) {
setGutterLineHeightPx(lineHeightPx);
}

if (!wrap) {
setGutterRows(unitRowCounts(lineCount));
return;
}

setGutterRows(measureWrappedRowCounts(textarea, body));
}, [body, wrap, lineCount]);

useEffect(() => {
refreshGutterMetrics();
}, [refreshGutterMetrics]);

useEffect(() => {
const textarea = bodyRef.current;
if (!textarea) return;
const observer = new ResizeObserver(() => {
refreshGutterMetrics();
});
observer.observe(textarea);
return () => observer.disconnect();
}, [refreshGutterMetrics, activeId]);

const selectNote = useCallback((note: Note) => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
Expand Down Expand Up @@ -587,14 +625,25 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) {
{activeId ? (
<div className="zed-editor">
<div className="zed-buffer">
<div className="zed-gutter" ref={gutterRef} aria-hidden>
<div
className="zed-gutter"
ref={gutterRef}
data-wrap={wrap ? "true" : "false"}
aria-hidden
>
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1;
const rows = gutterRows[index] ?? 1;
return (
<div
key={line}
className="zed-gutter__line"
data-active={line === currentLine}
style={
wrap && gutterLineHeightPx > 0
? { height: `${rows * gutterLineHeightPx}px` }
: undefined
}
>
{line}
</div>
Expand Down
71 changes: 71 additions & 0 deletions lib/gutter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Measure how many visual rows each hard line occupies in a wrapped textarea.
* Returns one entry per `\n`-separated line (empty document → [1]).
*/
export function measureWrappedRowCounts(
textarea: HTMLTextAreaElement,
text: string,
): number[] {
const lines = text.split("\n");
if (lines.length === 0) return [1];

const style = window.getComputedStyle(textarea);
const lineHeightPx = parseFloat(style.lineHeight);
if (!Number.isFinite(lineHeightPx) || lineHeightPx <= 0) {
return lines.map(() => 1);
}

// Content-box width available for text (exclude horizontal padding).
const paddingLeft = parseFloat(style.paddingLeft) || 0;
const paddingRight = parseFloat(style.paddingRight) || 0;
const contentWidth = Math.max(
0,
textarea.clientWidth - paddingLeft - paddingRight,
);
if (contentWidth <= 0) {
return lines.map(() => 1);
}

const mirror = document.createElement("div");
mirror.setAttribute("aria-hidden", "true");
Object.assign(mirror.style, {
position: "absolute",
visibility: "hidden",
height: "auto",
width: `${contentWidth}px`,
top: "0",
left: "-99999px",
whiteSpace: style.whiteSpace,
overflowWrap: style.overflowWrap,
wordBreak: style.wordBreak,
font: style.font,
fontSize: style.fontSize,
fontFamily: style.fontFamily,
fontWeight: style.fontWeight,
fontStyle: style.fontStyle,
letterSpacing: style.letterSpacing,
lineHeight: style.lineHeight,
tabSize: style.tabSize,
boxSizing: "content-box",
padding: "0",
border: "0",
margin: "0",
} as Partial<CSSStyleDeclaration>);

document.body.appendChild(mirror);

try {
return lines.map((line) => {
// Preserve trailing spaces / empty lines the same way pre-wrap does.
mirror.textContent = line.length === 0 ? "\u00a0" : line;
const rows = Math.max(1, Math.round(mirror.offsetHeight / lineHeightPx));
return rows;
});
} finally {
mirror.remove();
}
}

export function unitRowCounts(lineCount: number): number[] {
return Array.from({ length: Math.max(1, lineCount) }, () => 1);
}