英文页面是权威参考。本译文可能已经过期,可能落后英文版若干个版本;翻译之后有改动的条目会显示英文原文,并带有译文已过期标记。
Kimi Design API 参考 · 指南
Add Taco Design to an Electron app
This guide builds a small Electron app that shows the Taco Design editor in a window, opens a document from a file, saves it, exports layers, and closes without losing work. It uses @taco-design/electron, and everything in it runs in Electron’s main process.
The code on this page is not written for the page. Each block is read from the sample app in apps/electron-sample when the page is built, and the line under a block names the file and the lines. That app runs and is tested, so copy from it: main.mjs makes the window and the editor, saveModel.mjs holds the rules of saving, exportSelection.mjs exports, and windowTitle.mjs decides what the window title says.
Your app brings its own Electron, version 42 or later. The package was tried with Electron 44.7.0 on macOS only.
1. Install the package
For registry installs, set @taco-design:registry=https://packages.luyu.computer/ in .npmrc. Install @taco-design/electron for stable or @taco-design/electron@next for a prerelease build of main. PR builds are CI artifacts.
You can also install one file, named taco-design-electron-<version>-<short commit>.tgz. The package’s README says where to get it (a CI run, a release, or your own build). Install it from the file:
# in your Electron app
npm install /path/to/taco-design-electron-0.1.0-0123abc.tgz
Or keep the file in your repository and name it in package.json:
{
"dependencies": {
"@taco-design/electron": "file:vendor/taco-design-electron-0.1.0-0123abc.tgz"
}
}
The file holds the built editor, about 25 MB. The package has no dependency other than Electron. If you bundle your main process (webpack, Vite, esbuild), keep @taco-design/electron out of the bundle: it finds its preload script and the editor’s files next to its own code on disk (preloadPath, appDirectory).
2. Register the scheme
The editor’s page is loaded from a custom address, taco-design://app/. Electron must hear of the scheme before the app is ready, so register it at the top of your main file.
import { createTacoDesignView, registerTacoDesignScheme } from "@taco-design/electron";apps/electron-sample/main.mjs,第 21 行// Before the app is ready. An app with custom schemes of its own adds the
// exported `tacoDesignScheme` to its own registerSchemesAsPrivileged call
// instead, because Electron accepts that call only once.
registerTacoDesignScheme();apps/electron-sample/main.mjs,第 37 至 40 行registerTacoDesignScheme throws a TacoDesignError with the code scheme-registered-too-late when the app is already ready.
Electron accepts protocol.registerSchemesAsPrivileged once per app. If your app registers schemes of its own, do not call registerTacoDesignScheme; add the exported tacoDesignScheme object to your own call instead: protocol.registerSchemesAsPrivileged([yourScheme, tacoDesignScheme]).
3. Create the view and put it in a window
Make the view once the app is ready. In an ES module main file, write app.whenReady().then(async () => { ... }) and not await app.whenReady() at the top level: Electron fires “ready” only after the main module has finished loading, so the second form waits for ever. Everything below this point is inside that function.
const window = new BaseWindow({
width: 1400,
height: 900,
title: "Taco Design sample",
show: !process.argv.includes("--hidden"),
});
// The editor stores no document itself: this app hands one over and saves it.
// While it starts, the editor shows a slim progress bar of its own. With
// `--host-progress` it shows none: the app then draws the progress itself
// (this sample only writes it in the window title, see below).
const editor = createTacoDesignView({
document: startDocument,
startupProgress: process.argv.includes("--host-progress") ? "host" : "editor",
menuShortcutConflict: startConflict,
shortcuts: startRemapped ? DUPLICATE_REMAP : {},
});
window.contentView.addChildView(editor.view);
const fillWindow = () => {
const { width, height } = window.getContentBounds();
editor.view.setBounds({ x: 0, y: 0, width, height });
};
fillWindow();
window.on("resize", fillWindow);apps/electron-sample/main.mjs,第 125 至 150 行createTacoDesignView returns a TacoDesignView. TacoDesignView.view is a plain WebContentsView: add it to any window and give it bounds. The function sets the view up with safe settings (a sandbox, no Node.js in the page, its own storage partition), so your app does not have to. TacoDesignViewOptions lists what you can pass; document is explained in the next step, and menuShortcutConflict and shortcuts in step 8.
The editor is loading now. TacoDesignView.ready tells you how that ends:
try {
// The editor has opened the start document, so its file is now the one to save to.
await saverWhenReady;
const ready = await editor.ready;
windowTitle.startEnded();
windowTitle.setDocumentTitle(ready.document.title);
showTitle();
console.log(
`Taco Design is ready: "${ready.document.title}", document schema ${ready.documentSchemaVersion}.`,
startFile === null ? "No file was named, so nothing is saved." : `Saving to ${startFile}.`,
);
editor.focus();
for (const request of startsBeforeTheWindow.splice(0)) openInRunningApp(request.file);
} catch (error) {
// A TacoDesignError: error.code says what went wrong ("invalid-document"
// with error.path, "schema-too-new", ...). The view shows the same message.
// No last progress report came, so this is where the title stops saying
// that the editor is loading.
windowTitle.startEnded();
showTitle();
console.error("Taco Design could not start:", error);
if (startFile !== null) console.error(`${startFile} is left as it is: nothing is saved to it.`);
}apps/electron-sample/main.mjs,第 455 至 477 行ready resolves with a TacoDesignReadyState when the document is open and the renderer has drawn. It rejects with a TacoDesignError, and the view shows the same message, so a failed start is never a blank window. Always handle the rejection. There is no automatic retry: after a rejected ready, dispose the view and make a new one.
Two lines of that block belong to later parts of the sample. saverWhenReady is the save model of step 4, which is made once the editor is ready. The loop over startsBeforeTheWindow opens files that a second start of the app handed over: the sample runs as one copy at a time, because two copies would share one user data folder.
A view in a window that is not shown may stop drawing, because Electron tells the page that it is hidden. If your app drives the editor in a hidden window, call editor.view.webContents.setBackgroundThrottling(false).
4. Load and save a document
The editor in the view stores no document. It starts from the one your app hands over, keeps it in memory, and tells your app when it changes. A document is plain JSON (DocumentJson); your app need not look inside.
/**
* Reads the document file. A missing file means a new document; a file that
* is not JSON stops the app, so that it is never saved over.
* @param {string | null} file
* @returns {Promise<unknown>}
*/
async function readDocument(file) {
if (file === null) return null;
let text;
try {
text = await readFile(file, "utf8");
} catch (error) {
if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") return null;
throw error;
}
return JSON.parse(text);
}apps/electron-sample/main.mjs,第 83 至 99 行const startFile = documentFileFromArguments();
let startDocument;
try {
startDocument = await readDocument(startFile);
} catch (error) {
console.error(`${String(startFile)} could not be read as a document, so the app stops instead of saving over it:`, error);
app.exit(1);
return;
}apps/electron-sample/main.mjs,第 113 至 121 行The sample passes the result as document to createTacoDesignView (in step 3). null, or no document at all, starts a new blank document. A document that cannot be opened is never replaced by a blank one: ready rejects with invalid-document (and error.path says where it is wrong) or schema-too-new, and the view shows the reason instead of an editor, so nothing can be drawn and saved over your file.
To save, call TacoDesignView.getDocument and write what it gives you (TacoDesignDocumentSnapshot). The sample keeps the rules of saving in one file with no Electron in it, saveModel.mjs, and only hands it what that file cannot do itself: reading and replacing the editor’s document, reading and writing a file, closing the window, and asking the person what to do after a save that failed:
// Saving. saveModel.mjs decides when to save, what counts as edited, what
// happens after a failed save, when the window may close and how it goes
// over to another file; its first lines list the rules. This file only
// gives it what it cannot do itself. The model is made once the editor has
// opened the start document: before that, and when the editor refused that
// document, there is nothing to save, and a close saves nothing over the
// file. Without a file the model saves nothing until another file is opened.
/** @type {import("./saveModel.mjs").SaveModel<string> | null} */
let saver = null;
const saverWhenReady = editor.ready.then(() => {
/** @type {import("./saveModel.mjs").SaveModel<string>} */
const model = createSaveModel({
file: startFile,
saveDelayMs: SAVE_DELAY_MS,
getDocument: () => editor.getDocument(),
// With `ifRevision` the editor replaces only the document the model has just saved.
// `readDocument` gives null for a file that does not exist yet: the window
// then starts a new blank document that is saved there, as at the first start.
setDocument: (document, options) =>
(document === null ? editor.newDocument(options) : editor.setDocument(document, options)),
load: readDocument,
write: async (document, file) => {
// Written next to the file first, so a crash half-way leaves the old file whole.
await writeFile(`${file}.saving`, JSON.stringify(document));
await rename(`${file}.saving`, file);
},
close: () => window.close(),
askAfterFailedClose: async (error) => {
const { response } = await dialog.showMessageBox(window, {
type: "warning",
message: `"${windowTitle.documentTitle()}" could not be saved.`,
detail: `${String(model.file())}\n${error instanceof Error ? error.message : String(error)}\n\nClosing now loses the changes made since the last save.`,
buttons: ["Try Again", "Close Without Saving", "Cancel"],
defaultId: 0,
cancelId: 2,
});
return response === 0 ? "try-again" : response === 1 ? "close-without-saving" : "cancel";
},
askAfterFailedSwitch: async (error, next) => {
const { response } = await dialog.showMessageBox(window, {
type: "warning",
message: `"${windowTitle.documentTitle()}" could not be saved, so ${path.basename(next)} is not open yet.`,
detail: `${String(model.file())}\n${error instanceof Error ? error.message : String(error)}\n\nOpening ${next} now loses the changes made since the last save.`,
buttons: ["Try Again", "Open Without Saving", "Cancel"],
defaultId: 0,
cancelId: 2,
});
return response === 0 ? "try-again" : response === 1 ? "open-without-saving" : "cancel";
},
onState: showTitle,
// Told once, not for every retry. The title says the rest.
onSaveFailed: (error) => console.error(`Could not save ${String(model.file())}. The app keeps trying:`, error),
});
saver = model;
return model;
});apps/electron-sample/main.mjs,第 181 至 236 行The rules that file follows, and that your app should follow too:
- Do not read the document on every change. Documents hold their pictures, so they can be tens of megabytes. Save a little after the last change, and run one save at a time.
- Write next to the file first, then rename, so a crash half-way leaves the old file whole.
- A failed save changes nothing. The document stays edited; try again after a pause, and tell the person once, not on every retry.
- Never compare revisions across a reload. They start at 0 each time the editor’s page loads.
- Do not decide from
changeevents whether there is something to save. An event arrives a little after its edit.TacoDesignView.getDocumentis never behind.
The first lines of saveModel.mjs list every case as a table. The undo history is not part of the document.
Replace the open document with TacoDesignView.setDocument. When your app goes over to another file, save the open document first, and then replace exactly the document you saved. The page is another process, so it can take an edit after it answered your last read and before the replacement reaches it. Pass the revision of that read as ifRevision: the editor then replaces the document only while it is still at that revision, and rejects with revision-changed otherwise. Nothing has changed then, so save again and retry, a limited number of times:
for (let refusals = 0; ; ) {
readRevision = from !== null && saveFirst ? await saveUntilNothingIsNewer(from) : null;
// No `await` between that answer and this call (see `saveUntilNothingIsNewer`).
// The page is another process and can still take an edit before the
// call reaches it, so the editor is told which revision is on disk
// and replaces only that one.
replacing = replacement;
try {
opened = await setDocument(document, readRevision === null ? {} : { ifRevision: readRevision });
break;
} catch (error) {
const code = /** @type {{ code?: string } | null} */ (error)?.code ?? "";
if (code === "revision-changed" && readRevision !== null) {
// An edit came in between. The old document is still open, with
// that edit: bring it to disk and ask again.
refusals += 1;
if (refusals < MOST_REPLACEMENTS_FOR_ONE_SWITCH) continue;
// The edit of the last refusal is saved like any other, a little later.
if (saveTimer !== null) clearTimer(saveTimer);
saveTimer = setTimer(() => {
saveTimer = null;
saveInTheBackground();
}, saveDelayMs);
throw new Error(
`The document was changed each of the last ${MOST_REPLACEMENTS_FOR_ONE_SWITCH} times the other file was about to be opened, so it is still open.`,
);
}
if (DOCUMENT_KEPT_CODES.has(code)) return { outcome: "refused", error };
// It is not known which document the editor has: stop saving.
file = null;
failures = 0;
stopTimers();
tell();
return { outcome: "stopped-saving", error, file: from };
} finally {
replacing = null;
}
}apps/electron-sample/saveModel.mjs,第 486 至 523 行saveUntilNothingIsNewer is the loop shown in step 9. Revisions start at 0 again on every page load, so use a revision read after the last reloaded event. The sample goes over to another file when the app is started a second time with a file name.
A file that does not exist yet has no document to hand to setDocument, and setDocument(null) is refused. Call TacoDesignView.newDocument instead: it replaces the open document with a new blank one and takes the same ifRevision. In the sample, readDocument gives null for such a file, and the setDocument function it hands to the save model (in the snippet of this step) calls newDocument for null, so the open document is saved first in both cases.
5. Listen for events
TacoDesignView.on takes an event name and a listener, and returns a function that removes the listener. TacoDesignEvents lists the events. Events are small: none of them carries the document.
change says that the document changed, with a few facts about it (TacoDesignChange). During a burst of edits it arrives at most once per 100 ms, and the last one always arrives.
// A change event is small and carries no document.
editor.on("change", (change) => {
windowTitle.setDocumentTitle(change.title);
showTitle();
saver?.changed(change.revision, { origin: change.origin });
});apps/electron-sample/main.mjs,第 244 至 249 行selection says which page is shown and which layers are selected (SelectionSummary). TacoDesignView.getSelection answers the same at any time.
// Which layers are selected, for the window title. The event is small:
// the page ID and each layer's ID, name and kind.
editor.on("selection", (next) => {
selection = next;
showTitle();
});apps/electron-sample/main.mjs,第 252 至 257 行/**
* The selection in a few words, for a window title or a status line.
* @param {import("@taco-design/electron").SelectionSummary | null} selection
* @returns {string}
*/
export function describeSelection(selection) {
const only = selection?.nodes[0];
if (selection === null || only === undefined) return "nothing selected";
if (selection.nodes.length === 1) return `${only.name} (${only.shape ?? only.type}) selected`;
return `${selection.nodes.length} layers selected`;
}apps/electron-sample/exportSelection.mjs,第 153 至 163 行failure says that the editor stopped working after ready: the renderer stopped, the editor crashed, or the page’s process ended. error.code says which. TacoDesignView.reload loads the editor again; where the page can still answer it hands its document over first, so no edit is lost. Do not reload without a limit: a failure that comes back at once would reload for ever.
// The editor stopped working after it had started. Its crash screen has a
// Reload button of its own; a canvas that stopped drawing and a page whose
// process ended are loaded again here. reload() reads the document from
// the page first where it can, so no edit is lost.
let lastReloadAfterFailure = Number.NEGATIVE_INFINITY;
editor.on("failure", (error) => {
console.error(`Taco Design stopped working (${error.code}): ${error.message}`);
if (error.code === "editor-crashed") return;
if (Date.now() - lastReloadAfterFailure < RELOAD_AFTER_FAILURE_INTERVAL_MS) {
console.error("It failed again shortly after it was loaded again, so it is left as it is.");
return;
}
lastReloadAfterFailure = Date.now();
editor.reload().catch((reloadError) => console.error("Taco Design could not be loaded again:", reloadError));
});apps/electron-sample/main.mjs,第 286 至 300 行reloaded says that the page was loaded again and is ready (TacoDesignReloaded). Write the document again after it: your file may be older than what is shown.
// The page was loaded again (its crash screen's Reload, or reload() below)
// and counts its revisions from 0 again. With `handedOver` it shows the
// latest work, which the save model has not read and so saves again;
// without, the last document this app read.
editor.on("reloaded", (document) => {
windowTitle.setDocumentTitle(document.title);
selection = null;
showTitle();
if (!document.handedOver) {
console.warn("The editor was loaded again and could not hand over its document first: it shows the last saved state.");
}
saver?.reloaded(document.revision, { handedOver: document.handedOver });
});apps/electron-sample/main.mjs,第 271 至 283 行6. Export
TacoDesignView.exportLayers draws one layer or one whole page into a PNG, JPEG, SVG or PDF file through the editor’s own export, and resolves with a list that holds one file (TacoDesignExportedFile). The sample exports the selected layer and then asks where to save it, because the editor knows the file name that suits the layer:
const { nodes } = await editor.getSelection();
const [only] = nodes;
if (only === undefined) return { status: "nothing-selected" };
if (nodes.length === 1) {
// Exported first, because the editor knows the file name that suits the layer.
const [file] = await editor.exportLayers({ nodeId: only.id, format: "png", scale });
if (file === undefined) throw new Error("The editor answered the export without a file.");
const target = await chooseFile(file.fileName);
if (target === null) return { status: "cancelled" };
await writeFile(target, file.bytes);
return { status: "saved", files: [target] };
}apps/electron-sample/exportSelection.mjs,第 101 至 113 行TacoDesignExportOptions lists the choices: TacoDesignExportOptions.pageId instead of TacoDesignExportOptions.nodeId for a whole page, a scale or a fixed width or height, and so on. The bytes arrive as a Uint8Array; write them as they are. An export rejects with a TacoDesignError: export-too-large for a picture of more than 8192 x 8192 pixels, export-failed while fonts are still loading, and the other codes in TacoDesignErrorCode.
The same function goes on to export several selected layers one after another, so that only one picture is in memory at a time. In the sample, the menu item “Export selection as PNG…” calls it with Electron’s save dialogs.
7. Show start-up progress
While the editor starts it shows a slim progress bar of its own, and it tells your app the same thing with the startupProgress event (TacoDesignStartupProgress). The sample writes it in the window title:
// How far the editor's start-up is, in the window title. The last report
// (fraction 1) arrives before `ready` resolves. A start that fails sends no
// last report and rejects `ready` instead, so the title stops showing
// progress when `ready` settles, either way (see the end of this function).
editor.on("startupProgress", (progress) => {
windowTitle.startupProgress(progress);
showTitle();
});apps/electron-sample/main.mjs,第 170 至 177 行To draw the progress yourself, in a splash screen for example, pass startupProgress: "host" to createTacoDesignView; the editor then shows no bar of its own. The events arrive in every case.
- Show
labelto people. Later versions may add steps, so do not switch onstep. fractionnever goes down within one page load. The last report is{ fraction: 1, step: "done" }and arrives beforereadyresolves.- A start that fails sends no last report:
readyrejects instead. So end your splash screen whenreadysettles, as the code in step 3 does on both paths, and not only atfraction === 1.
8. Keyboard and menus
While the editor has the keyboard, the editor’s shortcuts are the editor’s: Cmd+Z undoes on the canvas, Cmd+C and Cmd+V copy and paste layers, Cmd+D duplicates, the letter keys choose tools. (Cmd is Ctrl on Windows and Linux.) That holds even when an item of your app’s menu has an accelerator on the same keys. Your app decides three things: which keys a command has, who gets a key that your menu wants too, and what the application menu looks like.
Change or switch off a shortcut. Each editor command has a stable ID, such as edit.duplicate; TacoDesignView.getShortcuts returns the list (ShortcutCommandInfo), and the package’s README has it as a table. Changes are one object (ShortcutOverrides): a command ID mapped to its new keys, to a list of keys, or to null for off. Keys are written like Electron accelerators. The sample has one change, which moves Duplicate:
/** The override the sample's "Duplicate on Cmd/Ctrl+Shift+D" item switches on. */
export const DUPLICATE_REMAP = /** @type {const} */ ({ "edit.duplicate": "CmdOrCtrl+Shift+D" });apps/electron-sample/appMenu.mjs,第 30 至 31 行Pass the object as shortcuts when you make the view, or call TacoDesignView.setShortcuts later. It is always the whole set of your changes, so {} puts every command back on its default keys. The view object keeps the changes and gives them to the page again after a reload; store them in your own settings if they should outlast the app. When something is wrong nothing is applied, and the call rejects with a TacoDesignError whose path is the command ID (unknown-shortcut-command, invalid-shortcut-keys, shortcut-keys-in-use, shortcut-not-customizable).
Choose who gets a key your menu wants too. menuShortcutConflict says what happens to a key press that runs an editor command and is also the accelerator of an enabled, visible item of your application menu (MenuShortcutConflict). "editor", the default, runs the editor’s command and not the menu item. "app" runs the menu item, and the editor does not see the key. "both" runs the menu item first and then the editor’s command. The sample reads both settings from its command line, to make them easy to try:
const startConflict = conflictFromArguments();
// `--remap-duplicate` starts with the editor's Duplicate on Shift+Cmd/Ctrl+D.
const startRemapped = process.argv.includes("--remap-duplicate");apps/electron-sample/main.mjs,第 108 至 110 行const editor = createTacoDesignView({
document: startDocument,
startupProgress: process.argv.includes("--host-progress") ? "host" : "editor",
menuShortcutConflict: startConflict,
shortcuts: startRemapped ? DUPLICATE_REMAP : {},
});apps/electron-sample/main.mjs,第 136 至 141 行A key that is no editor command is never touched: the menu handles it as in any Electron app. Nothing is done either while a text field or text on the canvas has the keyboard, because Cmd+Z, Cmd+C, Cmd+X, Cmd+V and Cmd+A must reach the text; Save is the one exception. Paste is never touched.
Answer Save. The editor saves nothing itself. Cmd+S in the editor arrives as the saveRequested event. Call the same function from it and from your menu’s Save item:
/** Cmd/Ctrl+S and the menu's Save. A failure is reported by the model (`onSaveFailed`) and shown in the title. */
const saveNow = () => void saver?.save().catch(() => undefined);apps/electron-sample/main.mjs,第 239 至 240 行// Cmd/Ctrl+S in the editor. The editor saves nothing itself.
editor.on("saveRequested", saveNow);apps/electron-sample/main.mjs,第 260 至 261 行With "editor" and "app" exactly one of the two runs for one key press. With "both" the function would run twice, so switch file.save off then (shortcuts: { "file.save": null }).
Build the application menu. The sample’s menu is in appMenu.mjs. Its File, Edit and View items are these:
{
label: "File",
submenu: [
{ id: SAVE_MENU_ID, label: "Save", accelerator: "CmdOrCtrl+S", click: () => options.save() },
{ type: "separator" },
mac ? { role: "close" } : { role: "quit" },
],
},
{
label: "Edit",
submenu: [
{ id: UNDO_MENU_ID, label: "Undo", accelerator: "CmdOrCtrl+Z", click: history("undo") },
{ id: REDO_MENU_ID, label: "Redo", accelerator: "Shift+CmdOrCtrl+Z", click: history("redo") },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
// No zoom roles here: they would scale the whole view.
{ label: "View", submenu: [{ role: "togglefullscreen" }] },apps/electron-sample/appMenu.mjs,第 89 至 110 行- Undo and Redo are plain items, not roles. The
undorole runs the page’s text undo, which does nothing on the canvas. The items callTacoDesignView.undoandTacoDesignView.redowhen the canvas or a panel has the keyboard, and do what the role does when a text field has it. - Cut, Copy, Paste and Select All are roles. Text fields need them, and on macOS paste in the editor needs the
pasterole to exist. - There are no zoom roles.
zoomIn,zoomOutandresetZoomscale the whole view. Cmd with plus, minus and 0 zoom the canvas. - No
reloadrole with an accelerator. Cmd+R is Rename in the editor, and a reload that does not go throughTacoDesignView.reloadloses the edits since your last read.
To know what Undo should do, the sample remembers what the focus event said last (FocusSummary):
/** @type {"canvas" | "text" | "other" | null} */
let editorFocus = null;
editor.on("focus", (focus) => {
editorFocus = focus.target;
});apps/electron-sample/main.mjs,第 264 至 268 行/**
* Whether Undo and Redo belong to the editor right now: its canvas or one of
* its panels has the keyboard. With a text field ("text"), or with the
* keyboard outside the editor (null), they belong to whatever has it.
*
* @param {"canvas" | "text" | "other" | null} focusTarget What the editor's last `focus` event said.
*/
export function editorOwnsUndo(focusTarget) {
return focusTarget === "canvas" || focusTarget === "other";
}apps/electron-sample/appMenu.mjs,第 35 至 44 行/** @param {"undo" | "redo"} command */
const history = (command) => () => {
if (!editorOwnsUndo(options.editorFocus())) options.runTextCommand(command);
else if (command === "undo") options.undoInEditor();
else options.redoInEditor();
};apps/electron-sample/appMenu.mjs,第 67 至 72 行main.mjs hands the menu what it cannot do itself, and sets it as the application menu. setConflict and setDuplicateRemapped belong to the sample’s “Shortcuts” menu, which changes the two settings while the app runs (TacoDesignView.setMenuShortcutConflict, TacoDesignView.setShortcuts):
let appCommandRuns = 0;
Menu.setApplicationMenu(Menu.buildFromTemplate(buildAppMenuTemplate({
platform: process.platform,
editorFocus: () => editorFocus,
undoInEditor: () => void editor.undo().catch((error) => console.error("Undo failed:", error)),
redoInEditor: () => void editor.redo().catch((error) => console.error("Redo failed:", error)),
// What the `undo` and `redo` roles do: the focused page's text command,
// or on macOS the focused native text field's.
runTextCommand: (command) => {
const focused = webContents.getFocusedWebContents();
if (focused !== null) focused[command]();
else if (process.platform === "darwin") Menu.sendActionToFirstResponder(`${command}:`);
},
save: saveNow,
conflict: startConflict,
setConflict: (conflict) => {
editor.setMenuShortcutConflict(conflict);
console.log(`Keys that are an editor shortcut and a menu shortcut: ${conflict}.`);
},
duplicateRemapped: startRemapped,
setDuplicateRemapped: (remapped) => {
editor.setShortcuts(remapped ? DUPLICATE_REMAP : {}).then(
(commands) => console.log(`Duplicate is on ${commands.find((command) => command.id === "edit.duplicate")?.keys.join(", ")}.`),
(error) => console.error("The shortcut could not be changed:", error),
);
},
runAppCommand: () => {
appCommandRuns += 1;
console.log(`The app's own command ran (${appCommandRuns}).`);
void dialog.showMessageBox(window, {
type: "info",
message: "The app's own command ran.",
detail: `It is on the same keys as the editor's Duplicate. Times it ran: ${appCommandRuns}.`,
});
},
})));apps/electron-sample/main.mjs,第 344 至 379 行Start the sample with --conflict=app or --remap-duplicate to see the settings from the first key press. The package’s README says how each setting is carried out in Electron, which keys can be named, and what was not tried on Windows and Linux.
9. Close safely
The page has no “unsaved changes” guard, and it stays editable while you save. So a window with a file may close only after the document was read one last time:
// The page has no "unsaved changes" guard of its own, so a window with a
// file closes only through its save model: once the document is on disk,
// or once the person chose to lose the changes. Quitting asks every window
// to close and so takes the same way; an app with several windows gives
// each its own model.
window.on("close", (event) => {
if (saver === null || saver.closed()) return;
event.preventDefault();
void saver.requestClose();
});apps/electron-sample/main.mjs,第 437 至 446 行TacoDesignView.dispose closes the view’s page. Call it when the window is gone; a WebContentsView does not close its page on its own.
What “the document is on disk” means is in saveModel.mjs. Read the document and write it, then read again. If nothing is newer, close at once, in the same turn of the event loop as that answer, so that no edit can arrive in between. If the document is newer, an edit was made during the write: write that one and read again. The same loop runs before the sample goes over to another file (step 4).
/**
* Reads, and writes what it read, until a read finds the document that is
* on disk. Then it closes with no `await` in between, so no edit can
* arrive between that answer and the close.
*/
const saveUntilNothingIsNewerThenClose = async () => {
if (hasClosed) return;
const to = file;
if (to === null) {
closeNow();
return;
}
await saveUntilNothingIsNewer(to);
closeNow();
};
/**
* Resolves, with the revision read, in the same turn as the answer of a
* read that found the document that is on disk. Whoever awaits this is
* resumed before any other event of this process is handled, so what it
* does next without an `await` of its own follows that answer directly.
* @param {File} to
*/
const saveUntilNothingIsNewer = async (to) => {
stopTimers();
try {
for (let writes = 0; ; writes += 1) {
const documentRead = await read();
if (documentRead.name === onDisk) {
worked();
return documentRead.revision;
}
if (writes === MOST_WRITES_FOR_ONE_CLOSE) {
throw new Error(
`The document was changed during each of the last ${MOST_WRITES_FOR_ONE_CLOSE} saves, so the newest changes are not saved yet.`,
);
}
await writeToDisk(documentRead, to);
}
} catch (error) {
failed(error);
throw error;
}
};apps/electron-sample/saveModel.mjs,第 380 至 422 行When a read or a write fails, keep the window open and ask the person what to do (try again, close without saving, or cancel), because the view holds the only copy of the newest edits.
Where to go next
- The reference of undefined lists every option, call, event and error code.
- The package’s README says what does not work yet, and what the scheme serves.
apps/electron-sampleis the whole app this page is made from. Start it withpnpm --filter @taco-design/electron-sample start -- drawing.json.
Read and plan without editing the document
Discover const capability = (await editor.getState()).design and require capability.versions.includes(DESIGN_PROTOCOL_VERSION). Then bind a client once:
const capability = (await editor.getState()).design;
if (!capability?.versions.includes(DESIGN_PROTOCOL_VERSION)) throw new Error("This editor does not advertise the supported design protocol.");
const call = createDesignClient(request => editor.designRequest(request, { timeoutMs: 10_000 }), capability.binding, randomUUID);
console.log(await call("protocol.describe", {}));
const snapshot = await call("document.inspect", {});
console.log(snapshot);packages/electron/examples/design-read.mjs,第 18 至 23 行Import createDesignClient and DESIGN_PROTOCOL_VERSION from the Electron package. Continue with nextCursor at the same snapshotId for pagination. The packaged dist/examples/design-read.mjs includes an optional duplicate/translate plan and can run with Electron against a supplied document. No public writes or ownership are enabled. Geometry reads support only mode: "stored".
DesignError reports catalog, capability and domain errors with code, path and committed: false; TacoDesignError reports startup or transport errors. The raw designRequest preserves domain failures as error replies. A timeout or abort can stop waiting but does not undo a remotely completed plan. No call silently retries against a new opening: after reload or stale-binding failure, discover and bind again.
Listen with editor.on("design", event => { ... }) and call its returned function to unsubscribe. Events identify a binding and revision; they invalidate cached reads and do not form a resumable synchronization log.