spaces: finish the writing surface — arrow navigation, a spoken Tab refusal, block controls - #444
Open
nyblnet wants to merge 1 commit into
Open
spaces: finish the writing surface — arrow navigation, a spoken Tab refusal, block controls#444nyblnet wants to merge 1 commit into
nyblnet wants to merge 1 commit into
Conversation
…efusal, block controls Three things a reader hits in the first minute, all of them absent rather than broken, and they are one piece of work because they are the same surface. ARROW NAVIGATION, which had never existed. Every block is its own contenteditable host — that is what keeps a Selection block-scoped so a split or a merge can never re-mint an id — and the price was that the browser's caret movement stopped at a block boundary. The only ArrowUp/ArrowDown handlers in the editor were inside the slash menu. The rule is within the block first: down from the first line of a wrapped paragraph means its second line, so the question is whether the caret is on the EDGE VISUAL LINE of its host, measured from the caret's own rectangle against the host's line boxes. Two things had to be got right and were only found in a browser: getClientRects() returns a rect per inline BOX, not per line (the starter space's first paragraph is two lines and five rects), so the boxes are merged per line before either the edge test or the goal-column clamp reads them; and a range collapsed at an element boundary reports no rects and an all-zero bounding box, which had the caret at the end of a wrapped paragraph deciding it was still on line one and never leaving. The goal column is kept across consecutive vertical steps and dropped by a pointer. Left at the start of a block goes to the end of the one before, right at the end to the start of the next. Containers come free (they render in document order); a table is stepped by its grid, and entering one lands in the column the goal column is over. The table's own Tab and Enter are untouched. TAB REFUSES, OUT LOUD. Nesting is one field — parent, pointing at a preceding sibling — so the first block at its level has nothing to nest under, and indent() fell off its backwards walk and returned in silence. The refusal is right; the silence was the bug. It now says the rule in the status line, nudges the block, and disables the control with the reason as its label. The alternative was to store an indent level, which is a permanent format addition and a second way to express nesting that the renderer, the markdown export, the outline, the graph and the CRDT would each have to reconcile forever. Rejected on that; the argument is in spaces/src/nesting.ts and docs/DECISIONS.md. No format change. Shift+Tab still outdents and now explains itself too, and Tab still commits a showing calc answer first. BLOCK CONTROLS. Bullet, numbered, to-do, quote, headings, indent and outdent had no control anywhere — the formatting bar is inline-only and appears on a selection, which is the wrong trigger for making a list. They are a row at the top of the block menu: per-block by construction, a bottom sheet on a phone, already the home of every other whole-block action. Cmd+/ is its keyboard route, since the gutter is hover-revealed and Tab inside a block is indent. Indent and outdent existed on no surface at all before this. New logic is in new files (caret.ts, nesting.ts, blockbar.ts); the edits to editor.ts, formatbar.ts and styles.css are kept small and local. scripts/test-spaces-caret.ts asserts the pure halves — 45 checks, every Tab case a happy-path test skips, eight sabotages each red on the intended assertion. Caret movement itself was verified in a real browser over http, with a build marker on every probe.
Build size
Updated: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Twelve advanced features were built on top of a writing surface that was never
finished. The maintainer tested the app and hit three things in a row; all three
are absent rather than broken, and they are one piece of work because they are
the same surface. Branched off
origin/maindeliberately — this depends on noneof the open branches.
1. Arrow navigation, which had never existed
Every block is its own
contenteditablehost — that is what keeps a Selectionblock-scoped, so a split or a merge can never re-mint an id, and ids are what
links, backlinks and collaboration key on. The price was that the browser's own
caret movement stops dead at a block boundary. The only
ArrowUp/ArrowDownhandlers in the editor were inside the slash menu, for picking a menu item.
Within the block first. ↓ from the first line of a wrapped paragraph means
its second line, not the next block, so the question asked is never "which block
is this" but "is the caret on the edge VISUAL line of its host" — answered from
the caret's own rectangle against the host's line boxes. Counting characters
cannot answer it: where a line wraps depends on the font, the width, the
language and the marks.
Two things had to be got right and neither could be found by reading code:
Range.getClientRects()returns a rect per inline BOX, not per line. Thestarter space's first paragraph is two visual lines and five rects, because
of the
<strong>in the middle of the first one. Unmerged, the edge test readthe first fragment as "the first line", and the goal-column clamp could not
reach past it. Boxes are merged per line now.
bounding box. Measured at the end of that same paragraph: the caret at the end
of a two-line paragraph decided it was still on line one and never left the
block — the exact bug this branch exists to fix, reintroduced by a fallback.
The box is now measured by widening one unit and reading the edge.
A goal column, kept across consecutive vertical steps and dropped by a
pointer — a click used to leave the previous run's column live, measured by
entering a table three times at three columns and landing in the first one every
time. ← at the start of a block goes to the end of the one before, → at the end
to the start of the next. Callouts, toggles and canvas cards come free (they
render in document order). A table is stepped by its grid, because cells are
row-major in the DOM and column-major on the screen; entering one from above or
below lands in the column the goal column is over. The table's own ⇥ and ⏎ are
untouched.
2. Tab refuses on the first block at its level — out loud
A space expresses nesting with one field:
parent, pointing at a precedingsibling. Everything downstream reads it as structural — the renderer's indent,
the markdown export's levels, the outline, the graph,
mergeBackre-homingorphans, the CRDT's per-node diff. So the first block at its level has nothing
to nest under, and
indent()walked backwards, found none, and returned. Thatwas correct and completely silent, which reads as a broken app rather than a
gesture that does not apply. The silence was the bug.
It now says the rule in the status line (the app's existing transient-message
mechanism — a second one is one too many), nudges the block so the message is
connected to the keystroke, and shows the reason on the indent control before
the key is pressed.
The alternative — storing an indent LEVEL, as Google Docs does — was rejected,
and NOT on taste. It is a permanent format addition and a second way to express
nesting, which every reader above would have to reconcile forever: what a
level: 2block with aparentmeans, which wins, what the export emits for anorphan at level 2, what two replicas do when one sets
parentand the other setslevel. A silent Tab costs an afternoon; a duplicate nesting model costs everyfuture version of the reader. Notion, Workflowy, Bear and Logseq refuse the same
case for the same reason. No format change. ⇧Tab still outdents (and now
explains itself when there is nothing to outdent from), and Tab still commits a
showing calc answer before it considers indenting.
3. Block controls, which existed on no surface at all
The formatting bar is inline-only and appears on a SELECTION — the wrong trigger
for "make this a bullet", which you want with a caret and nothing selected. So
the block's own format is a row at the top of the block menu: per-block by
construction, already a bottom sheet on a phone, and already the home of every
other whole-block action. Text, H1–H3, bulleted, numbered, to-do, quote, outdent,
indent — reusing
setTypeandindent, no second path. Indent and outdent hadno control at any width before this; ⇥ and ⇧⇥ were their only gesture, and a
phone keyboard has neither. ⌘/ opens the menu from the keyboard, because the
gutter is hover-revealed and ⇥ inside a block is indent. The disabled control
keeps
aria-disabledrather thandisabled, so a keyboard can still reach thelabel that explains why it is off.
Verification
scripts/test-spaces-caret.tsis new and registered in both the runner and CI.Eight sabotages, each red on exactly the intended assertions — including
restoring the shipped bug (fall off the loop with no reason), stepping a table
along its DOM row, comparing line tops for equality, and measuring column
distance to centres instead of to boxes.
The Tab assertions are deliberately the cases a happy path skips: the first item,
the only block, the first child of a container, a block whose only predecessor is
at another level, a block not on the page. A Tab test written against the SECOND
list item passes against the broken code exactly as happily as against the fixed
code — which is how this shipped.
Caret movement cannot be asserted in node, so it was driven in a real browser
over
http://127.0.0.1, a unique filename per build and a computed-style buildmarker checked on every probe. Verified case by case: within a wrapped paragraph
(real keys), out of its last line, goal column preserved through a short line,
first/only/empty/last block, nested block, block inside a callout, into and out
of a table by column, table ⇥ still walking the grid, the slash menu, ⌘K,
markdown autoformat (
-,##), and the calc ghost committing on Tab.Byte delta, built side by side against
origin/main: 277,769 → 282,881 =+5,112 bytes (+1.84%).
Could not verify: the real phone bottom sheet — the browser pane pins
innerWidthat 1600 and window resizes do not reach the viewport, so the sheetpath was checked by measuring the row's wrap at 288px and 358px containers with
the coarse-pointer 44px targets forced (two rows, no horizontal overflow) rather
than by producing a real 320px viewport.