fix(desktop): capture the current screen into a note with the image
One click from the tray now hides EdgeEver, grabs the full screen, and creates a note whose body already contains the screenshot. IPC restores PNG bytes that Electron serializes as Buffer JSON, and a failed upload deletes the empty placeholder instead of leaving a blank note.
This commit is contained in:
1 parent
cbedbd5c8c
commit
b05cebec8c
19 files changed
+552
-320
No files matched your search
@@ -17,7 +17,7 @@ const menuCopy = {
|
||||
resetZoom: "Reset Zoom",
|
||||
restartToUpdate: "Restart to update",
|
||||
screenshotFailed: "Couldn't capture a screenshot. If this is the first time, allow Screen Recording for EdgeEver in System Settings.",
|
||||
screenshotToNote: "Save screenshot to note",
|
||||
screenshotToNote: "Capture screen to note",
|
||||
selectAll: "Select All",
|
||||
show: "Show EdgeEver",
|
||||
toggleFocusMode: "Toggle Focus Mode",
|
||||
@@ -48,7 +48,7 @@ const menuCopy = {
|
||||
resetZoom: "重置缩放",
|
||||
restartToUpdate: "重启以更新",
|
||||
screenshotFailed: "截图失败。若是第一次使用,请在系统设置中允许 EdgeEver 使用屏幕录制。",
|
||||
screenshotToNote: "截图并保存到笔记",
|
||||
screenshotToNote: "截取屏幕到笔记",
|
||||
selectAll: "全选",
|
||||
show: "显示 EdgeEver",
|
||||
toggleFocusMode: "切换专注模式",
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("desktop native menu localization", () => {
|
||||
expect(copy.view).toBe("视图");
|
||||
expect(copy.window).toBe("窗口");
|
||||
expect(copy.restartToUpdate).toBe("重启以更新");
|
||||
expect(copy.screenshotToNote).toBe("截图并保存到笔记");
|
||||
expect(copy.screenshotToNote).toBe("截取屏幕到笔记");
|
||||
});
|
||||
|
||||
test("recognizes other Chinese locale variants", () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { isAllowedPrintPreviewUrl } from "./window-open-policy.mjs";
|
||||
import { showWindow } from "./window-visibility.mjs";
|
||||
import { trayIconPath } from "./tray-icon.mjs";
|
||||
import { writeRichClipboard } from "./clipboard-write.mjs";
|
||||
import { captureInteractiveScreenshot, writeScreenshotTempPath } from "./screenshot-capture.mjs";
|
||||
import { captureScreenToNote, screenshotImportIpcPayload, writeScreenshotTempPath } from "./screenshot-capture.mjs";
|
||||
import { LocalDataResetError, scheduleMacLocalDataReset } from "./local-data-reset.mjs";
|
||||
import { buildDesktopDiagnosticIssueUrl, normalizeDesktopDiagnostic } from "./desktop-diagnostics.mjs";
|
||||
import { createRendererStartupGuard } from "./renderer-startup-guard.mjs";
|
||||
@@ -519,17 +519,20 @@ const flushPendingMarkdownImport = () => {
|
||||
};
|
||||
|
||||
const sendScreenshotImport = (payload) => {
|
||||
const ipcPayload = screenshotImportIpcPayload(payload);
|
||||
if (!ipcPayload.bytes.byteLength) return;
|
||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoading() || !rendererReady) {
|
||||
pendingScreenshotImport = payload;
|
||||
pendingScreenshotImport = ipcPayload;
|
||||
return;
|
||||
}
|
||||
mainWindow.webContents.send("desktop:import-screenshot", payload);
|
||||
mainWindow.webContents.send("desktop:import-screenshot", ipcPayload);
|
||||
};
|
||||
|
||||
const flushPendingScreenshotImport = () => {
|
||||
if (!pendingScreenshotImport || !rendererReady || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
const payload = pendingScreenshotImport;
|
||||
pendingScreenshotImport = null;
|
||||
if (!payload.bytes?.byteLength) return;
|
||||
mainWindow.webContents.send("desktop:import-screenshot", payload);
|
||||
};
|
||||
|
||||
@@ -546,16 +549,12 @@ const captureScreenshotToNote = async () => {
|
||||
if (process.platform === "darwin") app.hide();
|
||||
else if (wasVisible) mainWindow.hide();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const captured = await captureInteractiveScreenshot({
|
||||
const captured = await captureScreenToNote({
|
||||
platform: process.platform,
|
||||
locale: app.getLocale(),
|
||||
outputPath: process.platform === "darwin" ? writeScreenshotTempPath(app.getPath("temp")) : undefined,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
screen,
|
||||
ipcMain,
|
||||
overlayHtmlPath: join(currentDirectory, "../screenshot/overlay.html"),
|
||||
overlayPreloadPath: join(currentDirectory, "../screenshot/overlay-preload.cjs"),
|
||||
});
|
||||
if (!captured) {
|
||||
if (wasVisible) revealWindow();
|
||||
|
||||
@@ -6,9 +6,36 @@ import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFileCallback);
|
||||
const MAC_SCREENCAPTURE = "/usr/sbin/screencapture";
|
||||
const MIN_CROP_EDGE = 4;
|
||||
|
||||
export const macScreencaptureArgs = (outputPath) => ["-i", "-x", "-t", "png", outputPath];
|
||||
export const macScreencaptureArgs = (outputPath) => ["-x", "-t", "png", outputPath];
|
||||
|
||||
export const normalizeIpcBytes = (value) => {
|
||||
if (value instanceof Uint8Array) {
|
||||
const copy = new Uint8Array(value.byteLength);
|
||||
copy.set(value);
|
||||
return copy;
|
||||
}
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return new Uint8Array(value.slice(0));
|
||||
}
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Uint8Array.from(value);
|
||||
}
|
||||
if (value && typeof value === "object" && value.type === "Buffer") {
|
||||
return normalizeIpcBytes(value.data);
|
||||
}
|
||||
return new Uint8Array();
|
||||
};
|
||||
|
||||
export const screenshotImportIpcPayload = (captured) => ({
|
||||
name: captured.name,
|
||||
type: captured.type,
|
||||
title: captured.title,
|
||||
bytes: normalizeIpcBytes(captured.bytes),
|
||||
});
|
||||
|
||||
export const isChineseDesktopLocale = (locale) =>
|
||||
typeof locale === "string" && locale.toLowerCase().startsWith("zh");
|
||||
@@ -29,24 +56,6 @@ export const isScreenshotCancelledExit = (error) => {
|
||||
return code === 1 || code === 2;
|
||||
};
|
||||
|
||||
export const clampCropRect = (rect, imageSize) => {
|
||||
const x = Math.max(0, Math.round(Number(rect?.x) || 0));
|
||||
const y = Math.max(0, Math.round(Number(rect?.y) || 0));
|
||||
const width = Math.max(0, Math.round(Number(rect?.width) || 0));
|
||||
const height = Math.max(0, Math.round(Number(rect?.height) || 0));
|
||||
const maxWidth = Math.max(0, Math.round(Number(imageSize?.width) || 0) - x);
|
||||
const maxHeight = Math.max(0, Math.round(Number(imageSize?.height) || 0) - y);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.min(width, maxWidth),
|
||||
height: Math.min(height, maxHeight),
|
||||
};
|
||||
};
|
||||
|
||||
export const isUsableCropRect = (rect) =>
|
||||
Boolean(rect) && rect.width >= MIN_CROP_EDGE && rect.height >= MIN_CROP_EDGE;
|
||||
|
||||
export const readCapturedScreenshot = async (outputPath, io = { readFile, unlink, stat }) => {
|
||||
try {
|
||||
const info = await io.stat(outputPath);
|
||||
@@ -63,7 +72,7 @@ export const readCapturedScreenshot = async (outputPath, io = { readFile, unlink
|
||||
}
|
||||
};
|
||||
|
||||
export const captureMacInteractiveScreenshot = async ({
|
||||
export const captureMacScreenScreenshot = async ({
|
||||
execFile = execFileAsync,
|
||||
outputPath,
|
||||
readFile: readCapturedFile = readFile,
|
||||
@@ -101,26 +110,7 @@ const matchCaptureSource = (sources, display) => {
|
||||
return sources.find((source) => source && String(source.display_id) === displayId) || sources[0];
|
||||
};
|
||||
|
||||
export const cropCapturedImage = (image, rect, scaleFactor = 1) => {
|
||||
const size = image?.getSize?.() || { width: 0, height: 0 };
|
||||
const crop = clampCropRect({
|
||||
x: (Number(rect?.x) || 0) * scaleFactor,
|
||||
y: (Number(rect?.y) || 0) * scaleFactor,
|
||||
width: (Number(rect?.width) || 0) * scaleFactor,
|
||||
height: (Number(rect?.height) || 0) * scaleFactor,
|
||||
}, size);
|
||||
if (!isUsableCropRect(crop)) return null;
|
||||
return toPngBytes(image.crop(crop));
|
||||
};
|
||||
|
||||
export const captureRegionScreenshot = async ({
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
screen,
|
||||
ipcMain,
|
||||
overlayHtmlPath,
|
||||
overlayPreloadPath,
|
||||
}) => {
|
||||
export const captureDisplayScreenshot = async ({ desktopCapturer, screen }) => {
|
||||
const point = screen.getCursorScreenPoint();
|
||||
const display = screen.getDisplayNearestPoint(point);
|
||||
const thumbnailSize = displayThumbnailSize(display);
|
||||
@@ -136,76 +126,17 @@ export const captureRegionScreenshot = async ({
|
||||
|
||||
const screenshotBytes = toPngBytes(thumbnail);
|
||||
if (!screenshotBytes) throw new Error("Screen capture is unavailable");
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const completeChannel = "desktop:screenshot-overlay-complete";
|
||||
const cancelChannel = "desktop:screenshot-overlay-cancel";
|
||||
let settled = false;
|
||||
const overlay = new BrowserWindow({
|
||||
x: display.bounds.x,
|
||||
y: display.bounds.y,
|
||||
width: display.bounds.width,
|
||||
height: display.bounds.height,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
hiddenInMissionControl: true,
|
||||
webPreferences: {
|
||||
preload: overlayPreloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
overlay.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
overlay.setAlwaysOnTop(true, "screen-saver");
|
||||
|
||||
const finish = (result, error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
ipcMain.removeListener(completeChannel, onComplete);
|
||||
ipcMain.removeListener(cancelChannel, onCancel);
|
||||
if (!overlay.isDestroyed()) overlay.close();
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
};
|
||||
|
||||
const onComplete = (_event, rect) => {
|
||||
try {
|
||||
finish(cropCapturedImage(thumbnail, rect, display.scaleFactor || 1));
|
||||
} catch (error) {
|
||||
finish(null, error);
|
||||
}
|
||||
};
|
||||
const onCancel = () => finish(null);
|
||||
|
||||
ipcMain.on(completeChannel, onComplete);
|
||||
ipcMain.on(cancelChannel, onCancel);
|
||||
overlay.on("closed", () => finish(null));
|
||||
overlay.webContents.once("did-finish-load", () => {
|
||||
overlay.webContents.send("desktop:screenshot-overlay-init", { bytes: screenshotBytes });
|
||||
overlay.show();
|
||||
overlay.focus();
|
||||
});
|
||||
overlay.loadFile(overlayHtmlPath).catch((error) => finish(null, error));
|
||||
});
|
||||
return screenshotBytes;
|
||||
};
|
||||
|
||||
export const captureInteractiveScreenshot = async (input) => {
|
||||
export const captureScreenToNote = async (input) => {
|
||||
const bytes = input.platform === "darwin"
|
||||
? await captureMacInteractiveScreenshot(input)
|
||||
: await captureRegionScreenshot(input);
|
||||
if (!bytes) return null;
|
||||
? await captureMacScreenScreenshot(input)
|
||||
: await captureDisplayScreenshot(input);
|
||||
if (!bytes || bytes.byteLength === 0) return null;
|
||||
const capturedAt = input.now ? new Date(input.now) : new Date();
|
||||
return {
|
||||
bytes,
|
||||
bytes: normalizeIpcBytes(bytes),
|
||||
name: screenshotFileName(capturedAt),
|
||||
type: "image/png",
|
||||
title: screenshotNoteTitle(input.locale, capturedAt),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
clampCropRect,
|
||||
cropCapturedImage,
|
||||
captureDisplayScreenshot,
|
||||
isScreenshotCancelledExit,
|
||||
isUsableCropRect,
|
||||
macScreencaptureArgs,
|
||||
normalizeIpcBytes,
|
||||
readCapturedScreenshot,
|
||||
screenshotFileName,
|
||||
screenshotImportIpcPayload,
|
||||
screenshotNoteTitle,
|
||||
} from "./screenshot-capture.mjs";
|
||||
|
||||
@@ -16,8 +16,8 @@ const preloadSource = readFileSync(new URL("../preload/index.cjs", import.meta.u
|
||||
const workspaceSource = readFileSync(new URL("../../../web/src/components/WorkspaceApp.tsx", import.meta.url), "utf8");
|
||||
|
||||
describe("screenshot capture helpers", () => {
|
||||
test("uses macOS interactive capture without a shutter sound", () => {
|
||||
expect(macScreencaptureArgs("/tmp/capture.png")).toEqual(["-i", "-x", "-t", "png", "/tmp/capture.png"]);
|
||||
test("captures the full screen on macOS without an interactive selection", () => {
|
||||
expect(macScreencaptureArgs("/tmp/capture.png")).toEqual(["-x", "-t", "png", "/tmp/capture.png"]);
|
||||
});
|
||||
|
||||
test("treats screencapture cancel exit codes as a quiet abort", () => {
|
||||
@@ -26,30 +26,30 @@ describe("screenshot capture helpers", () => {
|
||||
expect(isScreenshotCancelledExit({ code: "ENOENT" })).toBe(false);
|
||||
});
|
||||
|
||||
test("clamps and rejects tiny crop rectangles", () => {
|
||||
expect(clampCropRect({ x: -8, y: 2.8, width: 40, height: 12 }, { width: 20, height: 10 })).toEqual({
|
||||
x: 0,
|
||||
y: 3,
|
||||
width: 20,
|
||||
height: 7,
|
||||
});
|
||||
expect(isUsableCropRect({ width: 3, height: 40 })).toBe(false);
|
||||
expect(isUsableCropRect({ width: 8, height: 8 })).toBe(true);
|
||||
});
|
||||
|
||||
test("crops a captured image in physical pixels", () => {
|
||||
const cropped = [];
|
||||
const image = {
|
||||
getSize: () => ({ width: 200, height: 100 }),
|
||||
crop: (rect) => {
|
||||
cropped.push(rect);
|
||||
return { toPNG: () => Buffer.from("png") };
|
||||
test("captures the display under the cursor without a crop overlay", async () => {
|
||||
const bytes = await captureDisplayScreenshot({
|
||||
screen: {
|
||||
getCursorScreenPoint: () => ({ x: 10, y: 10 }),
|
||||
getDisplayNearestPoint: () => ({
|
||||
id: 2,
|
||||
size: { width: 100, height: 50 },
|
||||
scaleFactor: 2,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
expect(cropCapturedImage(image, { x: 10, y: 5, width: 20, height: 10 }, 2)).toEqual(Buffer.from("png"));
|
||||
expect(cropped).toEqual([{ x: 20, y: 10, width: 40, height: 20 }]);
|
||||
expect(cropCapturedImage(image, { x: 0, y: 0, width: 1, height: 1 }, 1)).toBeNull();
|
||||
desktopCapturer: {
|
||||
getSources: async (options) => {
|
||||
expect(options).toEqual({ types: ["screen"], thumbnailSize: { width: 200, height: 100 } });
|
||||
return [{
|
||||
display_id: "2",
|
||||
thumbnail: {
|
||||
isEmpty: () => false,
|
||||
toPNG: () => Buffer.from("png"),
|
||||
},
|
||||
}];
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(bytes).toEqual(Buffer.from("png"));
|
||||
});
|
||||
|
||||
test("returns null when the user cancelled before a file was written", async () => {
|
||||
@@ -71,16 +71,33 @@ describe("screenshot capture helpers", () => {
|
||||
expect(screenshotNoteTitle("en-US", date)).toBe("Screenshot 2026-09-14 11:08");
|
||||
expect(screenshotFileName(date)).toBe("screenshot-20260914-110803.png");
|
||||
});
|
||||
|
||||
test("restores Electron Buffer JSON instead of constructing an empty typed array", () => {
|
||||
const encoded = { type: "Buffer", data: [137, 80, 78, 71] };
|
||||
expect(new Uint8Array(encoded).byteLength).toBe(0);
|
||||
expect(Array.from(normalizeIpcBytes(encoded))).toEqual([137, 80, 78, 71]);
|
||||
expect(Array.from(screenshotImportIpcPayload({
|
||||
name: "screenshot.png",
|
||||
type: "image/png",
|
||||
title: "截图",
|
||||
bytes: Buffer.from([1, 2, 3]),
|
||||
}).bytes)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop screenshot to note wiring", () => {
|
||||
test("replaces the tray sync and backup actions with screenshot capture", () => {
|
||||
expect(mainSource).toContain("captureScreenshotToNote");
|
||||
expect(mainSource).toContain("captureScreenToNote");
|
||||
expect(mainSource).not.toContain("overlay.html");
|
||||
expect(mainSource).toContain("copy.screenshotToNote");
|
||||
expect(mainSource).not.toContain('label: copy.syncNow, click: () => sendDesktopCommand("sync-now")');
|
||||
expect(mainSource).not.toContain('label: copy.backupNow, click: () => sendDesktopCommand("backup-now")');
|
||||
expect(preloadSource).toContain("onImportScreenshot");
|
||||
expect(preloadSource).toContain('value.type === "Buffer"');
|
||||
expect(workspaceSource).toContain("onImportScreenshot");
|
||||
expect(workspaceSource).toContain("setPendingEditorInsert");
|
||||
expect(workspaceSource).toContain("createScreenshotMemo");
|
||||
expect(workspaceSource).toContain("screenshotFileFromImportPayload");
|
||||
expect(workspaceSource).not.toContain("setPendingEditorInsert");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,26 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
const normalizeIpcBytes = (value) => {
|
||||
if (value instanceof Uint8Array) {
|
||||
const copy = new Uint8Array(value.byteLength);
|
||||
copy.set(value);
|
||||
return copy;
|
||||
}
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return new Uint8Array(value.slice(0));
|
||||
}
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Uint8Array.from(value);
|
||||
}
|
||||
if (value && typeof value === "object" && value.type === "Buffer") {
|
||||
return normalizeIpcBytes(value.data);
|
||||
}
|
||||
return new Uint8Array();
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
|
||||
isAvailable: true,
|
||||
canClearLocalData: ipcRenderer.sendSync("desktop:local-data-reset-available-sync"),
|
||||
@@ -66,10 +87,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
|
||||
},
|
||||
onImportScreenshot: (callback) => {
|
||||
const listener = (_event, payload) => {
|
||||
const bytes = payload?.bytes instanceof Uint8Array
|
||||
? payload.bytes
|
||||
: new Uint8Array(payload?.bytes || []);
|
||||
callback({ ...payload, bytes });
|
||||
callback({ ...payload, bytes: normalizeIpcBytes(payload?.bytes) });
|
||||
};
|
||||
ipcRenderer.on("desktop:import-screenshot", listener);
|
||||
return () => ipcRenderer.removeListener("desktop:import-screenshot", listener);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("edgeeverScreenshot", Object.freeze({
|
||||
onInit: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on("desktop:screenshot-overlay-init", listener);
|
||||
return () => ipcRenderer.removeListener("desktop:screenshot-overlay-init", listener);
|
||||
},
|
||||
complete: (rect) => ipcRenderer.send("desktop:screenshot-overlay-complete", rect),
|
||||
cancel: () => ipcRenderer.send("desktop:screenshot-overlay-cancel"),
|
||||
}));
|
||||
@@ -1,107 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>EdgeEver Screenshot</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
background: #000;
|
||||
user-select: none;
|
||||
}
|
||||
#shot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#rect {
|
||||
position: absolute;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img id="shot" alt="" draggable="false" />
|
||||
<div id="rect"></div>
|
||||
<script>
|
||||
const api = window.edgeeverScreenshot;
|
||||
const shot = document.getElementById("shot");
|
||||
const rect = document.getElementById("rect");
|
||||
let origin = null;
|
||||
let objectUrl = null;
|
||||
|
||||
const toBytes = (value) => {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
if (value && value.type === "Buffer" && Array.isArray(value.data)) return new Uint8Array(value.data);
|
||||
return new Uint8Array();
|
||||
};
|
||||
|
||||
const selection = (event) => {
|
||||
const x = Math.min(origin.x, event.clientX);
|
||||
const y = Math.min(origin.y, event.clientY);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.abs(event.clientX - origin.x),
|
||||
height: Math.abs(event.clientY - origin.y),
|
||||
};
|
||||
};
|
||||
|
||||
api.onInit((payload) => {
|
||||
const bytes = toBytes(payload && payload.bytes);
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = URL.createObjectURL(new Blob([bytes], { type: "image/png" }));
|
||||
shot.src = objectUrl;
|
||||
});
|
||||
|
||||
window.addEventListener("mousedown", (event) => {
|
||||
if (event.button !== 0) {
|
||||
api.cancel();
|
||||
return;
|
||||
}
|
||||
origin = { x: event.clientX, y: event.clientY };
|
||||
rect.style.display = "block";
|
||||
rect.style.left = `${origin.x}px`;
|
||||
rect.style.top = `${origin.y}px`;
|
||||
rect.style.width = "0px";
|
||||
rect.style.height = "0px";
|
||||
});
|
||||
|
||||
window.addEventListener("mousemove", (event) => {
|
||||
if (!origin) return;
|
||||
const next = selection(event);
|
||||
rect.style.left = `${next.x}px`;
|
||||
rect.style.top = `${next.y}px`;
|
||||
rect.style.width = `${next.width}px`;
|
||||
rect.style.height = `${next.height}px`;
|
||||
});
|
||||
|
||||
window.addEventListener("mouseup", (event) => {
|
||||
if (!origin) return;
|
||||
const next = selection(event);
|
||||
origin = null;
|
||||
api.complete(next);
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") api.cancel();
|
||||
});
|
||||
|
||||
window.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
api.cancel();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -246,6 +246,11 @@ import {
|
||||
resolveEditorDraftState,
|
||||
shouldReplaceEditorDocument,
|
||||
} from "./editor/editor-draft-state";
|
||||
import {
|
||||
pendingEditorInsertMatchesMemo,
|
||||
shouldInsertPendingEditorFiles,
|
||||
usablePendingInsertFiles,
|
||||
} from "./editor/editor-pending-insert";
|
||||
import type { EdgeEverPluginHost, PluginEditorAdapter } from "@/lib/plugins/plugin-host";
|
||||
import {
|
||||
useEditorResourceActions,
|
||||
@@ -997,7 +1002,7 @@ const RichEditorPane = ({
|
||||
const currentEditor = editorRef.current;
|
||||
|
||||
if (!currentMemo || currentMemo.isDeleted || !currentEditor || !currentEditor.isEditable || files.length === 0) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetMemoId = currentMemo.id;
|
||||
@@ -1171,6 +1176,7 @@ const RichEditorPane = ({
|
||||
removeImageUploadPlaceholder(placeholderEditor, placeholder);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}, [queryClient, repository, resourceInsertionLimit, t]);
|
||||
|
||||
const pluginEmbedExtension = useMemo(() => createPluginEmbedExtension(pluginHost), [pluginHost]);
|
||||
@@ -1757,12 +1763,40 @@ const RichEditorPane = ({
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingInsertFiles || pendingInsertFiles.memoId !== memo?.id) return;
|
||||
if (hydratedEditorMemoId !== memo.id) return;
|
||||
if (!isEditorReady(editor) || !editor.isEditable || pendingInsertFiles.files.length === 0) return;
|
||||
insertResourceFiles(pendingInsertFiles.files);
|
||||
if (!pendingInsertFiles || !pendingEditorInsertMatchesMemo(
|
||||
pendingInsertFiles,
|
||||
memo?.id,
|
||||
editorInstanceMemoIdentityRef.current.aliases,
|
||||
)) return;
|
||||
const files = usablePendingInsertFiles(pendingInsertFiles);
|
||||
if (files.length === 0) {
|
||||
onPendingInsertFilesConsumed?.();
|
||||
return;
|
||||
}
|
||||
if (!shouldInsertPendingEditorFiles({
|
||||
pendingInsertFiles,
|
||||
memoId: memo?.id,
|
||||
memoAliases: editorInstanceMemoIdentityRef.current.aliases,
|
||||
editorHydratedForMemo: editorIsHydratedForCurrentMemo,
|
||||
editorReady: isEditorReady(editor),
|
||||
readOnly: effectiveReadOnly,
|
||||
}) || !isEditorReady(editor)) return;
|
||||
// Hydration leaves the editor non-editable until a later effect calls
|
||||
// setEditable. That later effect does not re-render, so waiting on
|
||||
// editor.isEditable here would drop the screenshot forever.
|
||||
if (!editor.isEditable) editor.setEditable(true);
|
||||
if (!insertResourceFiles(files)) return;
|
||||
onPendingInsertFilesConsumed?.();
|
||||
}, [editor, hydratedEditorMemoId, insertResourceFiles, memo?.id, onPendingInsertFilesConsumed, pendingInsertFiles]);
|
||||
}, [
|
||||
editor,
|
||||
editorIsHydratedForCurrentMemo,
|
||||
effectiveReadOnly,
|
||||
hydratedEditorMemoId,
|
||||
insertResourceFiles,
|
||||
memo?.id,
|
||||
onPendingInsertFilesConsumed,
|
||||
pendingInsertFiles,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) {
|
||||
|
||||
@@ -117,6 +117,9 @@ import { createPublicNetworkAdapter } from "@/lib/plugins/public-network-adapter
|
||||
import { clearRendererRecoveryRequired, isRendererRecoveryRequired } from "@/lib/renderer-recovery";
|
||||
import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBoundary";
|
||||
import { isMarkdownFile, readMarkdownFile } from "@/lib/markdown-file-import";
|
||||
import { compressImageForUpload } from "@/lib/image-compression";
|
||||
import { createScreenshotMemo, screenshotFileFromImportPayload } from "@/lib/screenshot-import";
|
||||
import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources";
|
||||
|
||||
const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches;
|
||||
const PULL_TO_REFRESH_TRIGGER_PX = 72;
|
||||
@@ -752,7 +755,6 @@ export const WorkspaceApp = ({
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const autoSelectedDemoNotebookRef = useRef(false);
|
||||
const [createdMemoEditId, setCreatedMemoEditId] = useState<string | null>(null);
|
||||
const [pendingEditorInsert, setPendingEditorInsert] = useState<{ memoId: string; files: File[] } | null>(null);
|
||||
const pendingCreatedMemoIdRef = useRef<string | null>(null);
|
||||
const pendingQuickSwitcherMemoIdRef = useRef<string | null>(null);
|
||||
const creatingMemoSelectionRef = useRef(false);
|
||||
@@ -1729,6 +1731,39 @@ export const WorkspaceApp = ({
|
||||
},
|
||||
});
|
||||
|
||||
const revealCreatedMemo = (memo: MemoDetail) => {
|
||||
const targetNotebookId = memo.notebookId;
|
||||
const isDiagram = Boolean(parseDiagramDocument(memo.contentMarkdown));
|
||||
|
||||
setMemoView("notebook");
|
||||
setSearch("");
|
||||
setSelectedTag(null);
|
||||
// A newly created memo is not pinned or otherwise guaranteed to match
|
||||
// the active list filter. Leave filtered views so the selected memo
|
||||
// remains visible instead of the list effect falling back to its first
|
||||
// item (for example, the currently pinned memo).
|
||||
setMemoFilterMode("all");
|
||||
if (targetNotebookId !== selectedNotebookId) {
|
||||
setSelectedNotebookId(targetNotebookId);
|
||||
}
|
||||
cacheMemoDetail(queryClient, memo, "notebook");
|
||||
updateMemoSummaryInLists(queryClient, memoToSummary(memo));
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }),
|
||||
]);
|
||||
navigateWorkspaceHome();
|
||||
setRightView("editor");
|
||||
pendingCreatedMemoIdRef.current = memo.id;
|
||||
setCreatedMemoEditId(isDiagram ? null : memo.id);
|
||||
setSelectedMemoId(memo.id);
|
||||
setActivePane("editor");
|
||||
|
||||
if (!isDesktopViewport() && !isDiagram) {
|
||||
openStandaloneMobileEditor(memo.id);
|
||||
}
|
||||
};
|
||||
|
||||
const createMemoMutation = useMutation({
|
||||
mutationFn: async (input: Parameters<typeof repository.createMemo>[0]) => {
|
||||
const requiresRemoteMemo = requiresRemoteMemoForStandaloneMobileEditor({
|
||||
@@ -1741,36 +1776,7 @@ export const WorkspaceApp = ({
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const targetNotebookId = data.memo.notebookId;
|
||||
const isDiagram = Boolean(parseDiagramDocument(data.memo.contentMarkdown));
|
||||
|
||||
setMemoView("notebook");
|
||||
setSearch("");
|
||||
setSelectedTag(null);
|
||||
// A newly created memo is not pinned or otherwise guaranteed to match
|
||||
// the active list filter. Leave filtered views so the selected memo
|
||||
// remains visible instead of the list effect falling back to its first
|
||||
// item (for example, the currently pinned memo).
|
||||
setMemoFilterMode("all");
|
||||
if (targetNotebookId !== selectedNotebookId) {
|
||||
setSelectedNotebookId(targetNotebookId);
|
||||
}
|
||||
cacheMemoDetail(queryClient, data.memo, "notebook");
|
||||
updateMemoSummaryInLists(queryClient, memoToSummary(data.memo));
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }),
|
||||
]);
|
||||
navigateWorkspaceHome();
|
||||
setRightView("editor");
|
||||
pendingCreatedMemoIdRef.current = data.memo.id;
|
||||
setCreatedMemoEditId(isDiagram ? null : data.memo.id);
|
||||
setSelectedMemoId(data.memo.id);
|
||||
setActivePane("editor");
|
||||
|
||||
if (!isDesktopViewport() && !isDiagram) {
|
||||
openStandaloneMobileEditor(data.memo.id);
|
||||
}
|
||||
revealCreatedMemo(data.memo);
|
||||
},
|
||||
onError: () => {
|
||||
clearPendingCreatedMemo();
|
||||
@@ -2188,25 +2194,56 @@ export const WorkspaceApp = ({
|
||||
: defaultMemoNotebookId;
|
||||
if (!notebookId) return;
|
||||
|
||||
const source = payload.bytes instanceof Uint8Array ? payload.bytes : new Uint8Array(payload.bytes);
|
||||
const bytes = new Uint8Array(source.byteLength);
|
||||
bytes.set(source);
|
||||
const file = new File([bytes], payload.name || "screenshot.png", { type: payload.type || "image/png" });
|
||||
const file = screenshotFileFromImportPayload(payload);
|
||||
if (file.size === 0) {
|
||||
setAppNoticeDialog({
|
||||
title: t("memoList.importScreenshotFailedTitle"),
|
||||
description: t("memoList.importScreenshotEmpty"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setTemplatesOpen(false);
|
||||
setMobileBottomNavActive("home");
|
||||
creatingMemoSelectionRef.current = true;
|
||||
try {
|
||||
const data = await createMemoMutation.mutateAsync({
|
||||
const preparedFile = imageCompressionEnabled ? (await compressImageForUpload(file)).file : file;
|
||||
const memo = await createScreenshotMemo({
|
||||
notebookId,
|
||||
title: payload.title?.trim() || "",
|
||||
contentMarkdown: "",
|
||||
tags: [],
|
||||
file: preparedFile,
|
||||
createMemo: (input) => repository.createMemo(input),
|
||||
uploadResource: async (memoId, uploadFile) => {
|
||||
try {
|
||||
const { resource } = await repository.uploadMemoResource(memoId, uploadFile);
|
||||
return { url: toDesktopResourceUrl(resource.url), filename: resource.filename };
|
||||
} catch (error) {
|
||||
if (!isDesktopResourceRuntime()) throw error;
|
||||
const staged = await stageDesktopResource(memoId, uploadFile);
|
||||
if (!staged) throw error;
|
||||
return { url: `edgeever-staged://${staged.id}`, filename: uploadFile.name };
|
||||
}
|
||||
},
|
||||
updateMemo: (created, content) => repository.updateMemo(created, {
|
||||
expectedRevision: created.revision,
|
||||
expectedContentHash: created.contentHash,
|
||||
editSessionId: `screenshot:${created.id}`,
|
||||
title: created.title ?? "",
|
||||
contentJson: content.contentJson,
|
||||
contentMarkdown: content.contentMarkdown,
|
||||
tags: created.tags,
|
||||
}),
|
||||
deleteMemo: (memoId) => repository.deleteMemo(memoId, true),
|
||||
});
|
||||
setPendingEditorInsert({ memoId: data.memo.id, files: [file] });
|
||||
await putLocalMemo(localDataScope, memo);
|
||||
revealCreatedMemo(memo);
|
||||
} catch {
|
||||
creatingMemoSelectionRef.current = false;
|
||||
setAppNoticeDialog({
|
||||
title: t("memoList.importScreenshotFailedTitle"),
|
||||
description: t("memoList.importScreenshotFailed"),
|
||||
});
|
||||
}
|
||||
}, [createMemoMutation, defaultMemoNotebookId, memoView, notebooks, selectedNotebookId]);
|
||||
}, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]);
|
||||
|
||||
const handleCreateMemo = (kind?: DiagramKind) => {
|
||||
const targetNotebookId = createMemoNotebookId;
|
||||
@@ -3699,8 +3736,6 @@ export const WorkspaceApp = ({
|
||||
onToggleDesktopFocusMode={toggleDesktopFocusMode}
|
||||
editorContentAlignment={editorContentAlignment}
|
||||
mobileDefaultEditMemoId={createdMemoEditId}
|
||||
pendingInsertFiles={pendingEditorInsert}
|
||||
onPendingInsertFilesConsumed={() => setPendingEditorInsert(null)}
|
||||
isTrashView={memoView === "trash"}
|
||||
notebooks={notebooks}
|
||||
isLoading={memoQuery.isLoading}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
pendingEditorInsertMatchesMemo,
|
||||
shouldInsertPendingEditorFiles,
|
||||
usablePendingInsertFiles,
|
||||
} from "./editor-pending-insert.ts";
|
||||
|
||||
const file = (size) => ({ size });
|
||||
|
||||
describe("pending editor screenshot insert", () => {
|
||||
test("matches a remapped memo id through aliases", () => {
|
||||
expect(pendingEditorInsertMatchesMemo(
|
||||
{ memoId: "local_memo", files: [file(12)] },
|
||||
"memo_remote",
|
||||
new Set(["local_memo", "memo_remote"]),
|
||||
)).toBe(true);
|
||||
expect(pendingEditorInsertMatchesMemo(
|
||||
{ memoId: "local_memo", files: [file(12)] },
|
||||
"memo_other",
|
||||
new Set(["memo_other"]),
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
test("waits until the editor is hydrated for the new memo", () => {
|
||||
const pendingInsertFiles = { memoId: "memo_1", files: [file(24)] };
|
||||
expect(shouldInsertPendingEditorFiles({
|
||||
pendingInsertFiles,
|
||||
memoId: "memo_1",
|
||||
editorHydratedForMemo: false,
|
||||
editorReady: true,
|
||||
readOnly: false,
|
||||
})).toBe(false);
|
||||
expect(shouldInsertPendingEditorFiles({
|
||||
pendingInsertFiles,
|
||||
memoId: "memo_1",
|
||||
editorHydratedForMemo: true,
|
||||
editorReady: true,
|
||||
readOnly: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores empty screenshot files instead of inserting a blank image", () => {
|
||||
const pendingInsertFiles = { memoId: "memo_1", files: [file(0)] };
|
||||
expect(usablePendingInsertFiles(pendingInsertFiles)).toEqual([]);
|
||||
expect(shouldInsertPendingEditorFiles({
|
||||
pendingInsertFiles,
|
||||
memoId: "memo_1",
|
||||
editorHydratedForMemo: true,
|
||||
editorReady: true,
|
||||
readOnly: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
export type PendingEditorInsertFiles = {
|
||||
memoId: string;
|
||||
files: File[];
|
||||
};
|
||||
|
||||
export const pendingEditorInsertMatchesMemo = (
|
||||
pendingInsertFiles: PendingEditorInsertFiles | null | undefined,
|
||||
memoId: string | null | undefined,
|
||||
memoAliases?: ReadonlySet<string>,
|
||||
) => Boolean(
|
||||
pendingInsertFiles
|
||||
&& memoId
|
||||
&& (
|
||||
pendingInsertFiles.memoId === memoId
|
||||
|| Boolean(memoAliases?.has(pendingInsertFiles.memoId))
|
||||
),
|
||||
);
|
||||
|
||||
export const usablePendingInsertFiles = (
|
||||
pendingInsertFiles: PendingEditorInsertFiles | null | undefined,
|
||||
) => (pendingInsertFiles?.files ?? []).filter((file) => file.size > 0);
|
||||
|
||||
export const shouldInsertPendingEditorFiles = ({
|
||||
pendingInsertFiles,
|
||||
memoId,
|
||||
memoAliases,
|
||||
editorHydratedForMemo,
|
||||
editorReady,
|
||||
readOnly,
|
||||
}: {
|
||||
pendingInsertFiles?: PendingEditorInsertFiles | null;
|
||||
memoId?: string | null;
|
||||
memoAliases?: ReadonlySet<string>;
|
||||
editorHydratedForMemo: boolean;
|
||||
editorReady: boolean;
|
||||
readOnly: boolean;
|
||||
}) => Boolean(
|
||||
pendingEditorInsertMatchesMemo(pendingInsertFiles, memoId, memoAliases)
|
||||
&& editorHydratedForMemo
|
||||
&& editorReady
|
||||
&& !readOnly
|
||||
&& usablePendingInsertFiles(pendingInsertFiles).length > 0
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createScreenshotMemo,
|
||||
normalizeScreenshotBytes,
|
||||
screenshotFileFromImportPayload,
|
||||
screenshotNoteContent,
|
||||
} from "./screenshot-import.ts";
|
||||
|
||||
describe("screenshot import bytes", () => {
|
||||
test("does not treat Electron Buffer JSON as an empty typed array", () => {
|
||||
const encoded = { type: "Buffer", data: [137, 80, 78, 71] };
|
||||
expect(new Uint8Array(encoded).byteLength).toBe(0);
|
||||
expect(Array.from(normalizeScreenshotBytes(encoded))).toEqual([137, 80, 78, 71]);
|
||||
});
|
||||
|
||||
test("copies Uint8Array payloads so later IPC buffer reuse cannot empty the file", () => {
|
||||
const source = new Uint8Array([1, 2, 3]);
|
||||
const copy = normalizeScreenshotBytes(source);
|
||||
source[0] = 9;
|
||||
expect(Array.from(copy)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("skips creating a screenshot file when the payload has no image bytes", () => {
|
||||
const file = screenshotFileFromImportPayload({
|
||||
name: "screenshot.png",
|
||||
type: "image/png",
|
||||
bytes: { type: "Buffer" },
|
||||
});
|
||||
expect(file.size).toBe(0);
|
||||
});
|
||||
|
||||
test("creates a file with the restored screenshot bytes", async () => {
|
||||
const file = screenshotFileFromImportPayload({
|
||||
name: "screenshot.png",
|
||||
type: "image/png",
|
||||
bytes: { type: "Buffer", data: [137, 80, 78, 71] },
|
||||
});
|
||||
expect(file.size).toBe(4);
|
||||
expect(Array.from(new Uint8Array(await file.arrayBuffer()))).toEqual([137, 80, 78, 71]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("screenshot note content", () => {
|
||||
test("stores the image in the note body with room to type underneath", () => {
|
||||
const content = screenshotNoteContent("screenshot.png", "/api/v1/resources/res_shot/blob");
|
||||
expect(content.contentJson.content?.some((node) => node.type === "image")).toBe(true);
|
||||
expect(content.contentJson.content?.at(-1)?.type).toBe("paragraph");
|
||||
expect(content.contentMarkdown).toContain("/api/v1/resources/res_shot/blob");
|
||||
expect(content.contentMarkdown).toContain("screenshot.png");
|
||||
});
|
||||
|
||||
test("creates the memo only after the screenshot is attached", async () => {
|
||||
const created = {
|
||||
id: "memo_local",
|
||||
revision: 0,
|
||||
contentHash: "empty",
|
||||
title: "截图 2026-09-15 09:28",
|
||||
tags: [],
|
||||
contentMarkdown: "",
|
||||
};
|
||||
const saved = {
|
||||
...created,
|
||||
revision: 1,
|
||||
contentHash: "shot",
|
||||
contentMarkdown: "",
|
||||
};
|
||||
const file = screenshotFileFromImportPayload({
|
||||
name: "screenshot.png",
|
||||
type: "image/png",
|
||||
bytes: { type: "Buffer", data: [137, 80, 78, 71] },
|
||||
});
|
||||
const memo = await createScreenshotMemo({
|
||||
notebookId: "nb_inbox",
|
||||
title: created.title,
|
||||
file,
|
||||
createMemo: async () => ({ memo: created }),
|
||||
uploadResource: async (memoId, uploaded) => {
|
||||
expect(memoId).toBe(created.id);
|
||||
expect(uploaded.size).toBe(4);
|
||||
return { url: "/api/v1/resources/res_shot/blob", filename: uploaded.name };
|
||||
},
|
||||
updateMemo: async (memoToUpdate, content) => {
|
||||
expect(memoToUpdate.id).toBe(created.id);
|
||||
expect(content.contentMarkdown).toContain("/api/v1/resources/res_shot/blob");
|
||||
return { memo: { ...saved, contentMarkdown: content.contentMarkdown } };
|
||||
},
|
||||
});
|
||||
expect(memo.contentMarkdown).toContain("/api/v1/resources/res_shot/blob");
|
||||
});
|
||||
|
||||
test("deletes the empty memo if the screenshot cannot be attached", async () => {
|
||||
const deleted = [];
|
||||
const file = screenshotFileFromImportPayload({
|
||||
name: "screenshot.png",
|
||||
type: "image/png",
|
||||
bytes: { type: "Buffer", data: [137, 80, 78, 71] },
|
||||
});
|
||||
await expect(createScreenshotMemo({
|
||||
notebookId: "nb_inbox",
|
||||
title: "截图",
|
||||
file,
|
||||
createMemo: async () => ({
|
||||
memo: {
|
||||
id: "memo_failed",
|
||||
revision: 0,
|
||||
contentHash: "empty",
|
||||
title: "截图",
|
||||
tags: [],
|
||||
contentMarkdown: "",
|
||||
},
|
||||
}),
|
||||
uploadResource: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
updateMemo: async (memo) => ({ memo }),
|
||||
deleteMemo: async (memoId) => {
|
||||
deleted.push(memoId);
|
||||
},
|
||||
})).rejects.toThrow("offline");
|
||||
expect(deleted).toEqual(["memo_failed"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { docToMarkdown, markdownToDoc, type TiptapDoc } from "@edgeever/shared";
|
||||
|
||||
export const normalizeScreenshotBytes = (value: unknown): Uint8Array => {
|
||||
if (value instanceof Uint8Array) {
|
||||
const copy = new Uint8Array(value.byteLength);
|
||||
copy.set(value);
|
||||
return copy;
|
||||
}
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return new Uint8Array(value.slice(0));
|
||||
}
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
|
||||
}
|
||||
if (Array.isArray(value) && value.every((item) => typeof item === "number")) {
|
||||
return Uint8Array.from(value);
|
||||
}
|
||||
if (value && typeof value === "object" && "type" in value && (value as { type?: unknown }).type === "Buffer") {
|
||||
return normalizeScreenshotBytes((value as { data?: unknown }).data);
|
||||
}
|
||||
return new Uint8Array();
|
||||
};
|
||||
|
||||
export const screenshotFileFromImportPayload = (payload: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
bytes: unknown;
|
||||
}) => {
|
||||
const bytes = normalizeScreenshotBytes(payload.bytes);
|
||||
const blobPart = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(blobPart).set(bytes);
|
||||
return new File([blobPart], payload.name || "screenshot.png", { type: payload.type || "image/png" });
|
||||
};
|
||||
|
||||
const escapeMarkdownImageAlt = (value: string) => value.replaceAll("\\", "\\\\").replaceAll("]", "\\]");
|
||||
|
||||
export const screenshotNoteContent = (filename: string, url: string) => {
|
||||
const alt = escapeMarkdownImageAlt(filename || "screenshot.png");
|
||||
const contentMarkdown = ``;
|
||||
const parsed = markdownToDoc(contentMarkdown);
|
||||
const nodes = parsed.content ?? [];
|
||||
const contentJson: TiptapDoc = {
|
||||
type: "doc",
|
||||
content: nodes.at(-1)?.type === "paragraph" ? nodes : [...nodes, { type: "paragraph" }],
|
||||
};
|
||||
return { contentJson, contentMarkdown: docToMarkdown(contentJson) };
|
||||
};
|
||||
|
||||
type ScreenshotMemo = {
|
||||
id: string;
|
||||
revision: number;
|
||||
contentHash: string;
|
||||
title: string | null;
|
||||
tags: string[];
|
||||
contentMarkdown: string;
|
||||
};
|
||||
|
||||
export const createScreenshotMemo = async <TMemo extends ScreenshotMemo>(input: {
|
||||
notebookId: string;
|
||||
title: string;
|
||||
file: File;
|
||||
createMemo: (payload: {
|
||||
notebookId: string;
|
||||
title: string;
|
||||
contentMarkdown: string;
|
||||
tags: string[];
|
||||
}) => Promise<{ memo: TMemo }>;
|
||||
uploadResource: (memoId: string, file: File) => Promise<{ url: string; filename?: string | null }>;
|
||||
updateMemo: (
|
||||
memo: TMemo,
|
||||
content: { contentJson: TiptapDoc; contentMarkdown: string },
|
||||
) => Promise<{ memo: TMemo }>;
|
||||
deleteMemo?: (memoId: string) => Promise<unknown>;
|
||||
}) => {
|
||||
const created = await input.createMemo({
|
||||
notebookId: input.notebookId,
|
||||
title: input.title,
|
||||
contentMarkdown: "",
|
||||
tags: [],
|
||||
});
|
||||
try {
|
||||
const resource = await input.uploadResource(created.memo.id, input.file);
|
||||
const content = screenshotNoteContent(resource.filename || input.file.name, resource.url);
|
||||
return (await input.updateMemo(created.memo, content)).memo;
|
||||
} catch (error) {
|
||||
await input.deleteMemo?.(created.memo.id).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -63,7 +63,7 @@
|
||||
"release": "bun scripts/release.mjs",
|
||||
"publish:stores": "bun scripts/store-delivery.mjs",
|
||||
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
|
||||
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
|
||||
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
|
||||
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
|
||||
"verify:renderer-origin-migration": "bun run --cwd apps/desktop verify-origin-migration",
|
||||
"verify:desktop-cross-version-startup": "bun scripts/verify-desktop-cross-version-startup.mjs",
|
||||
|
||||
@@ -1436,6 +1436,9 @@ export const enUS = {
|
||||
importMarkdownSingleFile: "Drag in one Markdown file at a time.",
|
||||
importMarkdownUnsupported: "Only .md and .markdown files are supported.",
|
||||
importMarkdownReadFailed: "The file could not be read or the note could not be created. Try again.",
|
||||
importScreenshotFailedTitle: "Could not save screenshot to note",
|
||||
importScreenshotEmpty: "The screenshot image was empty. Try capturing again, and allow Screen Recording for EdgeEver in System Settings if this is the first time.",
|
||||
importScreenshotFailed: "Couldn't save the screenshot as a note. Check the connection and try again.",
|
||||
loadingMore: "Loading more notes",
|
||||
openMemo: "Open note",
|
||||
unpin: "Unpin",
|
||||
|
||||
@@ -1436,6 +1436,9 @@ export const ja = {
|
||||
importMarkdownSingleFile: "Markdown ファイルは一度に 1 つだけドラッグしてください。",
|
||||
importMarkdownUnsupported: ".md と .markdown ファイルのみ対応しています。",
|
||||
importMarkdownReadFailed: "ファイルを読み取れなかったか、ノートを作成できませんでした。再試行してください。",
|
||||
importScreenshotFailedTitle: "スクリーンショットをノートに保存できませんでした",
|
||||
importScreenshotEmpty: "スクリーンショット画像が空でした。もう一度撮影してください。初めて使う場合は、システム設定で EdgeEver の画面収録を許可してください。",
|
||||
importScreenshotFailed: "スクリーンショットをノートとして保存できませんでした。接続を確認して再試行してください。",
|
||||
loadingMore: "さらにノートを読み込み中",
|
||||
openMemo: "ノートを開く",
|
||||
unpin: "ピン留めを外す",
|
||||
|
||||
@@ -1436,6 +1436,9 @@ export const zhCN = {
|
||||
importMarkdownSingleFile: "请一次只拖入一个 Markdown 文件。",
|
||||
importMarkdownUnsupported: "仅支持 .md 或 .markdown 文件。",
|
||||
importMarkdownReadFailed: "文件读取或笔记创建失败,请重试。",
|
||||
importScreenshotFailedTitle: "无法将截图保存到笔记",
|
||||
importScreenshotEmpty: "截图图片为空。请重新截取;若是第一次使用,请在系统设置中允许 EdgeEver 使用屏幕录制。",
|
||||
importScreenshotFailed: "无法将截图保存为笔记。请检查网络后重试。",
|
||||
loadingMore: "正在加载更多笔记",
|
||||
openMemo: "打开笔记",
|
||||
unpin: "取消置顶",
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
"version": "1.73.0",
|
||||
"changes": {
|
||||
"en-US": [
|
||||
"Capture a screenshot from the desktop tray or menu bar and save it as a new note. The macOS menu-bar icon now uses the cat brand mark.",
|
||||
"Click the desktop tray or menu bar to capture the current screen and save it as a new note. The macOS menu-bar icon now uses the cat brand mark.",
|
||||
"Give desktop notes even Evernote-like reading gutters, including when a scrollbar is visible.",
|
||||
"Tighten the notebook sidebar so sync status, create, templates, all notes, and the notebook list sit closer together.",
|
||||
"Keep the web app installable without downloading the full interface up front; pages cache as you use them."
|
||||
],
|
||||
"zh-CN": [
|
||||
"桌面端可从托盘或菜单栏截图并保存为新笔记。macOS 菜单栏图标改为猫咪品牌图形。",
|
||||
"桌面端可从托盘或菜单栏一键截取当前屏幕并保存为新笔记。macOS 菜单栏图标改为猫咪品牌图形。",
|
||||
"桌面笔记左右留白更接近印象笔记,出现滚动条时两侧仍保持对称。",
|
||||
"收紧笔记本侧栏间距,同步状态、新建、模板、全部笔记和笔记本列表更紧凑。",
|
||||
"网页版安装为应用时不再预下载整套界面,改为使用各页面时再缓存。"
|
||||
],
|
||||
"ja": [
|
||||
"デスクトップのトレイまたはメニューバーからスクリーンショットを撮り、新しいノートとして保存できます。macOS のメニューバーアイコンは猫のブランドマークになりました。",
|
||||
"デスクトップのトレイまたはメニューバーをクリックすると、現在の画面を撮って新しいノートとして保存できます。macOS のメニューバーアイコンは猫のブランドマークになりました。",
|
||||
"デスクトップのノートに Evernote に近い左右余白を付け、スクロールバーが出ても左右が揃うようにしました。",
|
||||
"ノートブックサイドバーの余白を詰め、同期状態・作成・テンプレート・すべてのノート・ノートブック一覧を近づけました。",
|
||||
"インストール可能な Web アプリは導入時に全画面を先読みしなくなり、使うページからキャッシュします。"
|
||||
|
||||
Reference in new issue
Block a user