Skip to content

Remember scroll position per person in side-by-side group study viewer (108 LOC)#103

Closed
hendriebeats wants to merge 16 commits into
masterfrom
Remember-Other-Study-Scroll-Positions
Closed

Remember scroll position per person in side-by-side group study viewer (108 LOC)#103
hendriebeats wants to merge 16 commits into
masterfrom
Remember-Other-Study-Scroll-Positions

Conversation

@hendriebeats

@hendriebeats hendriebeats commented Feb 17, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR does two things:

  1. Remembers scroll position in the split-screen group study viewer, so switching between member documents and returning to one you've already read doesn't lose your place.
  2. Refactors the group study modal UI — replaces the separate "Invite New Members" page with an inline invite form, redesigns the members/invites list with status badges, and adds a close button and descriptive copy to the create-study dialog.

frontend/src/GroupStudy.ts — scroll position memory

makeScrollMemory

A small closure that wraps localStorage reads/writes for scroll position, scoped to a specific container element. Keys are namespaced as split-scroll:{pageDocId}:{docId} so positions don't collide across different study pages.

  • save(docId) — writes container.scrollTop to localStorage.
  • restore(docId) — reads the saved value and applies it inside a requestAnimationFrame, which is necessary because the editor isn't fully painted yet when DocListenStart fires.
  • watch(getCurrentDocId) — attaches a debounced scroll listener (200 ms) so saves don't hammer localStorage on every pixel of scroll.

loadEditor

  • Sends StopListenToDoc for the old document and saves its scroll position before switching.
  • Guards if (!docId) return to avoid sending an empty doc ID if the select has no selected option.
  • Sends ListenToDoc for the newly selected document.

DocListenStart handler

Calls scroll?.restore(currentDocId) after mounting the editor so the panel jumps to the remembered position.


backend/lib/Api/Htmx/GroupStudy.hs — group study modal refactor

createStudyGroupHTML

  • Added a close (×) icon button and a descriptive paragraph explaining what a group study is.
  • Labels now use field-label / field-desc classes. The people label was renamed to "Invite people (optional)" with a note that invites can be sent later.

shareHTML

  • Status is now shown as a colour-coded badge ("Invited" / "Rejected" / "Expired") instead of a plain red text suffix.
  • Remove button changed from a trash-icon image (red trash) to a text "Remove" button (remove-btn) for clarity.
  • Layout uses shared person-row / person-info / person-actions grid classes.

memberHTML

  • Shows the member's email address below their name.
  • Shows "Owner" or "Member" role label when the viewer is not the owner (so non-owners can see who runs the group).
  • The (you) indicator is now a styled you-label span.

groupStudyHTML

  • Close button added.
  • Renamed section: name-edit input is now under a field-label, with a read-only fallback for non-owners.
  • "Invite New Members" button replaced with an inline email invite form (email input + role dropdown + submit button). On submit, htmx replaces #groupStudyInner with the updated panel — no separate page needed.
  • Members and pending invites are merged into a single "People with access" section.

postInvite

  • Removed the old html =<< getInviteNewMemberHTML response (that rendered the now-deleted separate invite page). The endpoint now ends with getGroupStudy' user groupId, returning the full refreshed panel, which htmx swaps into #groupStudyInner.

backend/lib/Entity/User.hs

Added email to the GetUser projection so member rows can display email addresses in the group study modal.


backend/static/scripts/studyGroup.js

After htmx settles inside the open modal, focuses the email input in the inline invite form and attaches an Enter-key handler (guarded with _inviteHandlerAttached to avoid duplicate listeners across htmx swaps).


backend/static/styles/component/groupstudy.css

Full visual refresh of the modal to match the redesigned HTML:

  • person-row grid, person-info, person-actions, person-name, person-email, you-label, person-role — shared layout primitives for both member and invite rows.
  • badge / badge-pending / badge-error — pill badges for invite status.
  • ghost-blue / remove-btn — low-visual-weight action buttons.
  • groupStudy-invite-row — three-column grid (email | role | button) for the inline invite form.
  • p-select part styles scoped to the modal.

frontend/src/Editor/StudyBlockArrowPlugin.ts + Editor.tsx

Arrow-key navigation plugin that fixes cursor movement at study block cell boundaries in the two-column table layout. ProseMirror's default navigation follows document order, which causes the cursor to jump to the wrong column. This plugin intercepts ArrowUp/ArrowDown at cell edges and uses coordinate probing (coordsAtPos / posAtCoords) to land the cursor on the correct visual line in the adjacent cell.

Also fixed verseRefWidget to use onmousedown + getPos() instead of a stale closure over position, and removed the erroneous contentEditable = "true" from verse ref spans.


Minor fixes

  • study.html: fixed typo "grad" → "drag".
  • SidebarSections.ts: removed two lines of commented-out dead code.
  • StudyBlockEditor.ts: removed an unused currentIndex variable read inside the drag mouseup handler.

@hendriebeats hendriebeats force-pushed the Remember-Other-Study-Scroll-Positions branch from 92d9ad1 to dc86b0d Compare February 19, 2026 08:35
hendriebeats and others added 2 commits February 19, 2026 03:36
…table cells

ProseMirror's default up/down navigation follows document order, causing
the cursor to jump to the wrong column in the two-column study block layout.
The plugin intercepts arrow keys at cell boundaries and navigates visually,
preserving horizontal cursor position via coordinate probing. Also fixes
a boundary-position bug where posAtCoords could return a position between
block siblings, causing TextSelection to throw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Persists each viewed document's scroll position in localStorage so
switching between group members' documents restores where you left off.
Also fixes a bug where StopListenToDoc was sent with the new doc ID
instead of the old one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@hendriebeats hendriebeats force-pushed the Remember-Other-Study-Scroll-Positions branch from dc86b0d to 0c2fe6d Compare February 19, 2026 08:37
@hendriebeats hendriebeats changed the base branch from master to Fix-Table-Arrow-Key-Movement February 19, 2026 08:37
@hendriebeats hendriebeats changed the title Remember scroll position per person in side-by-side viewer Remember scroll position per person in side-by-side group study viewer Feb 21, 2026
@hendriebeats hendriebeats changed the base branch from Fix-Table-Arrow-Key-Movement to master February 21, 2026 04:50
hendriebeats and others added 5 commits February 20, 2026 23:52
- currentDocId typed as T.DocId | undefined for clarity
- editor?.dispatchSteps() null guard against early DocUpdated events
- navigateToTarget returns true (consume key) when position unchanged
- Remove unnecessary TextSelection cast in navigateOutOfStudyBlocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix boundary comparisons in resolveLinePos to use >= / <= so cursor
  positions exactly at block edges are not excluded
- Pass navigation direction directly to resolveLinePos instead of
  hardcoding ±1 so up/down arrows behave symmetrically
- Return false (not true) when navigation produces no cursor movement,
  allowing the event to fall through to the browser
- Fix coordsAtPos crash when section header starts on a block boundary
  by snapping to the first inline position before querying coords
- Guard scroll restore against a null currentDocId
- Tighten parseInt fallback: treat non-numeric stored values as NaN
  rather than silently scrolling to 0
- Remove stale console.log
- Drop unused EditorStateConfig and Selection imports from Editor.tsx
Use nullish coalescing instead of a ternary when parsing the saved
scroll value, keeping the same NaN-fallback behaviour.
If the select element has no selected option, groupStudySelector.value
returns an empty string. Without this guard, an invalid doc ID would
be sent to the server via ListenToDoc on the first loadEditor call.
@hendriebeats hendriebeats changed the title Remember scroll position per person in side-by-side group study viewer Remember scroll position per person in side-by-side group study viewer (108 LOC) Feb 22, 2026
hendriebeats and others added 7 commits February 22, 2026 12:52
To enable a multi-tab side panel, the WebSocket layer needed to track
multiple side documents per connection instead of a single optional one.

- Add DocUpdatedMsg wrapping docId + update so the frontend can route
  updates to the correct tab editor
- Replace SocketState.sideDoc (Maybe DocId) with sideDocs ([DocId])
- Handle InStopListenToDoc properly via closeSideDoc (previously fell
  through to the catch-all logger)
- Disconnect cleanup now iterates all sideDocs and unsubscribes each
- Frontend DocUpdatedEvent.contents now carries { docId, update }
  instead of a bare update value
- Embed groupStudy JSON and pageDocId into study.html via <script> tags
  for XSS-safe access from TypeScript
The old side panel only supported one member's document at a time via
a dropdown selector. This lays the HTML/CSS foundation for the new
multi-tab experience.

HTML (study.html):
- Replace #splitPicker / p-select / #sideBySideEditor with a tab bar
  (#tabBar), a "+" add button, a close button, and an empty
  #splitEditorArea where editors are injected by JS
- Move #memberPickerPopover outside #splitside so position:fixed works
- Remove the inline split() function (wired via JS in GroupStudy.ts)

CSS (study.css):
- Rewrite #splitside as a flex column with a sticky header strip and
  absolutely-positioned tab editor panes (.splitTabEditor)
- Add .splitTab styles (active state, close button, label ellipsis)
- Add #memberPickerPopover with fixed positioning, z-index 200
- Add mobile override: at ≤768px the panel becomes a fixed full-screen
  overlay (z-index 300) instead of a grid column
Replaces the single-dropdown side panel with a Chrome-style tab system
where users can open any group member's document in a parallel tab.

Key behaviours:
- Toolbar button opens a member picker popover (position:fixed,
  anchored to the button) listing all group members
- Each row shows a checkmark if already open, greyed if no doc yet
- openTab() creates a .splitTabEditor pane and a .splitTab button,
  sends ListenToDoc, and activates the tab immediately
- Editors are created lazily on DocListenStart via a FIFO listenQueue
  so concurrent opens don't cross-wire responses
- closeTab() sends StopListenToDoc, destroys the editor, and activates
  an adjacent tab; closing the last tab collapses the panel
- closeSidePanel() unsubscribes all tabs in one pass
- Scroll position per tab persisted to localStorage under
  split-scroll:{pageDocId}:{docId}; saved on tab switch, restored on
  activate and after editor mounts
- 4-tier name disambiguation avoids duplicate tab labels for members
  sharing a first name
- WS reconnect re-queues ListenToDoc for all currently open tabs
- groupStudy data read from #groupStudyData <script> tag (XSS-safe);
  pageDocId from window.pageDocId injected by the template
- Use unsafeRawHtml to embed pre-encoded JSON in the groupStudyData
  script tag. The Ginger | safe filter is broken in 0.10.5.2 (its
  asText path returns empty for Text GVals), so we bypass it by
  setting asHtml directly. </script> sequences are replaced with
  <\/script> to prevent HTML-parser breakout from the script tag.
- Remove the current user from the "No document yet" section of the
  member picker — their doc is intentionally excluded from allDocs,
  so they were incorrectly appearing as having no document.
- Replace empty checkmark column with avatar circles (two initials,
  matching the profile button style at 28×28px)
- Show a "Viewing" pill on rows whose tab is already open
- Toolbar splitscreen picker stays open for multi-select: click to
  open or close individual tabs without dismissing the dropdown
- Tab-bar plus button filters out already-open members and closes
  after a single selection (unchanged single-select behaviour)
- Fix dismiss handler ignoring clicks on DOM nodes removed during
  re-render (prevented spurious picker close on row click)
Tabs can now be reordered by dragging within the tab bar. The
implementation uses pointer capture for reliable tracking and the FLIP
animation technique to smoothly animate displaced siblings into their
new positions. A .dragging class lifts the dragged tab with a shadow;
dropping snaps it back with a short ease transition.

Also fixes the splitscreen button to not pre-select the current user
in the member picker (was passing `true` for self-exclusion).

CSS: darken tab bar and inactive tab backgrounds slightly for better
contrast; tighten the add-tab button to a fixed 34×34 square.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@hendriebeats hendriebeats deleted the Remember-Other-Study-Scroll-Positions branch February 22, 2026 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant