Skip to content

Commit f10a04e

Browse files
authored
Merge pull request #278 from Stvad/claude/infallible-chatterjee-e51521
fix(editor): stop block editor text shifting up on multi-line selection (iOS) + WebKit/iPad QA harness
2 parents a42c78e + 6ea3de2 commit f10a04e

2 files changed

Lines changed: 81 additions & 0 deletions

File tree

scripts/vite-qa.config.mjs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { defineConfig, mergeConfig } from 'vite'
2+
import { fileURLToPath } from 'node:url'
3+
import { existsSync } from 'node:fs'
4+
import path from 'node:path'
5+
import base from '../vite.config.ts'
6+
7+
// QA-only vite config for running a dev server INSIDE a git worktree.
8+
//
9+
// A worktree has no node_modules of its own — deps resolve up to the main
10+
// checkout — so vite's default `server.fs.allow` (rooted at the worktree)
11+
// rejects every parent-dependency request with 403 and the app hangs on
12+
// "Loading…".
13+
//
14+
// Do NOT fix this by disabling `fs.strict`: that makes Vite serve ANY local
15+
// file over `/@fs/...` (e.g. /etc/hosts, ~/.ssh, .env), which is a real hazard
16+
// because this server is reached over the Tailscale tunnel during iPad testing
17+
// (VITE_TUNNEL in vite.config.ts), not just localhost. Instead keep strict on
18+
// and explicitly allow the worktree plus the checkout that actually holds
19+
// node_modules — Vite's default deny list still blocks everything outside.
20+
//
21+
// Kept as plain .mjs (like webkit-qa.mjs) so `tsc -b` doesn't typecheck it
22+
// against the app's vite config types.
23+
//
24+
// Run from the worktree root, pointing node DIRECTLY at the installed Vite
25+
// binary. The worktree has no node_modules/vite of its own (so the bare
26+
// `node node_modules/vite/bin/vite.js` exits with MODULE_NOT_FOUND), and Vite's
27+
// package `exports` don't expose the bin to require.resolve — so derive the
28+
// checkout that holds it from git. This form works from a worktree or a normal
29+
// checkout regardless of nesting depth:
30+
//
31+
// node "$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")/node_modules/vite/bin/vite.js" \
32+
// --config scripts/vite-qa.config.mjs --port 5199 --strictPort
33+
const worktreeRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
34+
// Walk up to the nearest ancestor whose node_modules holds REAL packages (the
35+
// main checkout, when run from a worktree; the worktree itself for a normal
36+
// checkout). A worktree's own node_modules is cache-only (.vite/.cache) — node
37+
// resolves actual deps up the tree — so probe for `node_modules/vite` (the dev
38+
// server we're running) rather than a bare node_modules dir. depsRoot contains
39+
// the worktree, so allowing it covers both source and the resolved deps.
40+
let depsRoot = worktreeRoot
41+
while (
42+
!existsSync(path.join(depsRoot, 'node_modules', 'vite')) &&
43+
path.dirname(depsRoot) !== depsRoot
44+
) {
45+
depsRoot = path.dirname(depsRoot)
46+
}
47+
// Fail CLOSED. If no ancestor has node_modules/vite, the loop above exits with
48+
// depsRoot at the filesystem root — allow-listing `/` would re-open the very
49+
// arbitrary-file-read hole this config exists to prevent. Refuse to start.
50+
if (!existsSync(path.join(depsRoot, 'node_modules', 'vite'))) {
51+
throw new Error(
52+
`vite-qa.config: no ancestor of ${worktreeRoot} has node_modules/vite — ` +
53+
`refusing to start rather than allow-list the filesystem root. ` +
54+
`Run from a worktree nested under an installed checkout.`,
55+
)
56+
}
57+
58+
export default defineConfig(async (env) => {
59+
const resolved = typeof base === 'function' ? await base(env) : base
60+
return mergeConfig(resolved, { server: { fs: { allow: [worktreeRoot, depsRoot] } } })
61+
})

src/utils/codemirror.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,18 @@ export const createMinimalMarkdownConfig = (
153153
fontSize: "inherit",
154154
color: "inherit",
155155
lineHeight: "inherit",
156+
// Each block is its own auto-height editor that must never scroll
157+
// internally — the surrounding page is the only scroll container.
158+
// CodeMirror's base theme makes `.cm-scroller` `overflow: auto`, and
159+
// the selection layer reports a few px of phantom scrollHeight beyond
160+
// the content (the touch selection handles past the last line). On iOS
161+
// WebKit, dragging a multi-line selection through the last line fires a
162+
// native scroll-to-selection that scrolls the editor into that phantom
163+
// gap and leaves `scrollTop` stuck > 0 — the whole block's text appears
164+
// to shift up by those few px. `clip` makes the scroller a non-scroll
165+
// container (so scroll-to-selection can't move it) and clips the
166+
// overhang. Verified on-device (iPad, iOS 26): the shift is gone.
167+
overflow: "clip",
156168
},
157169
'.cm-editor': {outline: 'none'},
158170
'.cm-focused': {outline: 'none'},
@@ -188,7 +200,15 @@ export const createTypeScriptConfig = (): Extension[] => [
188200
'&': {background: 'transparent', color: 'inherit'},
189201
'.cm-editor': {border: '1px solid hsl(var(--border))', borderRadius: '4px'},
190202
'.cm-content': {padding: '8px'},
203+
// Wrapping (below) means no horizontal scroll, so the scroller can be a
204+
// non-scroll container — same as the markdown block editor. This also
205+
// avoids the iOS multi-line-selection scroll-shift (see the `.cm-scroller`
206+
// note in createMinimalMarkdownConfig).
207+
'.cm-scroller': {overflow: 'clip'},
191208
}),
209+
// Wrap long code lines instead of scrolling horizontally — in a narrow
210+
// outliner column, off-screen horizontal scroll hides content.
211+
EditorView.lineWrapping,
192212
]
193213

194214
/**

0 commit comments

Comments
 (0)