Merge branch 'feature/gitmost-audio-bridge' into develop
Expose window.gitmost native bridge for inserting recorded audio as audio blocks.
This commit is contained in:
@@ -65,7 +65,9 @@ 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 } 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 { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
@@ -86,6 +88,71 @@ 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<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({
|
||||
pageId,
|
||||
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 pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user