Rendered from specs/F28-scroll-sync.md — edit it there, not here.
F28 — Preview Scroll Sync
Overview
While a schema's preview panel (F01) is open, the editor and the preview panel keep each other in view as the author browses a long schema: scrolling (or clicking a line in) the editor moves the preview to the matching position, and scrolling the preview moves the editor's viewport to match — a single jsonschema.preview.syncScroll setting controls both directions together. This is separate from F01-FR-17, which restores the preview's own last scroll position after a save-triggered refresh — this spec covers continuous two-way sync, independent of any refresh.
Because rendered output (from json-schema-for-humans or the built-in fallback, F01-FR-21) has no per-line mapping back to the source document, the baseline sync is proportional rather than exact: the fraction scrolled on one side is applied as the same fraction of the other side's scrollable extent (the preview's content height on the editor→preview leg; the document's line count on the preview→editor leg).
On top of that baseline, the extension attempts section-accurate sync in both directions using the same anchor-id convention: json-schema-for-humans's default flat template (F01-FR-11) assigns each inlined property an id derived from its schema path (property names joined by _, with a literal items segment for array items — e.g. a schema's properties.address.properties.city renders as id="address_city"; an array's items schema keeps the literal segment, e.g. id="items_items"). Editor→preview, the extension computes that same kind of path for the schema source position being synced and scrolls straight to the matching element when one exists (F28-FR-09/10). Preview→editor, the webview reports the id of the element nearest the top of its viewport and the extension resolves it back to a source position (F28-FR-13) — the same convention, run in reverse.
Both legs fall back to the proportional position when the anchor convention doesn't apply, which happens for:
- a position reached only through
$ref,oneOf/anyOf/allOf, or$defs/definitionsindirection (dereferenced or restructured by the renderer in ways this spec does not attempt to reverse); - a non-
flatrender template, or a config that disables ids; - preview→editor only: a property whose name itself contains
_— the reverse leg starts from an already-_-joined id string with no record of where the original segment boundaries were, so it naively splits on every_and may misread the path for such a name (the editor→preview leg has no such gap, since it builds the id from the real segments instead of parsing one back apart).
None of these cases are errors: they just fall back to the proportional position, in either direction.
User Stories
- As a schema author, I want the preview to follow along as I scroll through a large schema file, so I don't have to scroll the preview separately to see the section I'm editing.
- As a schema author browsing the rendered documentation first, I want scrolling the preview to bring the editor to the matching source location, so either view can lead.
- As a user who finds synced scrolling distracting or inaccurate for their schema, I want to turn it off (in both directions at once) and have each view stay exactly where I left it.
Functional Requirements
Setting
- F28-FR-01 A
jsonschema.preview.syncScrollboolean setting MUST control whether the preview panel's scroll position follows the schema editor's scroll position and whether the editor's viewport follows the preview panel's scroll position — one setting gates both directions. It MUST default totrue.
Sync Behaviour (Editor → Preview)
- F28-FR-02 When
jsonschema.preview.syncScrollistrueand the active editor's document is a JSON Schema file (F01-FR-02) with a preview panel currently open for it, a change in the editor's visible range MUST scroll that preview panel to the proportionally equivalent position: the topmost visible line divided by the document's total line count (clamped to[0, 1]), applied client-side against the preview's own scrollable height (document.body.scrollHeightminus the viewport height). - F28-FR-03 A document with fewer than 2 lines MUST be treated as fraction
0rather than dividing by zero. - F28-FR-04 When there is no open preview panel for the scrolled document, or the scrolled document is not a JSON Schema file, the extension MUST NOT send a scroll message to any panel.
Disabling
- F28-FR-05 When
jsonschema.preview.syncScrollisfalse, neither direction MUST send any sync action: editor scrolling MUST NOT send a scroll-position message to the preview panel, and preview scrolling MUST NOT reveal any range in the editor. The preview panel's scroll position is then only affected by the F01-FR-17 restore-on-refresh behaviour.
Directionality and Cost
- F28-FR-06 Sync MUST be bidirectional: an editor scroll (F28-FR-02) or selection change (F28-FR-08) moves the preview, and a preview scroll (F28-FR-12) moves the editor's viewport — both gated by the same setting (F28-FR-01/05).
- F28-FR-07 Neither direction MUST trigger a preview re-render or re-invoke the renderer (F01/F01-FR-21). Editor→preview MUST be implemented purely as a
webview.postMessagecarrying the target fraction (and any resolved anchor-id candidates, F28-FR-09) consumed client-side in the already-rendered panel; preview→editor MUST be implemented purely as a viewport reveal (F28-FR-15) — neither leg reloads or regenerates content.
Section-Accurate Sync (Anchor Ids) — Editor → Preview
- F28-FR-08 In addition to a change in the editor's visible range (F28-FR-02), a change in the editor's cursor/selection position MUST also trigger a sync attempt under the same enablement and applicability rules (F28-FR-01/04/05) — this is what makes clicking a line (without necessarily scrolling) or jumping to a Ctrl+F match in the editor move the preview.
- F28-FR-09 For a reference position (the topmost visible line for a visible-range-triggered sync, or the caret position for a selection-triggered sync) in a JSON, JSONC, or YAML schema document, the extension MUST compute zero or more anchor-id candidate strings by resolving the chain of enclosing
properties/patternPropertieskeys and literalitemssteps that contain that position in the parsed source, joining each candidate's segments with_, ordered from the deepest enclosing candidate to progressively shorter prefixes ending at the shallowest (top-level) enclosing property. A position not enclosed by any such step (e.g. at the document root) MUST yield an empty candidate list. - F28-FR-10 The webview script MUST try each anchor-id candidate (when any were computed, F28-FR-09) via
document.getElementById, in the supplied order, and on the first match scroll that element into view instead of applying the proportional fraction. When the candidate list is empty, or none of its ids match an element in the current page, the script MUST fall back to the proportional scroll (F28-FR-02) unchanged. - F28-FR-11 The built-in fallback renderer (F01-FR-21) MUST emit an
idattribute on each rendered property row, built from the same underscore-joined segment convention as F28-FR-09 restricted to the segments it can produce (property name chains through nestedproperties; it has no separate array-item section, so it never emits anitemssegment), so that section-accurate sync also works against its own output.
Sync Behaviour (Preview → Editor)
- F28-FR-12 When
jsonschema.preview.syncScrollistrue, scrolling the preview panel MUST, once the scroll settles, attempt to move the viewport of a visible editor showing the same schema document to the matching position: an anchor-id lookup first (F28-FR-13), falling back to the proportional position (topmost-visible-preview-element fraction × the document's line count, clamped to[0, document.lineCount - 1], paralleling F28-FR-02/03) when the anchor doesn't resolve. - F28-FR-13 The extension MUST resolve a reported anchor id back to a source position by splitting it on
_into candidate segments and reversing the F28-FR-09 convention (a literalitemssegment maps back to theitemskeyword; every other segment is looked up as apropertieschild —patternPropertiesis not attempted in reverse, since its name can't be distinguished from an ordinary property's by the id alone). An id that doesn't resolve to a real location in the source (including the_-ambiguity case in the Overview) MUST fall back to the proportional position (F28-FR-12) rather than erroring or leaving the sync attempt incomplete. - F28-FR-14 When no visible editor shows the scrolled document, or that document is not a JSON Schema file, the extension MUST NOT reveal any range — this is the preview→editor analogue of F28-FR-04.
- F28-FR-15 Revealing a position in the editor (F28-FR-12) MUST only change the editor's visible range (viewport) — it MUST NOT change the editor's selection/cursor position, and MUST NOT make any edit to the document.
Echo Suppression
- F28-FR-16 Because each direction's sync action itself changes state the other direction listens to (a preview scroll reveals a range, which is itself a visible-range change; an editor-driven preview scroll fires the preview's own scroll event), the extension MUST suppress a sync action that is the likely echo of the opposite direction's own immediately preceding sync action for the same document — tracked per document, with a short cooldown window — so the two directions do not oscillate. Two consecutive sync actions in the same direction (e.g. continued editor scrolling) MUST NOT be suppressed by this mechanism; only an opposite-direction action within the cooldown window is treated as an echo.
Non-Functional Requirements
- F28-NFR-01 The event listeners driving both directions (F28-FR-02, F28-FR-08, F28-FR-12) MUST be the only mechanism driving sync — no additional timers or polling — so the feature costs nothing while no preview panel is open.
- F28-NFR-02 Anchor-id computation (F28-FR-09) parses the schema source on every triggering event; this MUST be debounced (coalescing rapid successive triggers, e.g. continuous mouse-wheel scrolling) so it does not re-parse the document on every intermediate event.
- F28-NFR-03 The echo-suppression cooldown (F28-FR-16) trades off a narrow false positive — two genuinely independent, opposite-direction scroll actions within the same short window are treated as one echoed action and only the first is applied — for eliminating visible oscillation between the editor and the preview; the window MUST be short enough that this is imperceptible as "stuck" during normal use.
Acceptance Criteria
- With a schema's preview panel open and
jsonschema.preview.syncScrollat its default, scrolling to the middle of the schema file in the editor moves the preview panel's scroll position to approximately the middle of the rendered output. - Setting
jsonschema.preview.syncScrolltofalsedisables both directions: scrolling the editor leaves the preview's scroll position unchanged, and scrolling the preview leaves the editor's viewport unchanged. - Scrolling the preview panel to a property's rendered section moves a visible editor showing that schema to the matching source line, without changing the editor's selection or editing the document.
- Scrolling an editor for a file that is not a JSON Schema file, or that has no open preview panel, sends no message to any panel; scrolling a preview panel with no visible editor for its document reveals nothing.
- With the built-in renderer (F01-FR-21) active, clicking a line inside a nested property's subschema (e.g.
properties.address.properties.city) in the editor — without scrolling — scrolls the preview panel to that property's row, and scrolling the preview back to that row moves the editor back to the matching line. - Clicking a line reached only through a
$ref(e.g. inside$defs) does not throw, does not scroll to a wrong section, and falls back to the proportional position; the same holds in reverse for a preview element with no resolvable anchor. - Rapidly alternating edits that scroll the editor and then the preview do not oscillate or visibly fight each other — the sync settles rather than ping-ponging between the two views.
Requirement traceability
Status of each requirement in specs/traceability.json. Test coverage is auto-discovered from [ID] tags in test titles and is not listed here.
| Requirement | Status | Implementation | Note |
|---|---|---|---|
F28-FR-01 | implemented | package.json, src/settings.ts | jsonschema.preview.syncScroll setting, default true; getSyncScrollEnabled() unit-tested |
F28-FR-02 | implemented | src/extension.ts, src/PreviewWebPanel.ts | onDidChangeTextEditorVisibleRanges wired to syncPreviewScroll(); unit-tested |
F28-FR-03 | implemented | src/PreviewWebPanel.ts | syncPreviewScroll() zero-division guard for lineCount < 2; unit-tested |
F28-FR-04 | implemented | src/PreviewWebPanel.ts | syncPreviewScroll() no-ops without an open panel or a non-schema document; unit-tested |
F28-FR-05 | implemented | src/settings.ts, src/PreviewWebPanel.ts | getSyncScrollEnabled() gates syncPreviewScroll(); unit-tested |
F28-FR-06 | implemented | src/extension.ts, src/PreviewWebPanel.ts | redefined for bidirectionality: editor listeners (extension.ts) drive syncPreviewScroll(), preview scrollend drives syncEditorFromPreview(), both gated by the same setting; unit-tested |
F28-FR-07 | implemented | src/PreviewWebPanel.ts | syncPreviewScroll() only calls webview.postMessage, never re-renders; unit-tested |
F28-FR-08 | implemented | src/extension.ts | onDidChangeTextEditorSelection wired alongside the visible-ranges listener; unit-tested |
F28-FR-09 | implemented | src/schemaPointer.ts, src/PreviewWebPanel.ts | computeAnchorCandidates() (JSON/JSONC + YAML) wired into syncPreviewScroll(); unit-tested |
F28-FR-10 | manual | src/PreviewWebPanel.ts | injected webview script's anchor-first-then-fraction-fallback logic; VS Code webview-bound, verified by manual/E2E testing |
F28-FR-11 | implemented | src/fallbackRenderer.ts | per-row id attribute (escaped) matching the anchor convention; unit-tested |
F28-FR-12 | implemented | src/PreviewWebPanel.ts | syncEditorFromPreview(), triggered from the webview's scrollend 'position' message; unit-tested |
F28-FR-13 | implemented | src/schemaPointer.ts, src/PreviewWebPanel.ts | locateAnchorSegments() reverses computeAnchorCandidates(); unit-tested including the round-trip and unresolvable-path fallback |
F28-FR-14 | implemented | src/PreviewWebPanel.ts | syncEditorFromPreview() no-ops without a visible editor or a non-schema document; unit-tested |
F28-FR-15 | implemented | src/PreviewWebPanel.ts | syncEditorFromPreview() only calls editor.revealRange(), never touches selection or edits the document; unit-tested |
F28-FR-16 | implemented | src/PreviewWebPanel.ts | shared per-document direction-aware cooldown (lastSyncAction) in syncPreviewScroll()/syncEditorFromPreview(); unit-tested including same-direction non-suppression |
F28-NFR-01 | implemented | src/extension.ts | the two event-driven listeners (visible-range and selection change) are the only sync trigger, no polling/timers |
F28-NFR-02 | implemented | src/PreviewWebPanel.ts | scheduleSyncPreviewScroll() debounce; unit-tested with fake timers |
F28-NFR-03 | implemented | src/PreviewWebPanel.ts | SYNC_COOLDOWN_MS (250ms) is short relative to normal interaction pacing; unit-tested for correct suppress/resume timing |
Documented in
Documentation sections tagged spec:F28 (or one of its requirement ids) — 218 of ~1068 expected words (20%). The expectation comes from this spec's evaluated complexity of 26.7 (19 documentable requirements — 16 FR, 3 NFR — weighted by kind and by each definition's length).