F1: the availability publish-effect duplicated the #218 editability gate
(editable && inEditMode && !showStatic) inline — a copy that could silently
diverge from the tested isBodyEditable — and the reason computation (the core of
#309) had no tests. Extract computeDictationAvailability into editor-sync-state.ts
REUSING isBodyEditable; the effect is now a one-line call. Unit tests cover the
branches (synced→null; pre-sync disconnected→offline / else connecting;
!editable/!edit→read-only).
F2: DictationGroup gated the mic on the non-reactive editor.isEditable while the
PR already publishes the reactive dictationAvailability.isEditable (same signals)
— so gate and reason came from different sources and the mic could stick. Gate on
dictationAvailability.isEditable: one reactive source of truth for both.
vitest (editor-sync-state + dictation): 37 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dictation mic could be grey/disabled while silently showing "Start
dictation", and Mantine's native `disabled` set pointer-events:none so the
Tooltip never fired at all — the UI knew the cause but told the user nothing.
Runtime error strings were also duplicated verbatim across the two dictation
hooks.
- New dictation-status.ts: the single source of truth. A DictationUnavailableReason
enum (connecting/offline/read-only/unsupported/busy) + a DictationErrorCode enum,
pure classifiers (classifyGetUserMediaError / classifyTranscriptionError) and
resolvers (resolveUnavailableLabel / dictationErrorMessage). All user-facing
dictation strings are formed here; the verbatim server message still wins for
transcription errors.
- page-editor publishes dictationAvailabilityAtom { isEditable, reason } computed
at the source (editable/edit-mode/showStatic/collab status): connecting vs
offline (stuck) vs read-only. DictationGroup forwards the reason to MicButton.
- MicButton is reason-aware: a disabled mic shows the cause-specific tooltip. The
disabled-hover silence is fixed by marking disabled the Mantine way
(data-disabled/aria-disabled + click guard) instead of the native attribute, so
the Tooltip fires — applied to both the idle (reason) and error (errorMessage)
states.
- Both hooks route every error through the shared resolver (deleting the
duplicated transcriptionErrorMessage), and expose errorMessage for the tooltip.
Wording is byte-identical to each hook's original (incl. the batch hook's
DOMException name prefix and the verbatim server message).
- i18n: 3 new reason keys in en-US + ru-RU, and the previously-missing ru-RU
dictation error translations.
Tests: dictation-status.test.ts (all classifier/resolver branches, incl. server
message passthrough) + mic-button.test.tsx (disabled mic shows the reason text,
uses data-disabled not native disabled — fails against the pre-fix code).
vitest: 5 files / 32 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The +42-line height-reservation logic lived inline in PageEditor on the risky
static→live swap path with zero tests (F1). Extract it into
useSwapHeightReservation(showStatic, menuContainerRef) → { reservedHeight,
captureReservation }, mirroring the sibling useScrollRestoreOnSwap extraction,
so its release guard and cap are directly unit-testable.
Pure extraction — behavior identical. The capture stays a synchronous callback
the editor invokes in the collab-sync effect (reading swapWrapperRef.offsetHeight
while the static content is still mounted, before setShowStatic(false)); a
post-transition effect inside the hook would read the collapsed live height and
be wrong. The rAF release loop (release at scrollHeight >= reserved, or the
RELEASE_CAP_MS=4000 cap) and cancelAnimationFrame cleanup moved verbatim.
Tests (use-swap-height-reservation.test.ts) cover 4 branches, mutation-verified:
(a) capture → reservedHeight; (b) release when live content reaches reserved;
(c) release at the cap when it never does; (d) non-swap → stays null.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause (confirmed via Chrome DevTools on the live app, and the fix validated
there too): after the reading-position restore lands correctly, the static→live
editor swap momentarily SHRINKS the document (the live editor lays out its content
over a few frames — measured height 32005 → 22050), so the browser CLAMPS window
scroll to the top. That is what produced all of:
- "lands correct → jumps to top → back down" (restore#2 recovering from the clamp),
- the final position overshooting (~6000px) via scroll-anchoring during recovery,
- "scroll a little → jumps to 0" (the clamp catching the reader mid-scroll).
Fixing the restore logic was chasing symptoms. This reserves the pre-swap content
height (a min-height on a wrapper around the static/live editor) until the live
editor has laid out (or a short safety cap), so the document never collapses and
window scroll simply survives the swap. Validated live: with the height pinned the
restore fires ONCE and the position stays put (no reset, no jitter, no overshoot);
the existing post-swap re-assert becomes a silent no-op.
No change to the restore hook or its tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prior test guarded a verbatim MIRROR of the two scroll-restore useLayoutEffect
blocks — the reviewer proved removing '&& editor' from the real page-editor.tsx left
the test green (a copy, not the original). Extract the wiring into an exported
useScrollRestoreOnSwap(pageId, editor, showStatic) hook (the two effects verbatim +
useScrollPosition inside; F1 budget logic untouched), call it once from page-editor.tsx
(replacing the removed useScrollPosition call + both effects), and rewrite the test to
render the REAL hook — deleting the mirror and the false 'regresses in lockstep' comment
(F2-doc). Non-vacuity proven: removing '&& editor' from the real hook reddens the guard
test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reading-position restore fired only after collab sync (`!showStatic`),
so the page painted at the top and then visibly jumped — and readers who had
already started scrolling were rudely yanked back to the saved position.
- Abort restore permanently on genuine user scroll intent: `wheel`/`touchstart`
unconditionally, and `keydown` only for real scroll keys (Arrow/Page/Home/End/
Space) so shortcuts and typing do not disable it. Our own `window.scrollTo`
never emits these, so restore cannot self-abort.
- Restore earlier via `useLayoutEffect` (before paint) while the static/cached
content is laid out, and re-assert once after the static->live editor swap.
- Make `restoreScrollPosition` idempotent with a redundancy guard so the two
triggers never double-scroll; share one bounded timeout budget across them.
- Add tests for interaction-abort, scroll-key filtering, idempotence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds CommentHoverPreview, mounted in page-editor next to <EditorContent>:
hovering a `.comment-mark[data-comment-id]` span shows a small floating card
(createPortal, position:fixed, pointer-events:none so it never intercepts the
mark's click) with the parent comment's plain text. Uses useCommentsQuery
(shares the ["comments", pageId] cache with the side panel — no extra
request). Skips unknown/not-yet-loaded, resolved (data-resolved attr or
resolvedAt/resolvedById), and empty-text comments. A ~120ms open delay avoids
flicker; hides on mouseout / mousedown / scroll(capture) / resize / page
change. commentContentToText flattens the comment's ProseMirror doc
(stringified or parsed) to plain text, preserving hardBreaks as newlines and
never throwing. Main editor only (read-only / shares / history out of scope).
closes#268
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds useScrollPosition(pageId): saves window.scrollY to sessionStorage
(key gitmost:scroll-position:<pageId>) on throttled scroll / pagehide /
visibilitychange / cleanup, capturing the previously-saved value
synchronously at mount before any handler can overwrite it with the fresh 0.
restoreScrollPosition() (wired in page-editor.tsx to fire once the live
content is laid out, !showStatic && editor) yields to a #hash anchor, then
polls the document height and scrolls to the saved Y once the content is
tall enough, with a 5s timeout clamped to the max reachable position. All
storage access is try/caught so a disabled/quota'd Storage never breaks the
page. The in-flight restore poll is held in a ref and cancelled on unmount,
so a fast SPA navigation can't scroll the next page. closes#266
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public sharing (#218):
- Bind public-share content to the requested shareId. getSharedPage now
enforces dto.shareId (forwarded from /share/:shareId/p/:slug): the page must
be reachable THROUGH that exact share (its own share, or an includeSubPages
ancestor that contains it). A forged/mismatched shareId 404s instead of
rendering off the slug alone and no longer leaks the real canonical key via
redirect. A request with no shareId keeps the legacy slug-capability path.
- Trim /shares/page-info: drop internal metadata (creatorId, spaceId,
workspaceId, contributorIds, lastUpdated*, parent/position, lock/template
flags, timestamps) from the anonymous payload.
- Default share-to-web includeSubPages to false (opt-in), so enabling a share
no longer silently exposes the whole sub-tree (#216).
Editor (#218):
- Harden the new-page pre-sync window: the body editor is kept read-only until
the collab provider is Connected and synced, so early keystrokes can't land
only in local ProseMirror and then be clobbered by the server's empty doc.
- Surface a "Connecting… (read-only)" affordance during the static phase so
input isn't silently swallowed.
Other:
- Breadcrumb: resolve from the page's own ancestor data (/pages/breadcrumbs)
instead of waiting for the lazily-built sidebar tree, so deep pages don't
render a blank breadcrumb for seconds.
- Pasting GitHub `> [!type]` callouts now converts to a callout node instead of
a literal blockquote (new marked extension wired into markdownToHtml).
Tests: editor-sync-state gate (client), getSharedPage share-binding (server),
github-callout markdown conversion (editor-ext).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the window.gitmost native-host bridge with three methods that work
when no page is open, registered globally at the app-shell level (not in
page-editor.tsx) so the react-router navigate fn and the api-client are
available:
- listSpaces(): reuse getSpaces() -> [{id, name}], flags truncation.
- listPages({spaceId, parentPageId?}): reuse getSidebarPages()
-> [{id, title, hasChildren}], first page only (truncated flag).
- createPageWithRecording({spaceId, parentPageId?, title?, base64,
filename, mimeType}): validate/decode the audio first (so a bad payload
leaves no junk page), resolve the space slug via getSpaceById (no-space
probe), createPage(), navigate via the router (no reload), wait for the
new page's editor to be mounted+editable+Yjs-connected, then run the same
uploadAudioAction path as insertRecording. Resolve-only error contract:
no-space | create-failed | editor-timeout | insert-failed.
DRY: extract the base64 decode/validate + audio-insert pipeline from
page-editor.tsx into features/editor/gitmost/gitmost-recording.ts; the
existing insertRecording now delegates to it (behavior unchanged).
Mount GitmostGlobalBridge once in GlobalAppShell. Before navigating, reset
the shared yjsConnectionStatusAtom so the readiness gate waits for the NEW
page's provider to connect instead of a stale "connected" from a previously
open page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a stable native-host JS-API on window.gitmost so the gitmost.app
WKWebView wrapper can hand a recorded audio file to the current page as an
audio block without depending on editor internals (atoms/Tiptap/Yjs).
- page-editor.tsx: register/tear down window.gitmost only while an editable
page editor is mounted (ready=true, version=1). insertRecording validates
mime/size, decodes base64 in 4-char-aligned chunks, rejects oversized
payloads before decoding (too-large), reuses the existing uploadAudioAction
pipeline, resolves machine-readable error codes
(no-editor/bad-type/too-large/insert-failed) and never throws. Cleanup is
identity-guarded so an unmounting PageEditor cannot disable a newer live
registration.
- editor-ext audio-upload: return the uploaded attachment from the upload fn
so the bridge can report success + attachmentId. Backward compatible:
existing fire-and-forget callers ignore the return value; the error path
still swallows and returns undefined (no re-throw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a gated "Transcribe" action to the audio block's bubble menu so an
already-embedded audio file can be transcribed (previously only live
microphone dictation was supported). The button fetches the embedded
file, normalizes its MIME type to the STT whitelist, reuses the existing
POST /ai-chat/transcribe endpoint, and inserts the result as a paragraph
right below the audio block.
- Mount the previously-unwired AudioMenu in page-editor (edit mode only),
which also surfaces the existing Download/Delete actions for audio.
- Gate the Transcribe button on settings.ai.dictation; show a spinner and
block double-submits while transcribing; map errors like the mic hook.
- Disambiguate duplicate-src blocks by re-scanning the doc and inserting
after the audio node closest to the originally selected one.
- Add i18n keys (en-US, ru-RU): Transcribe, Transcribing…, No speech
detected, plus ru-RU translations for the transcription error messages.
Batch of fixes from the automated QA pass on develop. Each was reproduced and
then verified fixed live (browser/curl); logic-bearing fixes have unit tests.
Functional bugs:
- #122 collab-token was capped by the anonymous public-share-AI throttler (5/min);
skip all non-AUTH named throttlers on this auth-guarded, client-cached route.
- #123 editor onAuthenticationFailed threw `jwtDecode(undefined)` and never
reconnected; read the token via a ref, guard the decode (incl. missing exp),
and refetch+reconnect on any auth failure.
- #124 a slash command containing a space ("/Heading 1") inserted literal text;
enable allowSpaces and close the menu when the query matches no items.
- #125 space slug auto-gen produced uppercase initials for multi-word names;
computeSpaceSlug now yields a lowercase alphanumeric slug.
- #126 AI chat window position/size now persisted (atomWithStorage) across reload;
also fixes a latent ResizeObserver-attach bug on first open.
- #127 workspace name update accepted URLs; add @NoUrls (parity with setup).
- #132 icon-columns 4/5 passed calc() into SVG width/height attrs (console spam);
size via style. share-for-page query returns null instead of undefined.
- #134 "Reindex now" counter looked stuck: reindex runs async; the client now
polls coverage (bounded) so the counter climbs live; misleading server comment
reworded.
UX / consistency:
- #128 add success toasts to favorite/label/avatar/member-(de)activate.
- #129 "1 result found" pluralization; hide the single-option Type filter.
- #130 replace raw Zod strings with friendly messages (name/password/group).
- #131 unify "Untitled" casing in tree/breadcrumb/tab; stop force-uppercasing
space-name chips; fix confirm-dialog labels (Cancel / Remove), invite
placeholder typo, Export/Move-to-space labels.
- #133 disable profile Save when clean; toast on unsupported avatar image;
style the invalid-invitation page with a CTA; hide Share for read-only users;
align the dictation "not configured" message; "Go to login page" typo.
Tests: computeSpaceSlug, workspace-name NoUrls DTO, share-query null
normalization, slash getSuggestionItems empty-close.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Embed another page's LIVE content into a host page (it updates when the source
changes, not a static copy). A page can be flagged a template for discovery in
the picker; any accessible page can be embedded.
Server:
- migrations: pages.is_template (+ partial index) and page_template_references
(whole-page back-refs); db.d.ts/entity types hand-merged (db.d.ts is curated).
- POST /pages/toggle-template (CASL Edit) flips is_template; is_template is
returned by findById + the sidebar tree select so the tree menu label
reflects state. Search suggestions gain an onlyTemplates filter for the picker.
- POST /pages/template/lookup ({sourcePageIds[]}, <=50): returns each accessible
source's {title, icon, slugId, content, sourceUpdatedAt} with comment marks
stripped (same access path as transclusion: filterViewerAccessiblePageIds;
inaccessible -> no_access, missing -> not_found; error path -> not_found, never
raw content).
- reference sync (collectPageEmbedsFromPmJson + syncPageTemplateReferences) on
the Yjs save hook; duplicatePage remaps pageEmbed.sourcePageId + inserts refs.
Known MVP gap: REST content updates don't resync refs (lookup uses in-doc ids).
Client:
- pageEmbed node (editor-ext, registered in BOTH client + server schemas);
read-only NodeView with a batching lookup; '/Embed page' slash + template
picker (self-embed prevented); 'Make/Unset template' in the tree node menu.
- Cycle guard: an ancestry-chain context + depth cap (5) render a 'circular
embed' placeholder instead of recursing.
- Public shares show a placeholder (no public lookup in MVP).
MVP excludes (follow-ups): public-share lookup, unsync->static copy, server-side
expansion for export/RAG, MCP schema mirror, point-in-time snapshots.
Implements docs/page-templates-plan.md (MVP, variant A).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strip the proprietary client EE so the fork ships a clean community/AGPL
edition, mirroring Forkmost. Delete apps/client/src/ee (201 files) and
packages/ee, and patch every consumer that imported from @/ee/*.
- gate-out EE features (useHasFeature -> false): API keys, SSO, MFA, SCIM,
audit logs, AI / AI-chat, templates, page permissions, page verification,
comment resolution, trash retention, viewer comments
- drop cloud/billing/trial/entitlement/posthog flows; sign-in is now
email+password only (no SSO/LDAP/cloud)
- remove EE routes from App.tsx and EE entries from sidebars/settings nav
- restore the community page-share button (ShareModal) that the EE
PageShareModal used to provide
- remove the dead "Attachments" search filter, dead MFA navigation and
orphaned route constants
Client type-checks clean; full `pnpm build` is green for all three projects.
The header edit/read toggle now controls only the current session's mode
without saving it as the user's preference. The saved preference (set in
profile settings) is applied once on initial load and sticks across page
navigations within the session, so navigating to a new page no longer
resets the mode mid-session.
Fixes#1693
* Improve cmd-k / ctrl-k behavior
Use cmd-k on macOS/iOS for search and keep ctrl-k everywhere else.
Fixes a bug where ctrl-k on macOS, which cuts to the end of the line,
was also triggering the search prompt.
* comment submit: cmd-enter (mac) / ctrl-enter (win/linux)
* feat: new image menu
* switch to resizable side handles
* use pixels
* refactor excalidraw and drawio menu
* support image resize undo
* video resize
* callout menu refresh
* refresh table menus
* fix color scheme
* fix: patch @tiptap/core ResizableNodeView to prevent resize sticking after mouseup
* feat: columns
* notes callout
* focus on first column
* capture tab key in column
* fix print
* hide columns menu when some nodes are focused
* fix print
* fix columns
* selective placeholder
* fix blockquote
* quote
* fix callout in columns
* feat(ee): AI menu
* - Add insert below and copy option
* prebuild @editor-ext
* sanitize output
* clear existing output
* switch to menu component
* refactor directory
* separator
* refactor directory
* support more languages
* pass markdown to model
* fix: close AI menu on page change
* enhance text input and preview styling
* fix: Use absolute positioning for the AI menu
* make preview scrollable
* activation controls
* enhance bubble menu
* sync
* set width
* fix line break
* switch terminologies
* cloud
* buffer
---------
Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
* feat: add heading extension with unique ID support and scroll functionality
* Added unique id for heading
* remove baseUrl heading storage
* move heading to extensions package
* WIP
* support anchors in mentions
* enhance scrolling functionality
* nodeId function
* fix nanoid import
* Bring unique-id extension local
* fixes
* fix internal link scroll in public pages
* add unique id server side
* rename mention anchor to anchorId
* capture first anchorId on paste
---------
Co-authored-by: Romik <40670677+RomikMakavana@users.noreply.github.com>
* chore: add dev container
* feat: add drag handle when hovering cell
* feat: add column drag and drop
* feat: add support for row drag and drop
* refactor: extract preview controllers
* fix: hover issue
* refactor: add handle controller
* chore: f
* chore: remove log
* chore: remove dev files
* feat: hide other drop indicators when table dnd working
* feat: add auto scroll and bug fix
* chore: f
* fix: firefox
* feat: resolve comment (EE)
* Add resolve to comment mark in editor (EE)
* comment ui permissions
* sticky comment state tabs (EE)
* cleanup
* feat: add space_id to comments and allow space admins to delete any comment
- Add space_id column to comments table with data migration from pages
- Add last_edited_by_id, resolved_by_id, and updated_at columns to comments
- Update comment deletion permissions to allow space admins to delete any comment
- Backfill space_id on old comments
* fix foreign keys
* feat: page find and replace
* * Refactor search and replace directory
* bugfix scroll
* Fix search and replace functionality for macOS and improve UX
- Fixed cmd+f shortcut to work on macOS (using 'Mod' key instead of 'Control')
- Added search functionality to title editor
- Fixed "Not found" message showing when search term is empty
- Fixed tooltip error when clicking replace button
- Changed replace button from icon to text for consistency
- Reduced width of search input fields for better UI
- Fixed result index after replace operation to prevent out-of-bounds error
- Added missing translation strings for search and replace dialog
- Updated tooltip to show platform-specific shortcuts (⌘F on Mac, Ctrl-F on others)
* Hide replace functionality for users with view-only permissions
- Added editable prop to SearchAndReplaceDialog component
- Pass editable state from PageEditor to SearchAndReplaceDialog
- Conditionally render replace button based on edit permissions
- Hide replace input section for view-only users
- Disable Alt+R shortcut when user lacks edit permissions
* Fix search dialog not closing properly when navigating away
- Clear all state (search text, replace text) when closing dialog
- Reset replace button visibility state on close
- Clear editor search term to remove highlights
- Ensure dialog closes properly when route changes
* fix: preserve text marks (comments, etc.) when replacing text in search and replace
- Collect all marks that span the text being replaced using nodesBetween
- Apply collected marks to the replacement text to maintain formatting
- Fixes issue where comment marks were being removed during text replacement
* ignore type error
---------
Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>