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 `, `:repeat `, `:done` and `:list ` (`: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)
+}