From 767dd076dd79a4b39c4ad87f82f3ab0897c15539 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 21 Oct 2026 12:38:26 +0200 Subject: [PATCH] markdown: a paragraph an editor wrapped reads as running text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `breaks` is on because the diary is written a line at a time and a line typed in the browser is meant. Markdown written elsewhere is usually wrapped at a width instead — an import, a save over the SFTP mount from vim with `textwidth`, a pasted README — and kept its breaks, one in the middle of every sentence, worst on a phone. `hardWrapped` tells the two apart by what a wrapping editor leaves behind: every line but the last is long, each break comes where the next word would not have fitted on the line before, and the lines seldom end where a sentence does. A paragraph that looks like that has its soft breaks read as spaces; a break asked for outright, two spaces or a backslash at the end of the line, stays. Lines broken by hand — a short sentence and then another, a list of things that happened, a verse — keep their breaks as before. --- README.md | 6 ++++++ docs/wiki/Writing.md | 6 +++++- web/src/lib/markdown.ts | 19 ++++++++++++++++++- web/src/lib/util.ts | 24 ++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1f9edef..4dd9eba 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,12 @@ longer than 200 characters. The board has `:board`, `:card ` (`:card!` links the open document to it), `:due <when|->`, `:repeat <rule|->`, `:done` and `:list <name>` (`:list!` deletes one). +A single line break in a document is a line break, the way it was typed. A +paragraph an editor wrapped at a fixed width — an import, a save over the mount +from vim with `textwidth` — is the exception: every line but the last is long +and each break comes where the next word would not have fitted, so it is read +as running text. Two spaces or a `\` at the end of a line always break it. + On a touch device modal editing is off by default (`:set vim` turns it on) and the same actions are buttons: the `⋯` on a row — or a long press — opens what the keys do to it: rename, move, share, seal, delete. A move is picked up diff --git a/docs/wiki/Writing.md b/docs/wiki/Writing.md index 6522f54..cc3e2a5 100644 --- a/docs/wiki/Writing.md +++ b/docs/wiki/Writing.md @@ -46,7 +46,11 @@ Each save keeps the text it replaces, so nothing written is lost. See ## Markdown Documents are GitHub-flavoured markdown: headings, lists, task lists, tables, -quotes, code blocks and links. A single line break is kept as a line break. +quotes, code blocks and links. A single line break is kept as a line break, +the way it was typed, with one exception: a paragraph that an editor wrapped +at a fixed width, as imported notes and files saved over the SFTP mount often +are, is read as the running text it is. A break asked for outright, with two +spaces or a `\` at the end of the line, is always kept. Math is set by KaTeX: diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts index 30192c3..c8ad098 100644 --- a/web/src/lib/markdown.ts +++ b/web/src/lib/markdown.ts @@ -1,7 +1,7 @@ import DOMPurify from 'dompurify' import { marked, type TokenizerAndRendererExtension } from 'marked' import { mathEngine } from './math.svelte' -import { titleHref } from './util' +import { hardWrapped, titleHref } from './util' marked.setOptions({ gfm: true, breaks: true }) @@ -68,8 +68,25 @@ const wikilink: TokenizerAndRendererExtension = { }, } +/* + * A line break typed in the browser is meant: the diary is written a line at a + * time, so `breaks` is on. A paragraph an editor wrapped at its width is the + * exception, and its breaks are read as the spaces they stand for. A break + * asked for outright — two spaces or a `\` at the end of the line — stays. + */ +type Inline = { type: string; raw: string; tokens?: Inline[] } +function unwrap(tokens: Inline[]) { + for (const [at, token] of tokens.entries()) { + if (token.type === 'br' && /^ ?\n$/.test(token.raw)) tokens[at] = { type: 'text', raw: token.raw, text: ' ' } as Inline + else if (token.tokens) unwrap(token.tokens) + } +} + marked.use({ extensions: [mathBlock, mathInline, wikilink], + walkTokens(token) { + if (token.type === 'paragraph' && hardWrapped(token.raw)) unwrap(token.tokens as Inline[]) + }, renderer: { code(token) { return token.lang === 'math' ? tex(token.text, true) : false diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 197012e..eb0813f 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -322,3 +322,27 @@ export function neighbour(rects: Rect[], from: number, direction: Direction): nu }) return best } + +/** + * Whether a paragraph was wrapped by an editor at a fixed width rather than + * broken by hand: every line but the last is long, and each break comes + * where the next word would not have fitted on the line before. Markdown + * written elsewhere — an import, a save over the SFTP mount from vim with + * `textwidth`, a pasted README — is wrapped that way, and keeping its breaks + * puts one in the middle of every sentence. Lines broken by hand rarely pass: + * a short sentence followed by a short word would have fitted, and a line + * broken by hand mostly ends where a sentence does, which a wrapped one + * seldom does. + */ +export function hardWrapped(paragraph: string): boolean { + const lines = paragraph.trimEnd().split('\n').map((line) => line.trimEnd()) + if (lines.length < 2) return false + const body = lines.slice(0, -1) + const width = Math.max(...lines.map((line) => line.length)) + const fits = body.every((line, at) => { + const next = lines[at + 1].trimStart().split(/\s/, 1)[0] + return line.length >= 40 && line.length + 1 + next.length > width + }) + const sentences = body.filter((line) => /[.!?:;]$/.test(line)).length + return fits && sentences <= Math.floor((body.length - 1) / 3) +}