the vendoring machinery: one command reproduces the whole state
scripts/vendor.sh clones upstream, rewrites @/ imports to relative paths, and applies scripts/apply-host.py. web/host.ts is ours and gets placed into the vendored tree, because upstream declined it. a script rather than a .patch, and that is not a preference. between cfde698 and 5c83ac6 — one day — upstream changed an import line, re-indented the component from 6 spaces to 8, and added an editAccess guard. a context diff fails on all three, none of which concern us. so every anchor matches structure with flexible whitespace and re-indents to whatever it finds, and a missing anchor stops the run loudly. all three prompt-export paths are wired, which is what upstream said a complete version needs. the other two clipboard writes are ShareMenu's share-link and ask-an-AI blurb, and the reason they are excluded is written down so the count does not read as the same oversight twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wire the host adapter into the vendored editor.
|
||||
|
||||
Run by `vendor.sh` immediately after the import rewrite, so `web/vendor/` becomes a faithful copy of
|
||||
upstream plus exactly this and nothing else.
|
||||
|
||||
── Why a script and not a .patch ──
|
||||
|
||||
Upstream moves fast and reformats. Between `cfde698` and `5c83ac6`, one day apart, the i18n import
|
||||
line changed AND the whole component was re-indented from 6 spaces to 8 AND an `editAccess` guard
|
||||
appeared. A context diff would have failed on all three, none of which have anything to do with us.
|
||||
|
||||
So every anchor here matches on STRUCTURE with flexible whitespace, and re-indents its replacement to
|
||||
whatever it found. When an anchor genuinely disappears the script stops with the file and the thing
|
||||
it wanted — loudly, because a silent mis-wire is the failure that matters.
|
||||
|
||||
What it wires is what upstream said a complete version needs (lnkiai/m3e-canvas#56): the host hooks,
|
||||
in ALL THREE export paths rather than one.
|
||||
|
||||
── Three, and not the other two ──
|
||||
|
||||
A scan for `clipboard.writeText` finds FIVE call sites. Two of them are in `ShareMenu.tsx`, which
|
||||
upstream added after the review, and they are deliberately NOT wired:
|
||||
|
||||
copyAsk an "ask an AI" blurb containing a guide URL and the user's one-line idea
|
||||
copyLink a share link to the design
|
||||
|
||||
Neither is the generated prompt, so neither belongs behind `onExport`. This is written down because
|
||||
the count looks wrong at a glance, and the previous round of this work was rejected for wiring one
|
||||
path out of three — the next person to run that scan should not have to re-derive which is which.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VENDOR = Path(__file__).resolve().parent.parent / "web" / "vendor"
|
||||
|
||||
|
||||
def fail(what: str, where: str) -> None:
|
||||
raise SystemExit(
|
||||
f"\n Could not find {what} in {where}.\n"
|
||||
f" Upstream has moved underneath this script. Read the file, re-anchor, and re-run.\n"
|
||||
f" This failing is the point — it is how a silent mis-wire is avoided.\n"
|
||||
)
|
||||
|
||||
|
||||
def reindent(block: str, pad: str) -> str:
|
||||
"""Re-indent a replacement to sit at the indentation actually found in the file."""
|
||||
lines = block.split("\n")
|
||||
return "\n".join(pad + l if l.strip() else l for l in lines)
|
||||
|
||||
|
||||
def sub_once(src: str, pattern: str, build, what: str, where: str, flags=0) -> str:
|
||||
match = re.search(pattern, src, flags)
|
||||
if not match:
|
||||
fail(what, where)
|
||||
pad = re.match(r"[ \t]*", match.group(0)).group(0)
|
||||
return src[: match.start()] + build(match, pad) + src[match.end() :]
|
||||
|
||||
|
||||
def add_import(src: str, spec: str, where: str) -> str:
|
||||
lines = src.split("\n")
|
||||
idx = [i for i, l in enumerate(lines) if l.startswith("import ")]
|
||||
if not idx:
|
||||
fail("any import statement", where)
|
||||
lines.insert(idx[-1] + 1, spec)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Canvas.tsx ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def canvas(src: str, name: str) -> str:
|
||||
src = add_import(src, 'import { useHost } from "./lib/host";', name)
|
||||
|
||||
src = sub_once(
|
||||
src,
|
||||
r"[ \t]*export default function Page\(\) \{",
|
||||
lambda m, pad: m.group(0)
|
||||
+ f"\n{pad} // Optional host hooks; every default is the standalone behaviour. See lib/host.ts."
|
||||
+ f"\n{pad} const host = useHost();",
|
||||
"the component entry point",
|
||||
name,
|
||||
)
|
||||
|
||||
src = sub_once(
|
||||
src,
|
||||
r"([ \t]*)const d = localStorage\.getItem\(DOC_KEY\);\n[ \t]*if \(d\) \{",
|
||||
lambda m, pad: reindent(
|
||||
'// Branch on whether the host SUPPLIED a loader, not on what it returned: a host\n'
|
||||
'// returning null means "no saved document", which must start fresh rather than\n'
|
||||
"// fall back to this browser's standalone copy. Raised on lnkiai/m3e-canvas#56.\n"
|
||||
"const hosted = host.loadDoc ? host.loadDoc() : null;\n"
|
||||
"const d = host.loadDoc ? null : localStorage.getItem(DOC_KEY);\n"
|
||||
"if (hosted) {\n"
|
||||
" hadDocRef.current = true;\n"
|
||||
" applyDoc(hosted, false);\n"
|
||||
"} else if (d) {",
|
||||
pad,
|
||||
).lstrip(),
|
||||
"the document read",
|
||||
name,
|
||||
)
|
||||
|
||||
src = sub_once(
|
||||
src,
|
||||
r"([ \t]*)const u = localStorage\.getItem\(UI_KEY\);\n[ \t]*if \(u\) \{\n[ \t]*const ui = JSON\.parse\(u\);",
|
||||
lambda m, pad: reindent(
|
||||
"const stored = host.loadUi ? null : localStorage.getItem(UI_KEY);\n"
|
||||
"const ui = host.loadUi ? host.loadUi() : stored ? JSON.parse(stored) : null;\n"
|
||||
"if (ui) {",
|
||||
pad,
|
||||
).lstrip(),
|
||||
"the UI read",
|
||||
name,
|
||||
)
|
||||
|
||||
src = sub_once(
|
||||
src,
|
||||
r"([ \t]*)localStorage\.setItem\(\s*DOC_KEY,\s*JSON\.stringify\((\{[^{}]*\})\),?\s*\);",
|
||||
lambda m, pad: reindent(
|
||||
f"const payload = {m.group(2)};\n"
|
||||
"if (host.saveDoc) host.saveDoc(payload as Partial<Doc>);\n"
|
||||
"else localStorage.setItem(DOC_KEY, JSON.stringify(payload));",
|
||||
pad,
|
||||
).lstrip(),
|
||||
"the document write",
|
||||
name,
|
||||
re.S,
|
||||
)
|
||||
|
||||
src = sub_once(
|
||||
src,
|
||||
r"([ \t]*)localStorage\.setItem\(\s*UI_KEY,\s*JSON\.stringify\((\{[^{}]*\})\),?\s*\);",
|
||||
lambda m, pad: reindent(
|
||||
"const ui = {%s};\n" % " ".join(m.group(2)[1:-1].split())
|
||||
+ "if (host.saveUi) host.saveUi(ui);\n"
|
||||
"else localStorage.setItem(UI_KEY, JSON.stringify(ui));",
|
||||
pad,
|
||||
).lstrip(),
|
||||
"the UI write",
|
||||
name,
|
||||
re.S,
|
||||
)
|
||||
|
||||
return sub_once(
|
||||
src,
|
||||
r"([ \t]*)await navigator\.clipboard\.writeText\(effectivePrompt\(doc, widths, lang\)\);",
|
||||
lambda m, pad: reindent(
|
||||
"const text = effectivePrompt(doc, widths, lang);\n"
|
||||
"// The hook's own failure is isolated: a broken host must not also break copying.\n"
|
||||
"let handled = false;\n"
|
||||
"try { handled = host.onExport?.(text, doc) === true; } catch {}\n"
|
||||
"if (handled) return;\n"
|
||||
"await navigator.clipboard.writeText(text);",
|
||||
pad,
|
||||
).lstrip(),
|
||||
"the toolbar export (1 of 3)",
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
# ── the other two export paths ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def prompt_panel(src: str, name: str) -> str:
|
||||
src = add_import(src, 'import { useHost } from "../lib/host";', name)
|
||||
src = sub_once(
|
||||
src,
|
||||
r"[ \t]*const \[copied, setCopied\] = useState\(false\);",
|
||||
lambda m, pad: f"{pad}const host = useHost();\n" + m.group(0),
|
||||
"the copied state",
|
||||
name,
|
||||
)
|
||||
return sub_once(
|
||||
src,
|
||||
r"([ \t]*)await navigator\.clipboard\.writeText\(text\);",
|
||||
lambda m, pad: reindent(
|
||||
"let handled = false;\n"
|
||||
"try { handled = host.onExport?.(text, null) === true; } catch {}\n"
|
||||
"if (handled) { setCopied(true); return; }\n"
|
||||
"await navigator.clipboard.writeText(text);",
|
||||
pad,
|
||||
).lstrip(),
|
||||
"the copy call (2 of 3)",
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
def inspector(src: str, name: str) -> str:
|
||||
src = add_import(src, 'import { useHost } from "../lib/host";', name)
|
||||
# The hook belongs to whichever component owns `prompt`; it is taken beside the copied state.
|
||||
src = sub_once(
|
||||
src,
|
||||
r"[ \t]*const \[copied, setCopied\] = useState\(false\);",
|
||||
lambda m, pad: f"{pad}const host = useHost();\n" + m.group(0),
|
||||
"the copied state",
|
||||
name,
|
||||
)
|
||||
return sub_once(
|
||||
src,
|
||||
r"([ \t]*)await navigator\.clipboard\.writeText\(prompt\);",
|
||||
lambda m, pad: reindent(
|
||||
"let handled = false;\n"
|
||||
"try { handled = host.onExport?.(prompt, null) === true; } catch {}\n"
|
||||
"if (handled) { setCopied(true); return; }\n"
|
||||
"await navigator.clipboard.writeText(prompt);",
|
||||
pad,
|
||||
).lstrip(),
|
||||
"the export copy call (3 of 3)",
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not VENDOR.exists():
|
||||
raise SystemExit("web/vendor/ is missing — run scripts/vendor.sh first.")
|
||||
|
||||
# `lib/host.ts` is OURS, not upstream's — upstream declined it. The canonical copy is
|
||||
# `web/host.ts`, edited like any other file we own; this places it where the vendored code's
|
||||
# imports expect it. Without this step `vendor.sh` leaves those imports dangling, because it
|
||||
# wipes the directory before copying.
|
||||
source = VENDOR.parent / "host.ts"
|
||||
if not source.exists():
|
||||
raise SystemExit("web/host.ts is missing — it is the source for the vendored lib/host.ts.")
|
||||
(VENDOR / "lib" / "host.ts").write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
print(" placed lib/host.ts (ours)")
|
||||
for rel, fn in (
|
||||
("Canvas.tsx", canvas),
|
||||
("components/PromptPanel.tsx", prompt_panel),
|
||||
("components/Inspector.tsx", inspector),
|
||||
):
|
||||
path = VENDOR / rel
|
||||
path.write_text(fn(path.read_text(encoding="utf-8"), rel), encoding="utf-8")
|
||||
print(f" wired {rel}")
|
||||
print(" all three export paths wired")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -72,6 +72,10 @@ for root, _, files in os.walk(dest):
|
||||
open(path, "w", encoding="utf-8").write(out)
|
||||
PY
|
||||
|
||||
# Officer's own changes to the vendored code. Kept OUT of this script so the copy above is exactly
|
||||
# upstream, and applied here so one command reproduces the whole state.
|
||||
python3 "$HERE/scripts/apply-host.py"
|
||||
|
||||
cat > "$DEST/PROVENANCE.md" <<PROV
|
||||
# Vendored from lnkiai/m3e-canvas
|
||||
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Doc } from "./tokens";
|
||||
|
||||
/**
|
||||
* Hooks for an application that embeds the editor.
|
||||
*
|
||||
* The editor keeps its own state and its own UI; a host only needs to say where the document is kept
|
||||
* and where the prompt goes. Every field is optional and every default is what the standalone app
|
||||
* already did — localStorage for the document, the clipboard for the prompt — so an editor rendered
|
||||
* without a provider behaves exactly as before.
|
||||
*
|
||||
* ```tsx
|
||||
* <HostProvider value={{ loadDoc, saveDoc, onExport: (text) => sendToAgent(text) }}>
|
||||
* <Canvas />
|
||||
* </HostProvider>
|
||||
* ```
|
||||
*/
|
||||
export type HostAdapter = {
|
||||
/** Return the saved document, or null for a fresh one. Omit to read localStorage. */
|
||||
loadDoc?: () => Partial<Doc> | null;
|
||||
/** Persist the document. Omit to write localStorage. Called on every change, so debounce if remote. */
|
||||
saveDoc?: (doc: Partial<Doc>) => void;
|
||||
|
||||
/** Editor chrome — panel widths, favourites. Separate from the document because it is not the work. */
|
||||
loadUi?: () => unknown | null;
|
||||
saveUi?: (ui: unknown) => void;
|
||||
|
||||
/**
|
||||
* Where the generated prompt goes.
|
||||
*
|
||||
* Return `true` to say the host has handled it and the editor should not also write the clipboard;
|
||||
* anything else and the clipboard write still happens. That default matters — a host that only
|
||||
* wants to observe the export does not have to reimplement copying.
|
||||
*/
|
||||
onExport?: (prompt: string, doc: Doc) => boolean | void;
|
||||
};
|
||||
|
||||
const EMPTY: HostAdapter = {};
|
||||
|
||||
export const HostContext = createContext<HostAdapter>(EMPTY);
|
||||
|
||||
export const HostProvider = HostContext.Provider;
|
||||
|
||||
export const useHost = (): HostAdapter => useContext(HostContext);
|
||||
Vendored
+27
-23
@@ -99,6 +99,7 @@ import { ThemeContext, ensureFontLoaded, ensureLangFontLoaded } from "./lib/them
|
||||
import { BottomSheet, MobileActionBar, MobileInspector, MobileLang, MobileSettings } from "./components/Mobile";
|
||||
import { ConfirmDialog, IconBtn, Segmented } from "./components/ui";
|
||||
import { Lang, LangContext, SEED_TEXT, getLang, isLang, setGlobalLang, t, translateDefaultFrameName, translateDefaultText } from "./lib/i18n";
|
||||
import { useHost } from "./lib/host";
|
||||
|
||||
/** the screens while a model drafts: primary, tertiary and primary container, drifting */
|
||||
const DRAFT_GRADIENT = (p: Palette) => `linear-gradient(120deg, ${p.primaryContainer}, ${p.tertiaryContainer}, ${p.primary}, ${p.secondaryContainer}, ${p.primaryContainer})`;
|
||||
@@ -323,6 +324,8 @@ const LEFT_TABS: { key: LeftTab; icon: string; title: "parts" | "layers" | "colo
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
// Optional host hooks; every default is the standalone behaviour. See lib/host.ts.
|
||||
const host = useHost();
|
||||
/* ---------- document ---------- */
|
||||
const [editAccess, setEditAccess] = useState<"checking" | "editable" | "readonly">("checking");
|
||||
const [groups, setGroups] = useState<Group[]>(seed);
|
||||
@@ -605,8 +608,15 @@ export default function Page() {
|
||||
// React's development double-run would otherwise read back its own first save
|
||||
if (loadedRef.current) return;
|
||||
try {
|
||||
const d = localStorage.getItem(DOC_KEY);
|
||||
if (d) {
|
||||
// Branch on whether the host SUPPLIED a loader, not on what it returned: a host
|
||||
// returning null means "no saved document", which must start fresh rather than
|
||||
// fall back to this browser's standalone copy. Raised on lnkiai/m3e-canvas#56.
|
||||
const hosted = host.loadDoc ? host.loadDoc() : null;
|
||||
const d = host.loadDoc ? null : localStorage.getItem(DOC_KEY);
|
||||
if (hosted) {
|
||||
hadDocRef.current = true;
|
||||
applyDoc(hosted, false);
|
||||
} else if (d) {
|
||||
hadDocRef.current = true;
|
||||
applyDoc(JSON.parse(d) as Partial<Doc>, false);
|
||||
// frame mode is decided by the device (media-query effect), not restored
|
||||
@@ -619,9 +629,9 @@ export default function Page() {
|
||||
else localStorage.removeItem(BEFORE_KEY);
|
||||
}
|
||||
let initialLang: Lang = "ja";
|
||||
const u = localStorage.getItem(UI_KEY);
|
||||
if (u) {
|
||||
const ui = JSON.parse(u);
|
||||
const stored = host.loadUi ? null : localStorage.getItem(UI_KEY);
|
||||
const ui = host.loadUi ? host.loadUi() : stored ? JSON.parse(stored) : null;
|
||||
if (ui) {
|
||||
if (ui.view) setView(ui.view);
|
||||
if (typeof ui.leftOpen === "boolean") setLeftOpen(ui.leftOpen);
|
||||
if (typeof ui.rightOpen === "boolean") setRightOpen(ui.rightOpen);
|
||||
@@ -732,29 +742,18 @@ export default function Page() {
|
||||
useEffect(() => {
|
||||
if (!loadedRef.current || editAccess !== "editable") return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DOC_KEY,
|
||||
JSON.stringify({ groups, frames, paletteKey, frame, title, brief, promptEdit, platform: platform ?? undefined, customPalette: customPalette ?? undefined, dynamicColor, theme }),
|
||||
);
|
||||
const payload = { groups, frames, paletteKey, frame, title, brief, promptEdit, platform: platform ?? undefined, customPalette: customPalette ?? undefined, dynamicColor, theme };
|
||||
if (host.saveDoc) host.saveDoc(payload as Partial<Doc>);
|
||||
else localStorage.setItem(DOC_KEY, JSON.stringify(payload));
|
||||
} catch {}
|
||||
}, [editAccess, groups, frames, paletteKey, frame, title, brief, promptEdit, platform, customPalette, dynamicColor, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadedRef.current) return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
UI_KEY,
|
||||
JSON.stringify({
|
||||
view,
|
||||
leftOpen,
|
||||
rightOpen,
|
||||
leftW,
|
||||
rightW,
|
||||
favorites,
|
||||
mode,
|
||||
lang,
|
||||
}),
|
||||
);
|
||||
const ui = {view, leftOpen, rightOpen, leftW, rightW, favorites, mode, lang,};
|
||||
if (host.saveUi) host.saveUi(ui);
|
||||
else localStorage.setItem(UI_KEY, JSON.stringify(ui));
|
||||
} catch {}
|
||||
}, [
|
||||
view,
|
||||
@@ -3467,7 +3466,12 @@ export default function Page() {
|
||||
onLangSheet={() => setSheet(sheet === "lang" ? null : "lang")}
|
||||
onPrompt={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(effectivePrompt(doc, widths, lang));
|
||||
const text = effectivePrompt(doc, widths, lang);
|
||||
// The hook's own failure is isolated: a broken host must not also break copying.
|
||||
let handled = false;
|
||||
try { handled = host.onExport?.(text, doc) === true; } catch {}
|
||||
if (handled) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(t("copied", lang), 1400, "check");
|
||||
} catch {}
|
||||
}}
|
||||
|
||||
Vendored
+5
@@ -42,6 +42,7 @@ import { ButtonRun, CornerIcon, Field, IconBtn, Section, Segmented, SizePresets,
|
||||
import { AiWriteBtn } from "./AiPanel";
|
||||
import { popHistory } from "../lib/ai";
|
||||
import { KIND_TEXT, SWIPE_TEXT, TRANSITION_TEXT, t, useLang } from "../lib/i18n";
|
||||
import { useHost } from "../lib/host";
|
||||
|
||||
/** A text field for a web address: what is typed stays in the box, and only a complete
|
||||
* http(s) address (or an emptied box) reaches the part. */
|
||||
@@ -366,6 +367,7 @@ export function FrameInspector({
|
||||
onSize: (preset: FramePreset) => void;
|
||||
}) {
|
||||
const lang = useLang();
|
||||
const host = useHost();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [swipeDir, setSwipeDir] = useState<SwipeDir>("left");
|
||||
@@ -467,6 +469,9 @@ export function FrameInspector({
|
||||
copied ? t("copied", lang) : t("prompt", lang),
|
||||
async () => {
|
||||
try {
|
||||
let handled = false;
|
||||
try { handled = host.onExport?.(prompt, null) === true; } catch {}
|
||||
if (handled) { setCopied(true); return; }
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
setCopied(true);
|
||||
} catch {}
|
||||
|
||||
+5
@@ -6,6 +6,7 @@ import { Doc, Palette, Platform, defaultPlatformOf } from "../lib/tokens";
|
||||
import { Icon } from "./M3Node";
|
||||
import { Field, IconBtn, Segmented } from "./ui";
|
||||
import { t, useLang } from "../lib/i18n";
|
||||
import { useHost } from "../lib/host";
|
||||
|
||||
export function PromptPanel({
|
||||
doc,
|
||||
@@ -22,6 +23,7 @@ export function PromptPanel({
|
||||
const generated = useMemo(() => buildPrompt(doc, widths, undefined, lang), [doc, widths, lang]);
|
||||
const edited = doc.promptEdit !== undefined;
|
||||
const text = edited ? doc.promptEdit! : generated;
|
||||
const host = useHost();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,6 +34,9 @@ export function PromptPanel({
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
let handled = false;
|
||||
try { handled = host.onExport?.(text, null) === true; } catch {}
|
||||
if (handled) { setCopied(true); return; }
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
} catch {}
|
||||
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Doc } from "./tokens";
|
||||
|
||||
/**
|
||||
* Hooks for an application that embeds the editor.
|
||||
*
|
||||
* The editor keeps its own state and its own UI; a host only needs to say where the document is kept
|
||||
* and where the prompt goes. Every field is optional and every default is what the standalone app
|
||||
* already did — localStorage for the document, the clipboard for the prompt — so an editor rendered
|
||||
* without a provider behaves exactly as before.
|
||||
*
|
||||
* ```tsx
|
||||
* <HostProvider value={{ loadDoc, saveDoc, onExport: (text) => sendToAgent(text) }}>
|
||||
* <Canvas />
|
||||
* </HostProvider>
|
||||
* ```
|
||||
*/
|
||||
export type HostAdapter = {
|
||||
/** Return the saved document, or null for a fresh one. Omit to read localStorage. */
|
||||
loadDoc?: () => Partial<Doc> | null;
|
||||
/** Persist the document. Omit to write localStorage. Called on every change, so debounce if remote. */
|
||||
saveDoc?: (doc: Partial<Doc>) => void;
|
||||
|
||||
/** Editor chrome — panel widths, favourites. Separate from the document because it is not the work. */
|
||||
loadUi?: () => unknown | null;
|
||||
saveUi?: (ui: unknown) => void;
|
||||
|
||||
/**
|
||||
* Where the generated prompt goes.
|
||||
*
|
||||
* Return `true` to say the host has handled it and the editor should not also write the clipboard;
|
||||
* anything else and the clipboard write still happens. That default matters — a host that only
|
||||
* wants to observe the export does not have to reimplement copying.
|
||||
*/
|
||||
onExport?: (prompt: string, doc: Doc) => boolean | void;
|
||||
};
|
||||
|
||||
const EMPTY: HostAdapter = {};
|
||||
|
||||
export const HostContext = createContext<HostAdapter>(EMPTY);
|
||||
|
||||
export const HostProvider = HostContext.Provider;
|
||||
|
||||
export const useHost = (): HostAdapter => useContext(HostContext);
|
||||
Reference in New Issue
Block a user