d57392b5af
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>
63 lines
2.4 KiB
TypeScript
63 lines
2.4 KiB
TypeScript
import { WebSocketStatus } from "@hocuspocus/provider";
|
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
|
|
|
/**
|
|
* The collab document is usable only once the provider is Connected AND has
|
|
* synced (both the local IndexedDB replica and the remote room). Until then the
|
|
* in-browser Y.Doc is empty/stale, so edits would either be dropped or clobber
|
|
* the server's authoritative doc when it finally arrives.
|
|
*/
|
|
export function isCollabSynced(
|
|
status: WebSocketStatus | string,
|
|
isSynced: boolean,
|
|
): boolean {
|
|
return status === WebSocketStatus.Connected && isSynced;
|
|
}
|
|
|
|
/**
|
|
* Whether the page BODY editor may accept edits.
|
|
*
|
|
* `showStatic` is true during the pre-sync window (a read-only static editor is
|
|
* shown). Gating editability on `!showStatic` guarantees the body never becomes
|
|
* editable before the collab doc is synced, so early keystrokes on a freshly
|
|
* created page can't land only in local ProseMirror and then be lost when the
|
|
* server's initial empty doc syncs in (#218). Read-only and view modes are
|
|
* still honored via `editable`/`inEditMode`.
|
|
*/
|
|
export function isBodyEditable(opts: {
|
|
editable: boolean;
|
|
inEditMode: boolean;
|
|
showStatic: boolean;
|
|
}): boolean {
|
|
return opts.editable && opts.inEditMode && !opts.showStatic;
|
|
}
|
|
|
|
/**
|
|
* Whether dictation can start and, when it can't, the cause-specific reason the
|
|
* mic button surfaces. Derives editability from `isBodyEditable` (the single,
|
|
* tested gate) so the published `isEditable` can never diverge from the actual
|
|
* body-editable state and make the tooltip lie (#309).
|
|
*
|
|
* `isDisconnected` is the caller's own boolean (collab connection is in the
|
|
* Disconnected state), passed in so this module stays free of the collab enum.
|
|
*/
|
|
export function computeDictationAvailability(opts: {
|
|
editable: boolean;
|
|
inEditMode: boolean;
|
|
showStatic: boolean;
|
|
isDisconnected: boolean;
|
|
}): { isEditable: boolean; reason: DictationUnavailableReason | null } {
|
|
const isEditable = isBodyEditable({
|
|
editable: opts.editable,
|
|
inEditMode: opts.inEditMode,
|
|
showStatic: opts.showStatic,
|
|
});
|
|
if (isEditable) return { isEditable, reason: null };
|
|
// Permitted to edit and in edit mode but not yet synced (showStatic) → pre-sync.
|
|
if (opts.editable && opts.inEditMode && opts.showStatic) {
|
|
return { isEditable, reason: opts.isDisconnected ? "offline" : "connecting" };
|
|
}
|
|
// No edit permission or not in edit mode.
|
|
return { isEditable, reason: "read-only" };
|
|
}
|