feat(editor): expose window.gitmost bridge to insert recordings as audio blocks
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>
This commit is contained in:
@@ -65,7 +65,9 @@ import { useIdle } from "@/hooks/use-idle.ts";
|
|||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId, platformModifierKey } from "@/lib";
|
import { extractPageSlugId, platformModifierKey, formatBytes } from "@/lib";
|
||||||
|
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
|
||||||
|
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
@@ -86,6 +88,71 @@ interface PageEditorProps {
|
|||||||
canComment?: boolean;
|
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<GitmostInsertRecordingResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<ArrayBuffer> {
|
||||||
|
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<ArrayBuffer> (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({
|
export default function PageEditor({
|
||||||
pageId,
|
pageId,
|
||||||
editable,
|
editable,
|
||||||
@@ -354,6 +421,118 @@ export default function PageEditor({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expose the gitmost native bridge only while an editable page editor is
|
||||||
|
// mounted. Registering/tearing down here ties `ready` + `insertRecording`
|
||||||
|
// to the lifetime of the current editable editor: readonly/share pages and
|
||||||
|
// page switches re-run this effect (deps: live editable flag + pageId),
|
||||||
|
// recreating the closure over the active editor/pageId so a recording always
|
||||||
|
// targets whatever page is active at call time.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !editor.isEditable) return;
|
||||||
|
|
||||||
|
const w = window as unknown as { gitmost?: Partial<GitmostBridge> };
|
||||||
|
w.gitmost = w.gitmost || {};
|
||||||
|
w.gitmost.version = 1;
|
||||||
|
w.gitmost.ready = true;
|
||||||
|
|
||||||
|
const insertRecording = async (
|
||||||
|
payload: GitmostInsertRecordingPayload,
|
||||||
|
): Promise<GitmostInsertRecordingResult> => {
|
||||||
|
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<ArrayBuffer>;
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
w.gitmost.insertRecording = insertRecording;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Only tear down if our registration is still the active one. With
|
||||||
|
// React's mount-before-unmount ordering, a newer PageEditor instance may
|
||||||
|
// have already replaced the bridge; clearing it here would disable the
|
||||||
|
// live editor's bridge.
|
||||||
|
if (w.gitmost && w.gitmost.insertRecording === insertRecording) {
|
||||||
|
w.gitmost.ready = false;
|
||||||
|
delete w.gitmost.insertRecording;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [editor, pageId, editorIsEditable]);
|
||||||
|
|
||||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ const handleAudioUpload =
|
|||||||
.run();
|
.run();
|
||||||
disposePreviewFile();
|
disposePreviewFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return the uploaded attachment so callers that await this (e.g. the
|
||||||
|
// gitmost native bridge) can report success and the attachment id.
|
||||||
|
// Existing fire-and-forget callers ignore the return value.
|
||||||
|
return attachment;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(insertPlaceholderTimeout);
|
clearTimeout(insertPlaceholderTimeout);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user