diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx
index 0437f9ac..b756bdde 100644
--- a/apps/client/src/components/layouts/global/global-app-shell.tsx
+++ b/apps/client/src/components/layouts/global/global-app-shell.tsx
@@ -14,6 +14,7 @@ import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
+import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
import classes from "./app-shell.module.css";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
@@ -157,6 +158,10 @@ export default function GlobalAppShell({
{/* Floating AI chat window. Mounted once globally; it is position: fixed
and self-hides when closed, so its place in the tree is not critical. */}
+ {/* Global gitmost native bridge: registers listSpaces / listPages /
+ createPageWithRecording on window.gitmost so the native host can
+ create a page with a recording even when no page editor is open. */}
+
>
);
}
diff --git a/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx b/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx
new file mode 100644
index 00000000..a1bef61f
--- /dev/null
+++ b/apps/client/src/features/editor/gitmost/gitmost-global-bridge.tsx
@@ -0,0 +1,316 @@
+import { useEffect, useRef } from "react";
+import { useNavigate } from "react-router-dom";
+import { getDefaultStore } from "jotai";
+import { WebSocketStatus } from "@hocuspocus/provider";
+import { Editor } from "@tiptap/core";
+import {
+ pageEditorAtom,
+ yjsConnectionStatusAtom,
+} from "@/features/editor/atoms/editor-atoms.ts";
+import {
+ getSpaceById,
+ getSpaces,
+} from "@/features/space/services/space-service.ts";
+import {
+ createPage,
+ getSidebarPages,
+} from "@/features/page/services/page-service.ts";
+import { buildPageUrl } from "@/features/page/page.utils.ts";
+import {
+ GitmostBridge,
+ GitmostCreatePagePayload,
+ GitmostCreatePageResult,
+ GitmostListPagesPayload,
+ GitmostListPagesResult,
+ GitmostListSpacesResult,
+ gitmostDecodePayloadToFile,
+ gitmostUploadFileToEditor,
+} from "@/features/editor/gitmost/gitmost-recording.ts";
+
+// How long to wait for a freshly-navigated page's editor to mount, become
+// editable, and connect its Yjs provider before giving up.
+const GITMOST_EDITOR_READY_TIMEOUT_MS = 20000;
+const GITMOST_EDITOR_POLL_INTERVAL_MS = 120;
+
+// Poll the (default) jotai store until the editor for `pageId` is mounted,
+// editable and its Yjs provider is connected. Resolves the live editor, or null
+// on timeout. Reuses pageEditorAtom + yjsConnectionStatusAtom — the same signals
+// PageEditor maintains. The storage.pageId check guards against matching a stale
+// editor left over from the previously-open page.
+function gitmostWaitForEditor(
+ pageId: string,
+ timeoutMs: number,
+): Promise {
+ const store = getDefaultStore();
+ const deadline = Date.now() + timeoutMs;
+ return new Promise((resolve) => {
+ const check = () => {
+ const editor = store.get(pageEditorAtom) as Editor | null;
+ const yjsStatus = store.get(yjsConnectionStatusAtom);
+ // `storage.pageId` is a custom field PageEditor.onCreate sets; it is not
+ // part of Tiptap's Storage type, so read it through an indexed cast.
+ const editorPageId = (
+ editor?.storage as unknown as Record | undefined
+ )?.pageId;
+ const ready =
+ !!editor &&
+ !editor.isDestroyed &&
+ editor.isEditable &&
+ editorPageId === pageId &&
+ yjsStatus === WebSocketStatus.Connected;
+ if (ready) {
+ resolve(editor);
+ return;
+ }
+ if (Date.now() >= deadline) {
+ resolve(null);
+ return;
+ }
+ setTimeout(check, GITMOST_EDITOR_POLL_INTERVAL_MS);
+ };
+ check();
+ });
+}
+
+// Registers the global gitmost bridge methods that work WITHOUT an open page
+// (listSpaces / listPages / createPageWithRecording). Mounted once at the
+// app-shell level so the react-router navigate fn and the api-client are
+// available even when no page editor is mounted. insertRecording stays in
+// PageEditor (tied to the live editable editor). Renders nothing.
+export default function GitmostGlobalBridge() {
+ const navigate = useNavigate();
+ // The effect registers the bridge once; reading the latest navigate via a ref
+ // avoids a stale closure if react-router hands back a new function identity.
+ const navigateRef = useRef(navigate);
+ useEffect(() => {
+ navigateRef.current = navigate;
+ }, [navigate]);
+
+ useEffect(() => {
+ const w = window as unknown as { gitmost?: Partial };
+ w.gitmost = w.gitmost || {};
+ // Advertise the bridge version even before any page editor mounts; do not
+ // clobber a value already set by an active PageEditor.
+ if (typeof w.gitmost.version !== "number") w.gitmost.version = 1;
+
+ const listSpaces = async (): Promise => {
+ try {
+ const res = await getSpaces({ limit: 100 });
+ const spaces = (res?.items ?? []).map((s) => ({
+ id: s.id,
+ name: s.name,
+ }));
+ // v1 returns only the first page; flag truncation so the host knows
+ // more spaces exist.
+ const truncated = Boolean(res?.meta?.hasNextPage);
+ return { ok: true, spaces, truncated };
+ } catch (err: any) {
+ console.error("[gitmost] listSpaces failed", err);
+ return {
+ ok: false,
+ error: "list-failed",
+ message:
+ err?.response?.data?.message ??
+ err?.message ??
+ "Failed to list spaces",
+ };
+ }
+ };
+
+ const listPages = async (
+ payload: GitmostListPagesPayload,
+ ): Promise => {
+ try {
+ const spaceId = payload?.spaceId;
+ if (!spaceId) {
+ return {
+ ok: false,
+ error: "bad-args",
+ message: "spaceId is required",
+ };
+ }
+ const res = await getSidebarPages({
+ spaceId,
+ pageId: payload?.parentPageId,
+ limit: 100,
+ });
+ const pages = (res?.items ?? []).map((p) => ({
+ id: p.id,
+ title: p.title,
+ hasChildren: Boolean(p.hasChildren),
+ }));
+ // v1 returns only the first page of children; flag truncation so the
+ // host knows more exist.
+ const truncated = Boolean(res?.meta?.hasNextPage);
+ return { ok: true, pages, truncated };
+ } catch (err: any) {
+ console.error("[gitmost] listPages failed", err);
+ return {
+ ok: false,
+ error: "list-failed",
+ message:
+ err?.response?.data?.message ??
+ err?.message ??
+ "Failed to list pages",
+ };
+ }
+ };
+
+ const createPageWithRecording = async (
+ payload: GitmostCreatePagePayload,
+ ): Promise => {
+ try {
+ const { spaceId, parentPageId, title, base64, filename, mimeType } =
+ payload || ({} as GitmostCreatePagePayload);
+
+ if (!spaceId) {
+ return {
+ ok: false,
+ error: "no-space",
+ message: "spaceId is required",
+ };
+ }
+
+ // Validate/decode the recording BEFORE creating the page so a bad
+ // payload never leaves an empty junk page behind. Per the createPage
+ // error contract, any decode failure collapses to "insert-failed" (the
+ // real reason is kept in `message`).
+ const decoded = gitmostDecodePayloadToFile({
+ base64,
+ filename,
+ mimeType,
+ });
+ if ("error" in decoded) {
+ return {
+ ok: false,
+ error: "insert-failed",
+ message: decoded.error.message ?? "Invalid recording payload",
+ };
+ }
+
+ // Resolve the space slug (needed for router navigation); also a
+ // permission/existence probe -> no-space on failure.
+ let spaceSlug: string | undefined;
+ try {
+ const space = await getSpaceById(spaceId);
+ spaceSlug = space?.slug;
+ } catch (err: any) {
+ console.error("[gitmost] resolve space failed", err);
+ return {
+ ok: false,
+ error: "no-space",
+ message:
+ err?.response?.data?.message ??
+ err?.message ??
+ "Space not found or no access",
+ };
+ }
+ if (!spaceSlug) {
+ return {
+ ok: false,
+ error: "no-space",
+ message: "Space not found or no access",
+ };
+ }
+
+ // Create the page (REST). Default title when none is provided.
+ const defaultTitle = `Recording ${new Date().toLocaleString()}`;
+ let page;
+ try {
+ // `spaceId` is accepted by the create-page endpoint but is not part of
+ // the shared IPage type; cast to satisfy the createPage signature.
+ page = await createPage({
+ spaceId,
+ parentPageId: parentPageId ?? undefined,
+ title: title ?? defaultTitle,
+ } as any);
+ } catch (err: any) {
+ console.error("[gitmost] createPage failed", err);
+ return {
+ ok: false,
+ error: "create-failed",
+ message:
+ err?.response?.data?.message ??
+ err?.message ??
+ "Failed to create page",
+ };
+ }
+ if (!page?.id || !page?.slugId) {
+ return {
+ ok: false,
+ error: "create-failed",
+ message: "Failed to create page",
+ };
+ }
+
+ // Reset the shared Yjs status before navigating. The atom is global and
+ // is NOT reset when a PageEditor unmounts, so it can still hold
+ // "connected" from a previously-open page; clearing it ensures the
+ // readiness gate below waits for the NEW page's provider to connect.
+ getDefaultStore().set(yjsConnectionStatusAtom, "");
+
+ // Navigate via the router (no full reload).
+ navigateRef.current(buildPageUrl(spaceSlug, page.slugId, page.title));
+
+ // Wait for the new page's editor: mounted, editable, Yjs connected.
+ const editor = await gitmostWaitForEditor(
+ page.id,
+ GITMOST_EDITOR_READY_TIMEOUT_MS,
+ );
+ if (!editor) {
+ return {
+ ok: false,
+ error: "editor-timeout",
+ message: "Editor was not ready in time",
+ // Return pageId so the host can still surface the created page.
+ pageId: page.id,
+ };
+ }
+
+ // Same insert path as insertRecording.
+ const result = await gitmostUploadFileToEditor(
+ editor,
+ page.id,
+ decoded.file,
+ );
+ if (!result.ok) {
+ return {
+ ok: false,
+ error: "insert-failed",
+ message: result.message ?? "Failed to insert recording",
+ pageId: page.id,
+ };
+ }
+ return { ok: true, pageId: page.id };
+ } catch (err: any) {
+ console.error("[gitmost] createPageWithRecording failed", err);
+ return {
+ ok: false,
+ error: "insert-failed",
+ message:
+ err?.response?.data?.message ??
+ err?.message ??
+ "Failed to create page with recording",
+ };
+ }
+ };
+
+ w.gitmost.listSpaces = listSpaces;
+ w.gitmost.listPages = listPages;
+ w.gitmost.createPageWithRecording = createPageWithRecording;
+
+ return () => {
+ // Only remove our own registrations (defensive against a future second
+ // mount having replaced them).
+ if (w.gitmost) {
+ if (w.gitmost.listSpaces === listSpaces) delete w.gitmost.listSpaces;
+ if (w.gitmost.listPages === listPages) delete w.gitmost.listPages;
+ if (w.gitmost.createPageWithRecording === createPageWithRecording) {
+ delete w.gitmost.createPageWithRecording;
+ }
+ }
+ };
+ }, []);
+
+ return null;
+}
diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.ts
new file mode 100644
index 00000000..c9acec5f
--- /dev/null
+++ b/apps/client/src/features/editor/gitmost/gitmost-recording.ts
@@ -0,0 +1,263 @@
+import { Editor } from "@tiptap/core";
+import { getFileUploadSizeLimit } from "@/lib/config.ts";
+import { formatBytes } from "@/lib";
+import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
+
+// --- gitmost native bridge: shared types & helpers ------------------------
+// Stable JS-API on `window.gitmost` for the native host (gitmost.app /
+// WKWebView). This module holds the parts shared between the open-page bridge
+// (insertRecording, in page-editor.tsx) and the global bridge (gitmost-global-
+// bridge.tsx): payload decoding/validation and the audio-insert pipeline, so
+// both apply identical rules without depending on editor internals.
+
+export interface GitmostInsertRecordingPayload {
+ base64: string; // raw file bytes, base64 (no data: prefix)
+ filename: string;
+ mimeType: string; // must be an audio/* type
+}
+
+export interface GitmostInsertRecordingResult {
+ ok: boolean;
+ attachmentId?: string;
+ // Machine-readable code: "no-editor" | "bad-type" | "too-large" | "insert-failed"
+ error?: string;
+ message?: string; // human-readable, may be surfaced by the host
+}
+
+export interface GitmostSpaceSummary {
+ id: string;
+ name: string;
+}
+
+export interface GitmostListSpacesResult {
+ ok: boolean;
+ spaces?: GitmostSpaceSummary[];
+ // v1 lists only the first page of spaces; true when more exist server-side.
+ truncated?: boolean;
+ error?: string;
+ message?: string;
+}
+
+export interface GitmostListPagesPayload {
+ spaceId: string;
+ parentPageId?: string;
+}
+
+export interface GitmostPageSummary {
+ id: string;
+ title: string;
+ hasChildren: boolean;
+}
+
+export interface GitmostListPagesResult {
+ ok: boolean;
+ pages?: GitmostPageSummary[];
+ // v1 lists only the first page of children; true when more exist server-side.
+ truncated?: boolean;
+ error?: string;
+ message?: string;
+}
+
+export interface GitmostCreatePagePayload {
+ spaceId: string;
+ parentPageId?: string; // omit/null = space root
+ title?: string; // default "Recording "
+ base64: string;
+ filename: string;
+ mimeType: string;
+}
+
+export interface GitmostCreatePageResult {
+ ok: boolean;
+ pageId?: string;
+ // Machine-readable code: "no-space" | "create-failed" | "editor-timeout" | "insert-failed"
+ error?: string;
+ message?: string;
+}
+
+// Full bridge surface exposed on `window.gitmost`. Writers attach a subset
+// (Partial), so readonly/share pages and no-page states are valid.
+export interface GitmostBridge {
+ ready: boolean;
+ version: number;
+ insertRecording: (
+ payload: GitmostInsertRecordingPayload,
+ ) => Promise;
+ listSpaces: () => Promise;
+ listPages: (payload: GitmostListPagesPayload) => Promise;
+ createPageWithRecording: (
+ payload: GitmostCreatePagePayload,
+ ) => Promise;
+}
+
+// Estimate decoded byte length from a base64 string WITHOUT decoding it, so an
+// oversized payload can be rejected before the buffer is allocated.
+export function gitmostEstimateBase64Bytes(base64: string): number {
+ const len = base64.length;
+ if (len === 0) return 0;
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
+ return Math.floor((len * 3) / 4) - padding;
+}
+
+// Decode a base64 string into bytes in fixed-size chunks. Call recordings can
+// be tens of MB; slicing on 4-char boundaries (each slice decodes to whole
+// bytes, no carry) keeps each atob() call bounded. Assumes unwrapped base64
+// with no embedded whitespace (per the native-host contract). Throws
+// InvalidCharacterError on malformed input.
+export function gitmostBase64ToBytes(base64: string): Uint8Array {
+ const CHUNK = 0x8000 * 4; // multiple of 4 base64 chars
+ const parts: Uint8Array[] = [];
+ let total = 0;
+ for (let i = 0; i < base64.length; i += CHUNK) {
+ const binary = atob(base64.slice(i, i + CHUNK));
+ const bytes = new Uint8Array(binary.length);
+ for (let j = 0; j < binary.length; j++) {
+ bytes[j] = binary.charCodeAt(j);
+ }
+ parts.push(bytes);
+ total += bytes.length;
+ }
+ // Back the result with an explicit ArrayBuffer so the view is typed
+ // Uint8Array (not ArrayBufferLike), which `new File([...])`
+ // accepts as a BlobPart under the lib.dom typings.
+ const out = new Uint8Array(new ArrayBuffer(total));
+ let offset = 0;
+ for (const part of parts) {
+ out.set(part, offset);
+ offset += part.length;
+ }
+ return out;
+}
+
+// Decode + validate a recording payload into a File, or return an error result.
+// Shared so insertRecording (open page) and createPageWithRecording (no page
+// open) apply identical validation. Error codes: "bad-type" | "too-large" |
+// "insert-failed".
+export function gitmostDecodePayloadToFile(
+ payload: GitmostInsertRecordingPayload,
+): { file: File } | { error: GitmostInsertRecordingResult } {
+ const { filename, mimeType } =
+ payload || ({} as GitmostInsertRecordingPayload);
+ let base64 = payload?.base64;
+
+ if (typeof mimeType !== "string" || !mimeType.startsWith("audio/")) {
+ return {
+ error: { ok: false, error: "bad-type", message: "Not an audio file" },
+ };
+ }
+ if (typeof base64 !== "string" || base64.length === 0) {
+ return {
+ error: { ok: false, error: "insert-failed", message: "Empty payload" },
+ };
+ }
+
+ // Defensively strip an accidental data:*;base64, prefix.
+ const marker = base64.indexOf("base64,");
+ if (base64.startsWith("data:") && marker !== -1) {
+ base64 = base64.slice(marker + "base64,".length);
+ }
+
+ const sizeLimit = getFileUploadSizeLimit();
+ // Reject oversized payloads before allocating the decode buffer.
+ if (gitmostEstimateBase64Bytes(base64) > sizeLimit) {
+ return {
+ error: {
+ ok: false,
+ error: "too-large",
+ message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
+ },
+ };
+ }
+
+ let bytes: Uint8Array;
+ try {
+ bytes = gitmostBase64ToBytes(base64);
+ } catch (decodeErr: any) {
+ return {
+ error: {
+ ok: false,
+ error: "insert-failed",
+ message: decodeErr?.message ?? "Invalid base64 payload",
+ },
+ };
+ }
+
+ const file = new File([bytes], filename || "recording", { type: mimeType });
+
+ // Exact size check (the pre-decode estimate is approximate).
+ if (file.size > sizeLimit) {
+ return {
+ error: {
+ ok: false,
+ error: "too-large",
+ message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
+ },
+ };
+ }
+
+ return { file };
+}
+
+// Insert an already-decoded recording File into a live editor via the existing
+// audio pipeline (placeholder -> POST /api/files/upload -> `audio` node,
+// Yjs-synced). Returns the attachment id on success.
+export async function gitmostUploadFileToEditor(
+ editor: Editor,
+ pageId: string,
+ file: File,
+): Promise {
+ try {
+ // Insert at the cursor, falling back to the end of the document.
+ const pos = editor.state.selection?.to ?? editor.state.doc.content.size;
+
+ // uploadAudioAction returns the attachment on success and undefined when
+ // the upload failed (the pipeline swallows the upload error and shows its
+ // own notification).
+ const attachment = (await (uploadAudioAction(
+ file,
+ editor,
+ pos,
+ pageId,
+ ) as unknown as Promise<{ id?: string } | undefined>));
+
+ if (attachment?.id) {
+ return { ok: true, attachmentId: attachment.id };
+ }
+ return { ok: false, error: "insert-failed", message: "Upload failed" };
+ } catch (err: any) {
+ // Never swallow: log the raw error and surface the real reason.
+ console.error("[gitmost] audio upload into editor failed", err);
+ return {
+ ok: false,
+ error: "insert-failed",
+ message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
+ };
+ }
+}
+
+// Full insert path used by the open-page bridge (insertRecording): guard the
+// editor, validate/decode the payload, then upload. Never throws — resolves to
+// a result code.
+export async function gitmostInsertRecordingIntoEditor(
+ editor: Editor | null,
+ pageId: string,
+ payload: GitmostInsertRecordingPayload,
+): Promise {
+ try {
+ // Only a live, editable editor may receive a recording.
+ if (!editor || editor.isDestroyed || !editor.isEditable) {
+ return { ok: false, error: "no-editor", message: "No editable page open" };
+ }
+ const decoded = gitmostDecodePayloadToFile(payload);
+ if ("error" in decoded) return decoded.error;
+ return await gitmostUploadFileToEditor(editor, pageId, decoded.file);
+ } catch (err: any) {
+ // The bridge must never throw — surface any unexpected failure as a code.
+ console.error("[gitmost] insertRecording failed", err);
+ return {
+ ok: false,
+ error: "insert-failed",
+ message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
+ };
+ }
+}
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index 984f8128..cc7e7b5c 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -65,9 +65,13 @@ import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
-import { extractPageSlugId, platformModifierKey, formatBytes } from "@/lib";
-import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
-import { getFileUploadSizeLimit } from "@/lib/config.ts";
+import { extractPageSlugId, platformModifierKey } from "@/lib";
+import {
+ GitmostBridge,
+ GitmostInsertRecordingPayload,
+ GitmostInsertRecordingResult,
+ gitmostInsertRecordingIntoEditor,
+} from "@/features/editor/gitmost/gitmost-recording.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
@@ -88,71 +92,6 @@ interface PageEditorProps {
canComment?: boolean;
}
-// --- gitmost native bridge ------------------------------------------------
-// Stable JS-API on `window.gitmost` for the native host (gitmost.app /
-// WKWebView) to insert a recorded audio file into the current page as an
-// `audio` block, without depending on editor internals (atoms/Tiptap/Yjs).
-interface GitmostInsertRecordingPayload {
- base64: string; // raw file bytes, base64 (no data: prefix)
- filename: string;
- mimeType: string; // must be an audio/* type
-}
-
-interface GitmostInsertRecordingResult {
- ok: boolean;
- attachmentId?: string;
- // Machine-readable code: "no-editor" | "bad-type" | "too-large" | "insert-failed"
- error?: string;
- message?: string; // human-readable, may be surfaced by the host
-}
-
-interface GitmostBridge {
- ready: boolean;
- version: number;
- insertRecording: (
- payload: GitmostInsertRecordingPayload,
- ) => Promise;
-}
-
-// Estimate decoded byte length from a base64 string WITHOUT decoding it, so an
-// oversized payload can be rejected before the buffer is allocated.
-function gitmostEstimateBase64Bytes(base64: string): number {
- const len = base64.length;
- if (len === 0) return 0;
- const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
- return Math.floor((len * 3) / 4) - padding;
-}
-
-// Decode a base64 string into bytes in fixed-size chunks. Call recordings can
-// be tens of MB; slicing on 4-char boundaries (each slice decodes to whole
-// bytes, no carry) keeps each atob() call bounded. Assumes unwrapped base64
-// with no embedded whitespace (per the native-host contract). Throws
-// InvalidCharacterError on malformed input.
-function gitmostBase64ToBytes(base64: string): Uint8Array {
- const CHUNK = 0x8000 * 4; // multiple of 4 base64 chars
- const parts: Uint8Array[] = [];
- let total = 0;
- for (let i = 0; i < base64.length; i += CHUNK) {
- const binary = atob(base64.slice(i, i + CHUNK));
- const bytes = new Uint8Array(binary.length);
- for (let j = 0; j < binary.length; j++) {
- bytes[j] = binary.charCodeAt(j);
- }
- parts.push(bytes);
- total += bytes.length;
- }
- // Back the result with an explicit ArrayBuffer so the view is typed
- // Uint8Array (not ArrayBufferLike), which `new File([...])`
- // accepts as a BlobPart under the lib.dom typings.
- const out = new Uint8Array(new ArrayBuffer(total));
- let offset = 0;
- for (const part of parts) {
- out.set(part, offset);
- offset += part.length;
- }
- return out;
-}
-
export default function PageEditor({
pageId,
editable,
@@ -435,89 +374,10 @@ export default function PageEditor({
w.gitmost.version = 1;
w.gitmost.ready = true;
- const insertRecording = async (
+ const insertRecording = (
payload: GitmostInsertRecordingPayload,
- ): Promise => {
- try {
- const { filename, mimeType } = payload || ({} as GitmostInsertRecordingPayload);
- let base64 = payload?.base64;
-
- // Only a live, editable editor may receive a recording.
- if (!editor || editor.isDestroyed || !editor.isEditable) {
- return { ok: false, error: "no-editor", message: "No editable page open" };
- }
- if (typeof mimeType !== "string" || !mimeType.startsWith("audio/")) {
- return { ok: false, error: "bad-type", message: "Not an audio file" };
- }
- if (typeof base64 !== "string" || base64.length === 0) {
- return { ok: false, error: "insert-failed", message: "Empty payload" };
- }
-
- // Defensively strip an accidental data:*;base64, prefix.
- const marker = base64.indexOf("base64,");
- if (base64.startsWith("data:") && marker !== -1) {
- base64 = base64.slice(marker + "base64,".length);
- }
-
- const sizeLimit = getFileUploadSizeLimit();
- // Reject oversized payloads before allocating the decode buffer.
- if (gitmostEstimateBase64Bytes(base64) > sizeLimit) {
- return {
- ok: false,
- error: "too-large",
- message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
- };
- }
-
- let bytes: Uint8Array;
- try {
- bytes = gitmostBase64ToBytes(base64);
- } catch (decodeErr: any) {
- return {
- ok: false,
- error: "insert-failed",
- message: decodeErr?.message ?? "Invalid base64 payload",
- };
- }
-
- const file = new File([bytes], filename || "recording", { type: mimeType });
-
- // Exact size check (the pre-decode estimate is approximate).
- if (file.size > sizeLimit) {
- return {
- ok: false,
- error: "too-large",
- message: `File exceeds the ${formatBytes(sizeLimit)} attachment limit`,
- };
- }
-
- // Insert at the cursor, falling back to the end of the document.
- const pos = editor.state.selection?.to ?? editor.state.doc.content.size;
-
- // Reuse the existing audio pipeline (placeholder -> POST /api/files/upload
- // -> replace with an `audio` node, Yjs-synced). It returns the attachment
- // on success and undefined when the upload failed (the pipeline swallows
- // the upload error and shows its own notification).
- const attachment = (await (uploadAudioAction(
- file,
- editor,
- pos,
- pageId,
- ) as unknown as Promise<{ id?: string } | undefined>));
-
- if (attachment?.id) {
- return { ok: true, attachmentId: attachment.id };
- }
- return { ok: false, error: "insert-failed", message: "Upload failed" };
- } catch (err: any) {
- // The bridge must never throw — surface any unexpected failure as a code.
- return {
- ok: false,
- error: "insert-failed",
- message: err?.response?.data?.message ?? err?.message ?? "Insert failed",
- };
- }
- };
+ ): Promise =>
+ gitmostInsertRecordingIntoEditor(editor, pageId, payload);
w.gitmost.insertRecording = insertRecording;