chore: update EdgeEver from v2.14.0
EdgeEver-Upstream-Commit: 355286814e746828688e6b0de139c39d3ecd2b20
This commit is contained in:
1 parent
55a447d8f8
commit
d04350db18
119 files changed
+5200
-1391
No files matched your search
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@edgeever/public-network": "workspace:*"
|
||||
"@edgeever/public-network": "workspace:*",
|
||||
"content-type": "3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
MAX_IMAGE_UPLOAD_BYTES,
|
||||
mapResource,
|
||||
normalizeFilename,
|
||||
validateRestoredResourceMimeType,
|
||||
type ResourceRow,
|
||||
} from "./resource-service";
|
||||
import { getWorkspaceId, requireScopes, requireUser } from "./request-auth";
|
||||
@@ -489,6 +490,9 @@ export const registerBackupRoutes = (
|
||||
return apiError(context, "upload_too_large", "Backup resource size is invalid.", 413);
|
||||
}
|
||||
|
||||
const mimeType = metadata.mimeType ?? file.type ?? "application/octet-stream";
|
||||
validateRestoredResourceMimeType(metadata.kind, mimeType, file.size);
|
||||
|
||||
const filename = normalizeFilename(metadata.filename || file.name) || `${metadata.kind}-${metadata.id}`;
|
||||
const objectKey = `workspaces/${workspaceId}/restores/${metadata.memoId}/${metadata.id}/${Date.now()}-${filename}`;
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
@@ -511,7 +515,7 @@ export const registerBackupRoutes = (
|
||||
|
||||
const destination = await resolveObjectStorage(context.env);
|
||||
await destination.store.put(objectKey, bytes, {
|
||||
httpMetadata: { contentType: metadata.mimeType ?? file.type ?? "application/octet-stream" },
|
||||
httpMetadata: { contentType: mimeType },
|
||||
customMetadata: { memoId: metadata.memoId, resourceId: metadata.id, restored: "true" },
|
||||
});
|
||||
|
||||
@@ -547,7 +551,7 @@ export const registerBackupRoutes = (
|
||||
objectKey,
|
||||
destination.configId,
|
||||
metadata.kind,
|
||||
metadata.mimeType ?? file.type ?? null,
|
||||
mimeType,
|
||||
filename,
|
||||
bytes.byteLength,
|
||||
await dependencies.sha256Bytes(bytes),
|
||||
|
||||
@@ -137,6 +137,26 @@ describe("database write optimizations", () => {
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
test("persists literal tilde ranges through Markdown and JSON updates (#698)", async () => {
|
||||
const { database, sqlite } = createDatabase();
|
||||
try {
|
||||
const memo = getSeedMemo(sqlite);
|
||||
const actor = { actorType: "user", actorId: "user_owner" };
|
||||
const source = "成本 1~2~3 元,10~20, 30~40";
|
||||
const first = await updateMemoRecord(database, memo.workspace_id, memo.id,
|
||||
{ contentMarkdown: source }, actor, "owner");
|
||||
expect(first.memo.contentJson.content[0].content).toEqual([{ type: "text", text: source }]);
|
||||
const second = await updateMemoRecord(database, memo.workspace_id, memo.id,
|
||||
{ contentJson: first.memo.contentJson }, actor, "owner");
|
||||
const stored = sqlite.query("SELECT content_json, content_markdown FROM memo_contents WHERE memo_id = ?").get(memo.id);
|
||||
expect(JSON.parse(stored.content_json)).toEqual(first.memo.contentJson);
|
||||
expect(markdownToDoc(stored.content_markdown)).toEqual(first.memo.contentJson);
|
||||
expect(second.memo.contentJson).toEqual(first.memo.contentJson);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns an unchanged memo without revision, audit, or search writes", async () => {
|
||||
const { database, sqlite } = createDatabase();
|
||||
const memo = getSeedMemo(sqlite);
|
||||
|
||||
@@ -334,6 +334,11 @@ describe("public notebook site controls", () => {
|
||||
expect(tokenHtml).toContain(`<meta name="robots" content="${PUBLIC_PAGE_ROBOTS}">`);
|
||||
expect(tokenHtml).toContain(`rel="canonical" href="https://example.org/s/docs${suffix}"`);
|
||||
}
|
||||
// The stored zh-CN default title means "untitled"; the page falls back to the notebook name.
|
||||
f.db.run("UPDATE memos SET title = ? WHERE id = 'memo_child'", ["无标题笔记"]);
|
||||
const untitledHtml = await (await render("/s/docs/memo_child")).text();
|
||||
expect(untitledHtml).not.toContain("无标题笔记");
|
||||
expect(untitledHtml).toMatch(/<title>[^<]+ · EdgeEver<\/title>/);
|
||||
await manage(f, "/memos/memo_child", { excluded: true });
|
||||
const hidden = await render("/s/docs/memo_child");
|
||||
expect(hidden.status).toBe(404);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getMemoDisplayTitle } from "@edgeever/shared/memo-title";
|
||||
import { PUBLIC_PAGE_HEADERS, PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers";
|
||||
import type { DatabaseAdapter } from "./storage-contract";
|
||||
import { getNotebookSite, publicTreeSql, resolveNotebookSiteToken } from "./notebook-site-service";
|
||||
@@ -31,7 +32,7 @@ export const renderNotebookSitePage = async (
|
||||
.bind(site.token, selected).first<{ title: string | null; excerpt: string }>() : null;
|
||||
if (memoId && !note) status = 404;
|
||||
if (status === 200) {
|
||||
title = `${note?.title?.trim() || site.name} · EdgeEver`;
|
||||
title = `${getMemoDisplayTitle(note?.title, site.name)} · EdgeEver`;
|
||||
description = note?.excerpt || site.name;
|
||||
indexable = alias && site.settings.allowIndexing;
|
||||
const base = site.site_slug ? `/s/${encodeURIComponent(site.site_slug)}` : `/share/notebook/${encodeURIComponent(site.token)}`;
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseEdgeEverZip, restoreEdgeEverZip } from "@edgeever/shared/backup-archive";
|
||||
import { createEdgeEverClient } from "../../../packages/client/src/index.ts";
|
||||
import { fetchEdgeEverApp } from "./index.ts";
|
||||
import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts";
|
||||
|
||||
const fixtures = [];
|
||||
const html = "<!doctype html><p>Untrusted attachment</p>";
|
||||
const png = Uint8Array.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64"));
|
||||
const executionContext = { waitUntil() {}, passThroughOnException() {} };
|
||||
|
||||
afterEach(async () => {
|
||||
for (const { database, directory, server } of fixtures.splice(0)) {
|
||||
server?.stop(true);
|
||||
database.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const fixture = async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "edgeever-701-test-"));
|
||||
const database = new Database(":memory:");
|
||||
const cleanup = { database, directory };
|
||||
fixtures.push(cleanup);
|
||||
database.exec("PRAGMA foreign_keys = ON");
|
||||
const migrations = new URL("../../../migrations/", import.meta.url);
|
||||
for (const name of (await readdir(migrations)).filter((name) => name.endsWith(".sql")).sort()) {
|
||||
database.exec(await readFile(new URL(name, migrations), "utf8"));
|
||||
}
|
||||
database.run("INSERT OR IGNORE INTO workspaces (id, name, is_personal) VALUES ('ws_default', 'Personal', 1)");
|
||||
const environment = {
|
||||
storage: createSelfHostedStorageAdapter(database, directory),
|
||||
EDGE_EVER_ALLOW_UNAUTHENTICATED: "true",
|
||||
EDGE_EVER_RUNTIME: "self-hosted-bun",
|
||||
};
|
||||
const request = (path, init = {}) => fetchEdgeEverApp(new Request(`http://edgeever.test${path}`, init), environment, executionContext);
|
||||
const json = async (path, body, method = "POST") => {
|
||||
const response = await request(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
};
|
||||
const { notebook } = await json("/api/v1/notebooks", { name: "Security regression" });
|
||||
const { memo } = await json("/api/v1/memos", { notebookId: notebook.id, title: "Restore regression", contentMarkdown: "Original note" });
|
||||
const metadata = (overrides = {}) => ({
|
||||
id: "res_security", memoId: memo.id, originalMemoId: null, kind: "image", mimeType: "image/png",
|
||||
filename: "resource.png", byteSize: png.length, sha256: null, width: 1, height: 1,
|
||||
createdAt: "2026-08-31T00:00:00.000Z", updatedAt: "2026-08-31T00:00:00.000Z",
|
||||
archivePath: "resources/resource.png", ...overrides,
|
||||
});
|
||||
const restore = (resource, bytes = png, type = resource.mimeType ?? "application/octet-stream") => {
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify(resource));
|
||||
form.set("file", new File([bytes], resource.filename ?? "resource", { type }));
|
||||
return request(`/api/v1/restores/json/resources/${resource.id}`, { method: "PUT", body: form });
|
||||
};
|
||||
const resourceRow = () => database.query("SELECT * FROM resources WHERE id = 'res_security'").get();
|
||||
const serve = () => {
|
||||
cleanup.server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: (request) => fetchEdgeEverApp(request, environment, executionContext) });
|
||||
return cleanup.server.url.origin;
|
||||
};
|
||||
return { directory, database, environment, request, json, notebook, memo, metadata, restore, resourceRow, serve };
|
||||
};
|
||||
|
||||
describe("restored resource security", () => {
|
||||
for (const mimeType of ["text/html", "application/xhtml+xml", "image/unknown", null]) {
|
||||
test(`legacy image restore rejects ${mimeType ?? "HTML file MIME fallback"} without replacing existing bytes`, async () => {
|
||||
const f = await fixture();
|
||||
expect((await f.restore(f.metadata())).status).toBe(200);
|
||||
const previous = f.resourceRow();
|
||||
const response = await f.restore(f.metadata({ mimeType, filename: "resource.html", byteSize: html.length }), html, "text/html");
|
||||
expect(response.status).toBe(415);
|
||||
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
|
||||
expect(f.resourceRow()).toEqual(previous);
|
||||
expect(new Uint8Array(await readFile(join(f.directory, previous.object_key)))).toEqual(png);
|
||||
});
|
||||
}
|
||||
|
||||
for (const mimeType of ["text/html", "application/xhtml+xml"]) {
|
||||
test(`resumable image restore rejects ${mimeType} before creating a multipart upload`, async () => {
|
||||
const f = await fixture();
|
||||
const previousObjects = (await readdir(f.directory, { recursive: true })).sort();
|
||||
const response = await f.request("/api/v1/restores/json/resources/res_security/uploads", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(f.metadata({ mimeType, filename: "resource.html", byteSize: html.length })),
|
||||
});
|
||||
expect(response.status).toBe(415);
|
||||
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
|
||||
expect(f.database.query("SELECT COUNT(*) AS count FROM resource_uploads").get().count).toBe(0);
|
||||
expect(f.resourceRow()).toBeNull();
|
||||
expect((await readdir(f.directory, { recursive: true })).sort()).toEqual(previousObjects);
|
||||
});
|
||||
}
|
||||
|
||||
for (const mode of ["legacy", "resumable"]) {
|
||||
for (const mimeType of [null, "", "application/octet-stream"]) {
|
||||
test(`${mode} preserves unknown image MIME ${JSON.stringify(mimeType)} as a download`, async () => {
|
||||
const f = await fixture();
|
||||
const metadata = f.metadata({ mimeType, filename: "unknown.bin" });
|
||||
if (mode === "legacy") {
|
||||
expect((await f.restore(metadata, png, "application/octet-stream")).status).toBe(200);
|
||||
} else {
|
||||
const client = createEdgeEverClient({ baseUrl: f.serve() });
|
||||
await client.restoreJsonResource(metadata.id, metadata, new Blob([png]));
|
||||
}
|
||||
expect(f.resourceRow().mime_type).toBe(mimeType ?? "application/octet-stream");
|
||||
const response = await f.request("/api/v1/resources/res_security/blob");
|
||||
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(png);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const mimeType of ["application/pdf", "audio/mpeg", "video/mp4"]) {
|
||||
test(`untyped attachments with ${mimeType} object metadata keep their download behavior`, async () => {
|
||||
const f = await fixture();
|
||||
const token = "s".repeat(43);
|
||||
const notebookToken = "n".repeat(43);
|
||||
f.database.run("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES ('security_share', ?, 'ws_default', ?)", [f.memo.id, token]);
|
||||
f.database.run("INSERT INTO notebook_shares (id, notebook_id, workspace_id, token) VALUES ('security_notebook', ?, 'ws_default', ?)", [f.notebook.id, notebookToken]);
|
||||
await f.environment.storage.resources.put("legacy-attachment", Uint8Array.of(65, 66));
|
||||
const getObject = f.environment.storage.resources.get.bind(f.environment.storage.resources);
|
||||
f.environment.storage.resources.get = async (...args) => {
|
||||
const object = await getObject(...args);
|
||||
object.writeHttpMetadata = (headers) => headers.set("Content-Type", mimeType);
|
||||
return object;
|
||||
};
|
||||
f.database.run("INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) VALUES ('res_security', ?, 'legacy-attachment', 'attachment', NULL, 'untitled.bin', 2)", [f.memo.id]);
|
||||
for (const path of [
|
||||
"/api/v1/resources/res_security/blob",
|
||||
`/api/public/shares/${token}/resources/res_security/blob`,
|
||||
`/api/public/notebook-shares/${notebookToken}/resources/res_security/blob`,
|
||||
]) {
|
||||
const response = await f.request(path);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe(mimeType);
|
||||
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.of(65, 66));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("completion rejects an incompatible restore session persisted before the fix, before assembling or overwriting its object", async () => {
|
||||
const f = await fixture();
|
||||
expect((await f.restore(f.metadata())).status).toBe(200);
|
||||
const previous = f.resourceRow();
|
||||
const metadata = f.metadata({ kind: "attachment", mimeType: "text/html", filename: "resource.html", byteSize: html.length });
|
||||
const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", metadata);
|
||||
// This is the exact persisted metadata shape accepted by the old restore path.
|
||||
f.database.run("UPDATE resource_uploads SET kind = 'image', restore_metadata_json = ? WHERE id = ?", [JSON.stringify({ ...metadata, kind: "image" }), upload.id]);
|
||||
const session = f.database.query("SELECT * FROM resource_uploads WHERE id = ?").get(upload.id);
|
||||
const part = await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(html.length) }, body: html,
|
||||
});
|
||||
expect(part.status).toBe(200);
|
||||
const response = await f.request(`/api/v1/resource-uploads/${upload.id}/complete`, { method: "POST" });
|
||||
expect(response.status).toBe(415);
|
||||
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
|
||||
expect(f.resourceRow()).toEqual(previous);
|
||||
expect(await Bun.file(join(f.directory, session.object_key)).exists()).toBe(false);
|
||||
expect(new Uint8Array(await readFile(join(f.directory, previous.object_key)))).toEqual(png);
|
||||
});
|
||||
|
||||
for (const mimeType of ["text/html", "application/xhtml+xml", "TEXT/HTML; charset=utf-8", null]) {
|
||||
test(`legacy image rows with ${mimeType ?? "HTML object metadata"} download through authenticated and both public routes`, async () => {
|
||||
const f = await fixture();
|
||||
const token = "s".repeat(43);
|
||||
const notebookToken = "n".repeat(43);
|
||||
f.database.run("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES ('security_share', ?, 'ws_default', ?)", [f.memo.id, token]);
|
||||
f.database.run("INSERT INTO notebook_shares (id, notebook_id, workspace_id, token) VALUES ('security_notebook', ?, 'ws_default', ?)", [f.notebook.id, notebookToken]);
|
||||
await f.environment.storage.resources.put("legacy-html", new TextEncoder().encode(html), { httpMetadata: { contentType: "text/html" } });
|
||||
// Unlike R2/S3, the filesystem adapter does not persist HTTP metadata.
|
||||
const getObject = f.environment.storage.resources.get.bind(f.environment.storage.resources);
|
||||
f.environment.storage.resources.get = async (...args) => {
|
||||
const object = await getObject(...args);
|
||||
object.writeHttpMetadata = (headers) => headers.set("Content-Type", "text/html");
|
||||
return object;
|
||||
};
|
||||
f.database.run("INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) VALUES ('res_security', ?, 'legacy-html', 'image', ?, 'resource.html', ?)", [f.memo.id, mimeType, html.length]);
|
||||
for (const path of [
|
||||
"/api/v1/resources/res_security/blob",
|
||||
`/api/public/shares/${token}/resources/res_security/blob`,
|
||||
`/api/public/notebook-shares/${notebookToken}/resources/res_security/blob`,
|
||||
]) {
|
||||
const response = await f.request(path);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
|
||||
expect(response.headers.get("Content-Type")).toBe(mimeType === "TEXT/HTML; charset=utf-8" ? "text/html; charset=utf-8" : mimeType ?? "text/html");
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(await response.text()).toBe(html);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("legacy image restores accept image MIME parameters and a valid file MIME fallback without rewriting metadata", async () => {
|
||||
const f = await fixture();
|
||||
for (const mimeType of ["IMAGE/PNG; charset=binary", null]) {
|
||||
const response = await f.restore(f.metadata({ mimeType }), png, "image/png");
|
||||
expect(response.status).toBe(200);
|
||||
expect(f.resourceRow().mime_type).toBe(mimeType ?? "image/png");
|
||||
const blob = await f.request("/api/v1/resources/res_security/blob");
|
||||
expect(blob.headers.get("Content-Disposition")).toStartWith("inline;");
|
||||
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(png);
|
||||
}
|
||||
});
|
||||
|
||||
test("HTML remains a downloadable attachment through legacy and resumable restores", async () => {
|
||||
const f = await fixture();
|
||||
const metadata = f.metadata({ kind: "attachment", mimeType: "text/html", filename: "resource.html", byteSize: html.length });
|
||||
expect((await f.restore(metadata, html)).status).toBe(200);
|
||||
const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", metadata);
|
||||
expect((await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(html.length) }, body: html,
|
||||
})).status).toBe(200);
|
||||
await f.json(`/api/v1/resource-uploads/${upload.id}/complete`, {});
|
||||
const blob = await f.request("/api/v1/resources/res_security/blob");
|
||||
expect(blob.headers.get("Content-Disposition")).toStartWith("attachment;");
|
||||
expect(await blob.text()).toBe(html);
|
||||
});
|
||||
|
||||
test("resumable image restores validate MIME without rewriting its parameters", async () => {
|
||||
const f = await fixture();
|
||||
const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", f.metadata({ mimeType: "IMAGE/PNG; charset=binary" }));
|
||||
expect(f.database.query("SELECT mime_type FROM resource_uploads WHERE id = ?").get(upload.id).mime_type).toBe("IMAGE/PNG; charset=binary");
|
||||
expect((await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(png.length) }, body: png,
|
||||
})).status).toBe(200);
|
||||
await f.json(`/api/v1/resource-uploads/${upload.id}/complete`, {});
|
||||
expect(f.resourceRow().mime_type).toBe("IMAGE/PNG; charset=binary");
|
||||
expect(new Uint8Array(await readFile(join(f.directory, f.resourceRow().object_key)))).toEqual(png);
|
||||
});
|
||||
|
||||
test("SVG encoding parameters and bytes survive both restore paths", async () => {
|
||||
const mimeType = "image/svg+xml; charset=utf-16le";
|
||||
const bytes = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><text>旧备份</text></svg>', "utf16le");
|
||||
for (const mode of ["legacy", "resumable"]) {
|
||||
const f = await fixture();
|
||||
const metadata = f.metadata({ mimeType, filename: "encoded.svg", byteSize: bytes.length });
|
||||
if (mode === "legacy") expect((await f.restore(metadata, bytes)).status).toBe(200);
|
||||
else await createEdgeEverClient({ baseUrl: f.serve() }).restoreJsonResource(metadata.id, metadata, new Blob([bytes]));
|
||||
expect(f.resourceRow().mime_type).toBe(mimeType);
|
||||
const response = await f.request("/api/v1/resources/res_security/blob");
|
||||
expect(response.headers.get("Content-Type")).toBe(mimeType);
|
||||
expect(response.headers.get("Content-Disposition")).toStartWith("inline;");
|
||||
expect(response.headers.get("Content-Security-Policy")).toContain("sandbox");
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array(bytes));
|
||||
}
|
||||
});
|
||||
|
||||
for (const mode of ["legacy", "resumable"]) {
|
||||
test(`restores a real v2.12.0-exported archive through the patched ${mode} API`, async () => {
|
||||
const f = await fixture();
|
||||
const fixtureDirectory = new URL("../../../tests/fixtures/restore-resource-security/", import.meta.url);
|
||||
const source = JSON.parse(await readFile(new URL("source.json", fixtureDirectory), "utf8"));
|
||||
const backup = await parseEdgeEverZip(new Blob([await readFile(new URL("v2.12.0.zip", fixtureDirectory))]));
|
||||
expect(backup.manifest.edgeeverVersion).toBe("2.12.0");
|
||||
expect(backup.manifest.buildId).toBe(source.commit);
|
||||
const client = createEdgeEverClient({ baseUrl: f.serve() });
|
||||
await restoreEdgeEverZip(backup, {
|
||||
restoreNotebooks: client.restoreJsonNotebooks,
|
||||
restoreMemos: client.restoreJsonMemos,
|
||||
restorePrompts: client.restoreJsonAiPrompts,
|
||||
createResourceRestoreSink: mode === "resumable" ? client.createJsonResourceRestoreSink : async (_id, metadata) => {
|
||||
const chunks = [];
|
||||
return {
|
||||
write: async (chunk) => { chunks.push(chunk); },
|
||||
close: async () => {
|
||||
const response = await f.restore(metadata, await new Blob(chunks).arrayBuffer());
|
||||
expect(response.status).toBe(200);
|
||||
},
|
||||
abort: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const restoredMemo = await (await f.request("/api/v1/memos/memo_release")).json();
|
||||
expect(restoredMemo.memo).toMatchObject(source.memos[0]);
|
||||
const notebooks = await (await f.request("/api/v1/notebooks")).json();
|
||||
expect(notebooks.notebooks.find((notebook) => notebook.id === "nb_release")).toMatchObject(source.notebooks[0]);
|
||||
const revisions = await (await f.request("/api/v1/memos/memo_release/revisions")).json();
|
||||
const { contentJson, ...revisionSummary } = source.revisions[0];
|
||||
expect(revisions.revisions).toEqual(expect.arrayContaining([expect.objectContaining(revisionSummary)]));
|
||||
expect(JSON.parse(f.database.query("SELECT content_json FROM memo_revisions WHERE id = ?").get(revisionSummary.id).content_json)).toEqual(contentJson);
|
||||
for (const resource of source.resources) {
|
||||
const row = f.database.query("SELECT * FROM resources WHERE id = ?").get(resource.id);
|
||||
expect(row).toMatchObject({ memo_id: resource.memoId, kind: resource.kind, mime_type: resource.mimeType ?? "application/octet-stream", filename: resource.filename, byte_size: resource.byteSize, width: resource.width, height: resource.height, created_at: resource.createdAt });
|
||||
const response = await f.request(resource.url);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Disposition")).toStartWith(resource.kind === "image" && resource.mimeType ? "inline;" : "attachment;");
|
||||
if (resource.mimeType === "image/svg+xml") expect(response.headers.get("Content-Security-Policy")).toContain("sandbox");
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.from(Buffer.from(resource.bytesBase64, "base64")));
|
||||
}
|
||||
expect((await (await f.request(`/api/v1/memos/${f.memo.id}`)).json()).memo.contentMarkdown).toBe("Original note");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const ambiguousTypes = [
|
||||
["image", "image/png; charset=utf-8, text/html"],
|
||||
["image", 'image/png; note="quoted, value", text/html'],
|
||||
["image", "image/png,"],
|
||||
["attachment", "audio/mpeg; ignored=x, text/html"],
|
||||
["attachment", "video/mp4; ignored=x, application/xhtml+xml"],
|
||||
["attachment", "application/pdf, text/html"],
|
||||
["attachment", "not-a-mime"],
|
||||
["attachment", 'text/plain; note="line\nbreak"'],
|
||||
["image", 'image/png; note="ok"\r\n; charset=utf-8'],
|
||||
["attachment", 'audio/mpeg; charset=utf-8; charset="line\nbreak"'],
|
||||
["attachment", "\r\n"],
|
||||
];
|
||||
|
||||
const seedSharedResource = async (f, { kind, mimeType, filename = "resource.bin", objectMimeType = "application/octet-stream", body = html }) => {
|
||||
const token = "s".repeat(43);
|
||||
const notebookToken = "n".repeat(43);
|
||||
f.database.run("INSERT INTO memo_shares (id, memo_id, workspace_id, token) VALUES ('security_share', ?, 'ws_default', ?)", [f.memo.id, token]);
|
||||
f.database.run("INSERT INTO notebook_shares (id, notebook_id, workspace_id, token) VALUES ('security_notebook', ?, 'ws_default', ?)", [f.notebook.id, notebookToken]);
|
||||
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
|
||||
await f.environment.storage.resources.put("header-regression", bytes);
|
||||
const getObject = f.environment.storage.resources.get.bind(f.environment.storage.resources);
|
||||
f.environment.storage.resources.get = async (...args) => {
|
||||
const object = await getObject(...args);
|
||||
object.writeHttpMetadata = (headers) => headers.set("Content-Type", objectMimeType);
|
||||
return object;
|
||||
};
|
||||
f.database.run("INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size) VALUES ('res_security', ?, 'header-regression', ?, ?, ?, ?)", [f.memo.id, kind, mimeType, filename, bytes.length]);
|
||||
return [
|
||||
"/api/v1/resources/res_security/blob",
|
||||
`/api/public/shares/${token}/resources/res_security/blob`,
|
||||
`/api/public/notebook-shares/${notebookToken}/resources/res_security/blob`,
|
||||
];
|
||||
};
|
||||
|
||||
describe("single-valued resource content types", () => {
|
||||
for (const [kind, mimeType] of ambiguousTypes) {
|
||||
for (const mode of ["legacy", "resumable"]) {
|
||||
test(`${mode} restore rejects the entire ambiguous ${mimeType} declaration`, async () => {
|
||||
const f = await fixture();
|
||||
const metadata = f.metadata({ kind, mimeType, filename: "resource.bin", byteSize: html.length });
|
||||
const previousObjects = (await readdir(f.directory, { recursive: true })).sort();
|
||||
const response = mode === "legacy"
|
||||
? await f.restore(metadata, html, "application/octet-stream")
|
||||
: await f.request("/api/v1/restores/json/resources/res_security/uploads", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(metadata),
|
||||
});
|
||||
expect(response.status).toBe(415);
|
||||
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
|
||||
expect(f.resourceRow()).toBeNull();
|
||||
expect(f.database.query("SELECT COUNT(*) AS count FROM resource_uploads").get().count).toBe(0);
|
||||
expect((await readdir(f.directory, { recursive: true })).sort()).toEqual(previousObjects);
|
||||
});
|
||||
}
|
||||
|
||||
test(`legacy ${kind} resource with ${mimeType} downloads on all blob routes`, async () => {
|
||||
const f = await fixture();
|
||||
const paths = await seedSharedResource(f, { kind, mimeType });
|
||||
for (const path of paths) {
|
||||
const response = await f.request(path);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(await response.text()).toBe(html);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [kind, mimeType] of [ambiguousTypes[0], ambiguousTypes[3], ambiguousTypes[ambiguousTypes.length - 2]]) {
|
||||
test(`completion revalidates an old ${kind} upload with ambiguous MIME before assembly`, async () => {
|
||||
const f = await fixture();
|
||||
expect((await f.restore(f.metadata())).status).toBe(200);
|
||||
const previous = f.resourceRow();
|
||||
const metadata = f.metadata({ kind: "attachment", mimeType: "text/plain", filename: "resource.bin", byteSize: html.length });
|
||||
const { upload } = await f.json("/api/v1/restores/json/resources/res_security/uploads", metadata);
|
||||
f.database.run("UPDATE resource_uploads SET kind = ?, mime_type = ?, restore_metadata_json = ? WHERE id = ?", [kind, mimeType, JSON.stringify({ ...metadata, kind, mimeType }), upload.id]);
|
||||
const session = f.database.query("SELECT object_key FROM resource_uploads WHERE id = ?").get(upload.id);
|
||||
expect((await f.request(`/api/v1/resource-uploads/${upload.id}/parts/1`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(html.length) }, body: html,
|
||||
})).status).toBe(200);
|
||||
const response = await f.request(`/api/v1/resource-uploads/${upload.id}/complete`, { method: "POST" });
|
||||
expect(response.status).toBe(415);
|
||||
expect(await response.json()).toMatchObject({ error: { code: "unsupported_media_type" } });
|
||||
expect(f.resourceRow()).toEqual(previous);
|
||||
expect(await Bun.file(join(f.directory, session.object_key)).exists()).toBe(false);
|
||||
expect(new Uint8Array(await readFile(join(f.directory, previous.object_key)))).toEqual(png);
|
||||
});
|
||||
}
|
||||
|
||||
test("ambiguous object MIME and a misleading PDF filename cannot enable inline rendering", async () => {
|
||||
for (const metadata of [
|
||||
{ kind: "image", mimeType: null, objectMimeType: ambiguousTypes[0][1] },
|
||||
{ kind: "attachment", mimeType: null, objectMimeType: ambiguousTypes[3][1] },
|
||||
{ kind: "image", mimeType: ambiguousTypes[0][1], filename: "resource.pdf" },
|
||||
]) {
|
||||
const f = await fixture();
|
||||
for (const path of await seedSharedResource(f, metadata)) {
|
||||
const response = await f.request(path);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
expect(response.headers.get("Content-Disposition")).toStartWith("attachment;");
|
||||
expect(await response.text()).toBe(html);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("quoted commas, semicolons, escaped quotes and SVG charset remain one safely serialized type", async () => {
|
||||
const mimeType = 'IMAGE/SVG+XML;\tcharset="utf-16le"; note="a,b; text/html; \\"quoted\\""';
|
||||
const header = 'image/svg+xml; charset=utf-16le; note="a,b; text/html; \\"quoted\\""';
|
||||
const bytes = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><text>旧备份</text></svg>', "utf16le");
|
||||
for (const mode of ["legacy", "resumable"]) {
|
||||
const f = await fixture();
|
||||
const metadata = f.metadata({ mimeType, filename: "encoded.svg", byteSize: bytes.length });
|
||||
if (mode === "legacy") expect((await f.restore(metadata, bytes)).status).toBe(200);
|
||||
else await createEdgeEverClient({ baseUrl: f.serve() }).restoreJsonResource(metadata.id, metadata, new Blob([bytes]));
|
||||
expect(f.resourceRow().mime_type).toBe(mimeType);
|
||||
const response = await f.request("/api/v1/resources/res_security/blob");
|
||||
expect(response.headers.get("Content-Type")).toBe(header);
|
||||
expect(response.headers.get("Content-Disposition")).toStartWith("inline;");
|
||||
expect(response.headers.get("Content-Security-Policy")).toContain("sandbox");
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array(bytes));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveResourceUploadMimeType, setSvgResourceSecurityHeaders, isPdfAttachment, resolvePlayableMediaMimeType, ResourceUpdateSchema, type MemoDetail, type Resource } from "@edgeever/shared";
|
||||
import { resolveResourceUploadMimeType, ResourceUpdateSchema, type MemoDetail, type Resource } from "@edgeever/shared";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import type { Hono } from "hono";
|
||||
import { auditStatement } from "./audit";
|
||||
@@ -10,8 +10,7 @@ import { apiError, badRequest, notFound } from "./http-errors";
|
||||
import { resolveObjectStorage } from "./object-storage";
|
||||
import {
|
||||
SUPPORTED_IMAGE_MIME_TYPES,
|
||||
contentDispositionAttachment,
|
||||
contentDispositionInline,
|
||||
setResourceContentHeaders,
|
||||
mapResource,
|
||||
mapResourceListItem,
|
||||
mapResourceStorageSummary,
|
||||
@@ -294,13 +293,6 @@ export const registerResourceRoutes = (
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
const playableMimeType = resolvePlayableMediaMimeType(resource.mime_type, resource.filename);
|
||||
headers.set(
|
||||
"Content-Type",
|
||||
isPdfAttachment(resource.mime_type, resource.filename)
|
||||
? "application/pdf"
|
||||
: playableMimeType ?? resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream",
|
||||
);
|
||||
headers.set("Cache-Control", headers.get("Cache-Control") ?? "private, max-age=3600");
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
if (byteRange.kind === "range") {
|
||||
@@ -313,14 +305,7 @@ export const registerResourceRoutes = (
|
||||
} else {
|
||||
headers.set("Content-Length", String(resource.byte_size));
|
||||
}
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
resource.kind === "image" || isPdfAttachment(resource.mime_type, resource.filename) || playableMimeType
|
||||
? contentDispositionInline(resource.filename)
|
||||
: contentDispositionAttachment(resource.filename),
|
||||
);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
setSvgResourceSecurityHeaders(headers);
|
||||
setResourceContentHeaders(headers, resource);
|
||||
return new Response(object.body, { headers, status: byteRange.kind === "range" ? 206 : 200 });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
SUPPORTED_IMAGE_MIME_TYPES,
|
||||
isPdfAttachment,
|
||||
resolvePlayableMediaMimeType,
|
||||
setSvgResourceSecurityHeaders,
|
||||
type Resource,
|
||||
type ResourceListItem,
|
||||
type ResourceStorageSummary,
|
||||
} from "@edgeever/shared";
|
||||
import { format as formatContentType, parse as parseContentType } from "content-type";
|
||||
import { auditStatement } from "./audit";
|
||||
import type { AppContext, AuditActor, Bindings } from "./api-context";
|
||||
import { AppError } from "./app-error";
|
||||
@@ -105,6 +109,30 @@ export const validateImageUpload = (mimeType: string, size: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
// HTTP field bytes: HTAB, visible ASCII and obs-text, never other controls or DEL.
|
||||
const HTTP_FIELD_VALUE_PATTERN = /^[\t\u0020-\u007e\u0080-\u00ff]*$/;
|
||||
|
||||
const parseResourceContentType = (value: string) => {
|
||||
if (!HTTP_FIELD_VALUE_PATTERN.test(value)) return null;
|
||||
if (!value.trim()) return { type: "application/octet-stream", header: "application/octet-stream" };
|
||||
try {
|
||||
// A top-level comma ends the first type. Never silently accept the remainder.
|
||||
const parsed = parseContentType(value, { comma: true });
|
||||
if (parsed.index !== value.length) return null;
|
||||
return { type: parsed.type, header: formatContentType(parsed) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateRestoredResourceMimeType = (kind: Resource["kind"], mimeType: string, size: number) => {
|
||||
const parsed = parseResourceContentType(mimeType);
|
||||
if (!parsed) {
|
||||
throw new AppError("unsupported_media_type", "Resource MIME type must contain a single valid media type.", 415);
|
||||
}
|
||||
if (kind === "image" && parsed.type !== "application/octet-stream") validateImageUpload(parsed.type, size);
|
||||
};
|
||||
|
||||
export const deleteReleasedResourceObjects = async (
|
||||
env: Bindings,
|
||||
resources: { objectKey: string; storageConfigId: string }[],
|
||||
@@ -472,3 +500,31 @@ const contentDisposition = (kind: "inline" | "attachment", filename: string | nu
|
||||
export const contentDispositionInline = (filename: string | null) => contentDisposition("inline", filename);
|
||||
|
||||
export const contentDispositionAttachment = (filename: string | null) => contentDisposition("attachment", filename);
|
||||
|
||||
/** One policy for both the browser MIME and the decision to render it inline. */
|
||||
export const setResourceContentHeaders = (
|
||||
headers: Headers,
|
||||
resource: Pick<ResourceRow, "kind" | "mime_type" | "filename">,
|
||||
) => {
|
||||
const sourceMimeType = resource.mime_type ?? headers.get("Content-Type") ?? "";
|
||||
const parsed = parseResourceContentType(sourceMimeType);
|
||||
let contentType = "application/octet-stream";
|
||||
let inline = false;
|
||||
|
||||
// Invalid legacy metadata must not be rescued into a preview by its filename.
|
||||
if (parsed) {
|
||||
const pdf = isPdfAttachment(resource.mime_type, resource.filename);
|
||||
const media = resolvePlayableMediaMimeType(resource.mime_type ? parsed.type : null, resource.filename);
|
||||
if (pdf) contentType = "application/pdf";
|
||||
else if (media) contentType = resource.mime_type && parsed.type === media ? parsed.header : media;
|
||||
else contentType = parsed.header;
|
||||
|
||||
inline = pdf || Boolean(media)
|
||||
|| (resource.kind === "image" && SUPPORTED_IMAGE_MIME_TYPES.has(parsed.type));
|
||||
}
|
||||
|
||||
headers.set("Content-Type", contentType);
|
||||
headers.set("Content-Disposition", inline ? contentDispositionInline(resource.filename) : contentDispositionAttachment(resource.filename));
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
setSvgResourceSecurityHeaders(headers);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MAX_ATTACHMENT_UPLOAD_BYTES,
|
||||
MAX_IMAGE_UPLOAD_BYTES,
|
||||
normalizeFilename,
|
||||
validateRestoredResourceMimeType,
|
||||
validateAttachmentUpload,
|
||||
validateImageUpload,
|
||||
} from "./resource-service";
|
||||
@@ -185,6 +186,8 @@ export const initiateResourceRestoreUpload = async (
|
||||
if (!Number.isSafeInteger(metadata.byteSize) || metadata.byteSize <= 0 || metadata.byteSize > maxBytes) {
|
||||
throw new AppError("upload_too_large", "Backup resource size is invalid.", 413);
|
||||
}
|
||||
const mimeType = metadata.mimeType?.trim() || "application/octet-stream";
|
||||
validateRestoredResourceMimeType(metadata.kind, metadata.mimeType ?? mimeType, metadata.byteSize);
|
||||
|
||||
const workspaceId = getWorkspaceId(context);
|
||||
const memo = await context.env.storage.db.prepare(
|
||||
@@ -207,7 +210,6 @@ export const initiateResourceRestoreUpload = async (
|
||||
|
||||
const uploadId = createId("upload");
|
||||
const filename = normalizeFilename(metadata.filename || "") || `${metadata.kind}-${metadata.id}`;
|
||||
const mimeType = metadata.mimeType?.trim() || "application/octet-stream";
|
||||
const objectKey = `workspaces/${workspaceId}/restores/${metadata.memoId}/${metadata.id}/${Date.now()}-${filename}`;
|
||||
const destination = await resolveObjectStorage(context.env);
|
||||
const multipart = await destination.store.createMultipartUpload(
|
||||
@@ -300,11 +302,7 @@ export const uploadResourcePart = async (
|
||||
return { partNumber: part.partNumber, byteSize };
|
||||
};
|
||||
|
||||
const completeRestoredResourceUpload = async (
|
||||
context: AppContext,
|
||||
upload: ResourceUploadRow,
|
||||
actor: AuditActor,
|
||||
): Promise<Resource> => {
|
||||
const parseRestoredResourceMetadata = (upload: ResourceUploadRow): JsonBackupResource => {
|
||||
let metadataInput: unknown;
|
||||
try {
|
||||
metadataInput = JSON.parse(upload.restore_metadata_json ?? "null");
|
||||
@@ -316,6 +314,18 @@ const completeRestoredResourceUpload = async (
|
||||
throw new AppError("invalid_restore_resource", "Restore resource metadata is invalid.", 400);
|
||||
}
|
||||
const metadata = parsed.data as JsonBackupResource;
|
||||
validateRestoredResourceMimeType(
|
||||
metadata.kind, metadata.mimeType ?? upload.mime_type, upload.byte_size,
|
||||
);
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const completeRestoredResourceUpload = async (
|
||||
context: AppContext,
|
||||
upload: ResourceUploadRow,
|
||||
actor: AuditActor,
|
||||
metadata: JsonBackupResource,
|
||||
): Promise<Resource> => {
|
||||
const originalMemo = metadata.originalMemoId
|
||||
? await context.env.storage.db.prepare(
|
||||
"SELECT id FROM memos WHERE id = ? AND workspace_id = ? LIMIT 1",
|
||||
@@ -430,12 +440,14 @@ export const completeResourceUpload = async (
|
||||
throw new AppError("upload_incomplete", "Resource upload size does not match the expected file size.", 409);
|
||||
}
|
||||
|
||||
// Revalidate sessions persisted by an older server before assembling any bytes.
|
||||
const restoredMetadata = upload.restore_metadata_json ? parseRestoredResourceMetadata(upload) : null;
|
||||
const destination = await resolveObjectStorage(context.env, upload.storage_config_id);
|
||||
const multipart = destination.store.resumeMultipartUpload(upload.object_key, upload.provider_upload_id);
|
||||
await multipart.complete(parts);
|
||||
|
||||
if (upload.restore_metadata_json) {
|
||||
return completeRestoredResourceUpload(context, upload, actor);
|
||||
if (restoredMetadata) {
|
||||
return completeRestoredResourceUpload(context, upload, actor, restoredMetadata);
|
||||
}
|
||||
|
||||
const now = isoNow();
|
||||
|
||||
@@ -174,6 +174,19 @@ describe("public memo share pages", () => {
|
||||
expect(await lockedMarkdown.text()).not.toContain("secret()");
|
||||
});
|
||||
|
||||
test("treats the stored default title as untitled in metadata and markdown", async () => {
|
||||
const sqlite = createDatabase();
|
||||
sqlite.query("UPDATE memos SET title = ? WHERE id = 'memo_source'").run("无标题笔记");
|
||||
sqlite.query("UPDATE memo_contents SET content_markdown = ? WHERE memo_id = 'memo_source'").run("Body text\n");
|
||||
const html = await (await render(sqlite, `/share/${token}`)).text();
|
||||
expect(html).not.toContain("无标题笔记");
|
||||
expect(html).toContain("<title>Shared note · EdgeEver</title>");
|
||||
expect(html).toContain('<meta property="og:title" content="Shared note">');
|
||||
expect(html).toContain('<meta name="twitter:title" content="Shared note">');
|
||||
expect(html).toContain("Body text");
|
||||
expect(await (await render(sqlite, `/share/${token}.md`)).text()).toBe("Body text\n");
|
||||
});
|
||||
|
||||
test("HEAD keeps the headers and omits the body", async () => {
|
||||
const response = await renderPublicMemoSharePage(
|
||||
new Request(`https://note.example/share/${token}.md`, { method: "HEAD" }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getMemoDisplayTitle } from "@edgeever/shared/memo-title";
|
||||
import { isValidMemoShareSlug } from "@edgeever/shared/share-slug";
|
||||
import { PUBLIC_PAGE_HEADERS, PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers";
|
||||
import type { DatabaseAdapter } from "./storage-contract";
|
||||
@@ -80,7 +81,8 @@ export const PUBLIC_SHARE_MARKDOWN_PASSWORD = "This shared note requires a passw
|
||||
|
||||
/** Markdown body for a public note. A title already used as the first heading is kept once. */
|
||||
export const publicShareMarkdown = (title: string | null, body: string) => {
|
||||
const heading = (title ?? "").replace(/[\r\n]+/g, " ").trim();
|
||||
// The stored zh-CN default title means "untitled"; it is not a heading.
|
||||
const heading = getMemoDisplayTitle(title, "").replace(/[\r\n]+/g, " ").trim();
|
||||
if (!heading) return body;
|
||||
const firstLine = body.split(/\r?\n/, 1)[0] ?? "";
|
||||
if (firstLine.replace(/^#\s+/, "").trim() === heading) return body;
|
||||
@@ -153,7 +155,7 @@ export const renderPublicMemoSharePage = async (
|
||||
return new Response(request.method === "HEAD" ? null : publicShareMarkdown(row.title, body), { status: 200, headers });
|
||||
}
|
||||
|
||||
const title = readable ? (row?.title?.replace(/[\r\n]+/g, " ").trim() || "Shared note") : "Shared note";
|
||||
const title = readable ? (getMemoDisplayTitle(row?.title, "").replace(/[\r\n]+/g, " ").trim() || "Shared note") : "Shared note";
|
||||
const description = readable
|
||||
? describeNote(body)
|
||||
: locked
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { setSvgResourceSecurityHeaders, accountNoteProseFromRow, collectMemoLinkIds, isPdfAttachment, MemoShareUpdateSchema, NoteBodyFontUpdateSchema, NoteProseUpdateSchema, parsePublishedNoteBodyFont, publicNoteProseFromAccount, PublicShareUnlockSchema, resolveMemoContentDoc, resolvePlayableMediaMimeType, sanitizeNoteProseCss, type AccountNoteProse, type ManagedMemoShare, type MemoShare, type NoteProseAccountRow, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared";
|
||||
import { accountNoteProseFromRow, collectMemoLinkIds, MemoShareUpdateSchema, NoteBodyFontUpdateSchema, NoteProseUpdateSchema, parsePublishedNoteBodyFont, publicNoteProseFromAccount, PublicShareUnlockSchema, resolveMemoContentDoc, sanitizeNoteProseCss, type AccountNoteProse, type ManagedMemoShare, type MemoShare, type NoteProseAccountRow, type PublicMemoShare, type TiptapDoc } from "@edgeever/shared";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import type { Hono } from "hono";
|
||||
import type { AppContext, AppEnv } from "./api-context";
|
||||
@@ -9,7 +9,7 @@ import { createId, isoNow, parseJsonArray } from "./entity-utils";
|
||||
import { apiError, notFound } from "./http-errors";
|
||||
import { resolveObjectStorage } from "./object-storage";
|
||||
import { getAuditActor, getWorkspaceId, requireUser } from "./request-auth";
|
||||
import { contentDispositionAttachment, contentDispositionInline } from "./resource-service";
|
||||
import { setResourceContentHeaders } from "./resource-service";
|
||||
import {
|
||||
isShareUnlockBlocked,
|
||||
nextShareUnlockFailure,
|
||||
@@ -74,11 +74,6 @@ const mapMemoShare = (row: MemoShareRow, password?: string): MemoShare => ({
|
||||
...(password ? { password } : {}),
|
||||
});
|
||||
|
||||
const contentDisposition = (kind: SharedResourceRow["kind"], mimeType: string | null, filename: string | null) => {
|
||||
const inline = kind === "image" || isPdfAttachment(mimeType, filename) || Boolean(resolvePlayableMediaMimeType(mimeType, filename));
|
||||
return inline ? contentDispositionInline(filename) : contentDispositionAttachment(filename);
|
||||
};
|
||||
|
||||
const resolveShareToken = (c: AppContext, value: string) => resolveMemoShareToken(c.env.storage.db, value);
|
||||
|
||||
const shareSlugTaken = (c: AppContext) =>
|
||||
@@ -141,13 +136,6 @@ export const serveSharedResource = async (c: AppContext, resource: SharedResourc
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
const playableMimeType = resolvePlayableMediaMimeType(resource.mime_type, resource.filename);
|
||||
headers.set(
|
||||
"Content-Type",
|
||||
isPdfAttachment(resource.mime_type, resource.filename)
|
||||
? "application/pdf"
|
||||
: playableMimeType ?? resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream",
|
||||
);
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
if (byteRange.kind === "range") {
|
||||
const length = object.range?.length ?? byteRange.range.length;
|
||||
@@ -159,10 +147,8 @@ export const serveSharedResource = async (c: AppContext, resource: SharedResourc
|
||||
} else {
|
||||
headers.set("Content-Length", String(resource.byte_size));
|
||||
}
|
||||
headers.set("Content-Disposition", contentDisposition(resource.kind, resource.mime_type, resource.filename));
|
||||
setResourceContentHeaders(headers, resource);
|
||||
headers.set("Cache-Control", "private, no-store");
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
setSvgResourceSecurityHeaders(headers);
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow, noarchive");
|
||||
return new Response(object.body, { headers, status: byteRange.kind === "range" ? 206 : 200 });
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@edgeever/desktop",
|
||||
"version": "2.12.0",
|
||||
"version": "2.14.0",
|
||||
"desktopName": "EdgeEver",
|
||||
"private": true,
|
||||
"description": "Native EdgeEver desktop client",
|
||||
|
||||
@@ -209,6 +209,7 @@ test("manager creates named files, restores from history and deletes only the se
|
||||
});
|
||||
cleanups.push(() => manager.close());
|
||||
await manager.setContext({ baseUrl: source.baseUrl, accountId: "local", token: "" });
|
||||
await manager.save({ ...manager.status().config, retention: 3 });
|
||||
const id = manager.status().backupSetId;
|
||||
for (const encrypted of [false, true]) {
|
||||
await manager.run(encrypted ? "fixture password" : undefined, id, undefined, { name: encrypted ? "Encrypted snapshot.zip" : "Plain snapshot", encrypted });
|
||||
|
||||
@@ -22,7 +22,7 @@ test("publishes only completed jobs, retains registered files and isolates accou
|
||||
const f = await fixture();
|
||||
try {
|
||||
let status = f.manager.status();
|
||||
expect(status.config.enabled).toBe(false); expect(status.config.retention).toBe(5);
|
||||
expect(status.config.enabled).toBe(false); expect(status.config.retention).toBe(1);
|
||||
await f.manager.save({ ...status.config, retention: 3 });
|
||||
for (let i = 0; i < 4; i++) { await f.manager.run(); await complete(f); }
|
||||
status = f.manager.status(); expect(status.history).toHaveLength(3);
|
||||
@@ -38,6 +38,41 @@ test("publishes only completed jobs, retains registered files and isolates accou
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
test("default retention keeps the latest successful archive through failures and reloads", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.manager.run(); await complete(f);
|
||||
const first = f.manager.status().history[0];
|
||||
await f.manager.run();
|
||||
f.workers.at(-1).emit("message", { type: "error", error: { code: "backup_source_changed", message: "Changed" } });
|
||||
await f.manager.waitForIdle();
|
||||
expect(f.manager.status().history).toEqual([first]);
|
||||
expect(await readFile(join(f.manager.status().actualDirectory, first.name), "utf8")).toBe("complete archive");
|
||||
await f.manager.run(); await complete(f);
|
||||
const status = f.manager.status();
|
||||
expect(status.history).toHaveLength(1);
|
||||
expect(status.history[0].name).not.toBe(first.name);
|
||||
expect((await readdir(status.actualDirectory)).filter((name) => name.endsWith(".zip"))).toEqual([status.history[0].name]);
|
||||
await f.manager.setContext(null);
|
||||
await f.manager.setContext({ baseUrl: "http://localhost:1111", accountId: "a", token: "session-a" });
|
||||
expect(f.manager.status().config.retention).toBe(1);
|
||||
expect(f.manager.status().history).toEqual(status.history);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
test("loading an existing configuration preserves the previous default of five copies", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const record = JSON.parse(await readFile(f.manager.statePath(), "utf8"));
|
||||
record.config.retention = 5;
|
||||
await writeFile(f.manager.statePath(), JSON.stringify(record));
|
||||
await f.manager.setContext(null);
|
||||
await f.manager.setContext({ baseUrl: "http://localhost:1111", accountId: "a", token: "session-a" });
|
||||
expect(f.manager.status().config).toEqual(record.config);
|
||||
expect(f.manager.status().error).toBeNull();
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
test("failure and cancellation preserve success and never rotate; credentials stay out of config", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
@@ -451,6 +486,7 @@ const finishSnapshot = async (f, result = {}) => {
|
||||
test('named manual backups preserve formats/settings and never overwrite a collision', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.manager.save({ ...f.manager.status().config, retention: 5 });
|
||||
await f.manager.save({ ...f.manager.status().config, encrypted: true }, 'saved');
|
||||
const id = f.manager.status().backupSetId;
|
||||
await f.manager.run(undefined, id, undefined, { name: ' Travel.zip ', encrypted: false });
|
||||
@@ -481,6 +517,7 @@ test('named manual backups preserve formats/settings and never overwrite a colli
|
||||
test('history restore/delete are scoped, reject replacements and remove only the selected file', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.manager.save({ ...f.manager.status().config, retention: 5 });
|
||||
await f.manager.run(); await complete(f);
|
||||
await f.manager.run(); await complete(f);
|
||||
const s = f.manager.status(); const [first, second] = s.history;
|
||||
@@ -508,6 +545,7 @@ test('history restore/delete are scoped, reject replacements and remove only the
|
||||
test('automatic no-change advances checks, not archive time/retention, and invalidates its baseline correctly', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.manager.save({ ...f.manager.status().config, retention: 5 });
|
||||
await f.manager.save({ ...f.manager.status().config, enabled: true, interval: 'daily' });
|
||||
await f.manager.run(); await finishSnapshot(f);
|
||||
const first = f.manager.status(); const due = Date.parse(first.nextDueAt);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const BACKUP_INTERVALS = ["daily", "weekly", "biweekly", "monthly"];
|
||||
export const BACKUP_RETENTION = [3, 5, 10, 20];
|
||||
export const BACKUP_RETENTION = [1, 3, 5, 10, 20];
|
||||
export const backupError = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
export const normalizeBackupConfig = (input, directory) => {
|
||||
if (input === undefined) return { enabled: false, interval: "weekly", retention: 5, directory, encrypted: false };
|
||||
if (input === undefined) return { enabled: false, interval: "weekly", retention: 1, directory, encrypted: false };
|
||||
if (!input || typeof input.enabled !== "boolean" || typeof input.encrypted !== "boolean"
|
||||
|| !BACKUP_INTERVALS.includes(input.interval) || !BACKUP_RETENTION.includes(input.retention)
|
||||
|| typeof input.directory !== "string" || !input.directory) throw backupError("backup_invalid_configuration", "The backup configuration is invalid.");
|
||||
|
||||
@@ -13,7 +13,10 @@ test("calendar schedules clamp month end and preserve successful history when fr
|
||||
|
||||
test("accepts the specified defaults and choices, rejects invalid configuration", () => {
|
||||
const defaults = normalizeBackupConfig(undefined, "/Documents/EdgeEver Backups");
|
||||
expect(defaults).toEqual({ enabled: false, interval: "weekly", retention: 5, directory: "/Documents/EdgeEver Backups", encrypted: false });
|
||||
expect(defaults).toEqual({ enabled: false, interval: "weekly", retention: 1, directory: "/Documents/EdgeEver Backups", encrypted: false });
|
||||
for (const retention of [1, 3, 5, 10, 20]) {
|
||||
expect(normalizeBackupConfig({ ...defaults, retention }, defaults.directory).retention).toBe(retention);
|
||||
}
|
||||
expect(() => normalizeBackupConfig({ ...defaults, interval: "hourly" }, defaults.directory)).toThrow();
|
||||
expect(() => normalizeBackupConfig({ ...defaults, retention: 4 }, defaults.directory)).toThrow();
|
||||
expect(() => normalizeBackupConfig({ ...defaults, directory: "" }, defaults.directory)).toThrow();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { createDocument, createWindow } from '@mixmark-io/domino';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { bundleTestScripts } from './bundle-test-scripts.mjs';
|
||||
import { readMetaSocial, metaPostCard } from './src/social-clip.ts';
|
||||
import { platformTarget } from './src/platform-clip.ts';
|
||||
|
||||
const url = 'https://www.instagram.com/p/SelectedPost/';
|
||||
const source = 'https://www.instagram.com/';
|
||||
const media = { code: 'SelectedPost', user: { username: 'author' }, caption: { text: 'A full caption beyond the preview' }, taken_at: 1700000000,
|
||||
media_type: 8, carousel_media_count: 3, carousel_media: [1,2,3].map(i => ({ media_type: 1, image_versions2: { candidates: [{url:`https://images.example.test/${i}.jpg`,width:1000,height:1000}] } })) };
|
||||
const json = value => `<script type="application/json">${JSON.stringify(value)}</script>`;
|
||||
const feed = `<article><a href="/author/">author</a><a href="/p/SelectedPost/"><time datetime="2026-10-08T08:40:23Z">2 weeks</time></a><img src="https://images.example.test/1.jpg"><p>A full caption... <button>more</button></p><button>Like</button><button>Share</button></article>`;
|
||||
const [bundle] = bundleTestScripts(['capture-platform']);
|
||||
|
||||
async function capture({ html=feed, data=media, status=200, change, contentType='text/html', responseUrl=url, sourceUrl=source }={}) {
|
||||
const w=createWindow(html,sourceUrl), location={href:sourceUrl};
|
||||
Object.getPrototypeOf(w.document.querySelectorAll('*'))[Symbol.iterator] ??= Array.prototype[Symbol.iterator];
|
||||
const calls=[];
|
||||
let done;
|
||||
const result = new Promise(resolve=>{done=resolve;});
|
||||
runInNewContext(bundle, { window:w,document:w.document,location,URL,AbortSignal,setTimeout,clearTimeout,setInterval,clearInterval,
|
||||
DOMParser:class {parseFromString(html){return createDocument(html);}},
|
||||
__edgeeverPlatformClipPayload:{requestId:'instagram',url,sourceUrl},
|
||||
fetch:async (request,init)=>{calls.push({url:request,init});change?.(w.document,location);return {ok:status===200,url:responseUrl,headers:new Headers({'content-type':contentType}),text:async()=>json(data)};},
|
||||
chrome:{i18n:{getMessage:k=>k},runtime:{sendMessage:async message=>done(message.result)}},
|
||||
});
|
||||
return {result:await result,calls};
|
||||
}
|
||||
|
||||
test('Instagram dynamic feed fetches only the selected same-origin detail and keeps all carousel images',async()=>{
|
||||
const {result,calls}=await capture();
|
||||
expect(result.ok).toBe(true);expect(result.clip.url).toBe(url);expect(result.clip.author).toBe('author');
|
||||
expect(result.clip.markdown).toContain(media.caption.text);expect(result.clip.markdown.match(/!\[/g)).toHaveLength(3);
|
||||
expect(calls).toHaveLength(1);expect(calls[0].url).toBe(url);
|
||||
expect(calls[0].init.credentials).toBe('same-origin');expect(calls[0].init.redirect).toBe('error');
|
||||
});
|
||||
|
||||
for (const failure of ['wrong-post','login','redirect','http','not-html','incomplete','navigation','recycled','hidden','no-card']) test(`Instagram detail fallback rejects ${failure}`,async()=>{
|
||||
const {result,calls}=await capture({
|
||||
data:failure==='wrong-post'?{...media,code:'OtherPost'}:failure==='login'?{login:true}:failure==='incomplete'?{...media,carousel_media:media.carousel_media.slice(0,1)}:media,
|
||||
status:failure==='http'?403:200,contentType:failure==='not-html'?'application/json':'text/html',
|
||||
responseUrl:failure==='redirect'?'https://www.instagram.com/accounts/login/':url,
|
||||
html:failure==='hidden'?feed.replace('<article>','<article hidden>'):failure==='no-card'?'<main>Log in</main>':feed,
|
||||
change:(doc,location)=>{if(failure==='navigation')location.href='https://www.instagram.com/p/OtherPost/';if(failure==='recycled')doc.querySelector('a[href="/p/SelectedPost/"]').setAttribute('href','/p/OtherPost/');},
|
||||
});
|
||||
expect(result.ok).toBe(false);expect(result.clip).toBeUndefined();
|
||||
expect(calls).toHaveLength(['hidden','no-card'].includes(failure)?0:1);
|
||||
});
|
||||
|
||||
test('Instagram fallback preserves the current host, including the non-www origin',async()=>{
|
||||
const {result,calls}=await capture({sourceUrl:'https://instagram.com/',responseUrl:'https://instagram.com/p/SelectedPost/'});
|
||||
expect(result.ok).toBe(true);expect(calls[0].url).toBe('https://instagram.com/p/SelectedPost/');
|
||||
});
|
||||
|
||||
test('Instagram detail card includes media and caption rather than the likes footer or comments',()=>{
|
||||
const doc=createDocument(`<main><div id="post"><img src="https://images.example.test/1.jpg"><div><a href="/author/">author</a><p>${media.caption.text}</p></div><div id="footer"><a href="/liker/"><img src="https://images.example.test/avatar.jpg">liker</a><button>Like</button><button>Share</button><button>Save</button><a href="/author/p/SelectedPost/"><time datetime="2026-10-08T08:40:23Z">October 8</time></a></div></div><div>COMMENT SENTINEL</div></main>${json(media)}`);
|
||||
expect(metaPostCard(doc.querySelector('time').parentElement,'instagram',url).id).toBe('post');
|
||||
const result=readMetaSocial(doc,platformTarget(url));
|
||||
expect(result.ok).toBe(true);expect(result.clip.markdown).toContain(media.caption.text);
|
||||
expect(result.clip.markdown).not.toContain('COMMENT SENTINEL');
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
"dev": "vite --config vite.config.ts",
|
||||
"lint:firefox": "web-ext lint --source-dir dist-firefox --output text",
|
||||
"package:firefox": "bun run build:firefox && bun run lint:firefox && web-ext build --source-dir dist-firefox --artifacts-dir web-ext-artifacts --overwrite-dest",
|
||||
"test": "bun test manifest.test.mjs context-menus.test.mjs popup.test.mjs capture-bundle.test.mjs image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs github-repo.test.mjs xhs-clip.test.mjs zhihu-clip.test.mjs reddit-clip.test.mjs video-clip.test.mjs page-images.test.mjs notebook-clip.test.mjs clip-time.test.mjs platform-clip.test.mjs social-clip.test.mjs platform-capture.test.mjs platform-save.test.mjs vertical-clip.test.mjs article-clip.test.mjs"
|
||||
"test": "bun test manifest.test.mjs context-menus.test.mjs popup.test.mjs capture-bundle.test.mjs image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs github-repo.test.mjs xhs-clip.test.mjs zhihu-clip.test.mjs reddit-clip.test.mjs video-clip.test.mjs page-images.test.mjs notebook-clip.test.mjs clip-time.test.mjs platform-clip.test.mjs social-clip.test.mjs instagram-clip.test.mjs platform-capture.test.mjs platform-save.test.mjs vertical-clip.test.mjs article-clip.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "2.2.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readXiaoyuzhou } from "./xiaoyuzhou-clip";
|
||||
import { readBoss } from "./boss-clip";
|
||||
import { readXueqiu } from "./xueqiu-clip";
|
||||
import { readXueqiuFeedPost } from "./xueqiu-feed";
|
||||
import { readInstagramDetail } from "./instagram-clip";
|
||||
import { readArticle } from "./article-clip";
|
||||
import { readJike, jikeExpandButton, jikePostCard } from "./jike-clip";
|
||||
import { readDouban } from "./douban-clip";
|
||||
@@ -68,6 +69,9 @@ import { platformHtmlMarkdown, platformTarget, type PlatformLabels, type Platfor
|
||||
}
|
||||
if(expected.platform === "xueqiu" && !platformTarget(location.href)) return readXueqiuFeedPost(document,expected,sourceUrl);
|
||||
const initial = read();
|
||||
if (expected.platform === "instagram" && !initial.ok && (initial.reason === "unreadable" || initial.reason === "incomplete")) {
|
||||
return readInstagramDetail(document, expected, sourceUrl, samePage);
|
||||
}
|
||||
if (!initial.ok && initial.reason === "incomplete" && ["facebook", "linkedin", "jike"].includes(expected.platform)) {
|
||||
const card = expected.platform === "jike" ? jikePostCard(document,expected,location.href) : socialPostCard(document,expected);
|
||||
const expand = expected.platform === "jike" ? jikeExpandButton(document,card) : expected.platform === "facebook" ? facebookExpandButton(card) : linkedinExpandButton(card);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { instagramPostCard, readMetaSocial } from "./social-clip";
|
||||
import { platformTarget, type PlatformRead, type PlatformTarget } from "./platform-clip";
|
||||
|
||||
/** Feed items loaded after navigation are absent from the document's JSON.
|
||||
* Read only the selected post's same-origin HTML, without executing its scripts. */
|
||||
export const readInstagramDetail = async (
|
||||
doc: Document, target: PlatformTarget, sourceUrl: string, samePage: () => boolean,
|
||||
): Promise<PlatformRead> => {
|
||||
const source = new URL(sourceUrl);
|
||||
if (target.platform !== "instagram" || source.protocol !== "https:"
|
||||
|| !["www.instagram.com", "instagram.com"].includes(source.hostname)) return { ok: false, reason: "not-target" };
|
||||
const card = instagramPostCard(doc, target);
|
||||
if (!card) return { ok: false, reason: "unreadable" };
|
||||
const current = () => samePage() && doc.contains(card) && instagramPostCard(doc, target) === card;
|
||||
try {
|
||||
const url = new URL(`/p/${target.id}/`, source);
|
||||
const response = await fetch(url.href, {
|
||||
credentials: "same-origin", redirect: "error", headers: { Accept: "text/html" },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!current()) return { ok: false, reason: "changed" };
|
||||
if (!response.ok || !response.headers.get("content-type")?.includes("text/html")
|
||||
|| Number(response.headers.get("content-length")) > 10_000_000) return { ok: false, reason: "unreadable" };
|
||||
if (response.url && (new URL(response.url).origin !== source.origin
|
||||
|| platformTarget(response.url)?.url !== target.url)) return { ok: false, reason: "changed" };
|
||||
const html = await response.text();
|
||||
if (!current()) return { ok: false, reason: "changed" };
|
||||
if (html.length > 10_000_000) return { ok: false, reason: "unreadable" };
|
||||
const detail = new DOMParser().parseFromString(html, "text/html");
|
||||
return readMetaSocial(doc, target, detail);
|
||||
} catch {
|
||||
return { ok: false, reason: current() ? "unreadable" : "changed" };
|
||||
}
|
||||
};
|
||||
@@ -235,6 +235,12 @@ export const readBluesky = (doc: Document, target: PlatformTarget): PlatformRead
|
||||
return saved;
|
||||
};
|
||||
|
||||
const instagramCardHasMedia = (card: Element, base: string) => Array.from(card.querySelectorAll('img[src], video')).some(media => {
|
||||
if (platformElementHidden(media)) return false;
|
||||
const link = media.closest('a[href]');
|
||||
return !link || platformTarget(postLinkUrl(link, base))?.platform === "instagram";
|
||||
});
|
||||
|
||||
/** Bound Meta posts by their timestamp header and rendered action controls. */
|
||||
export const metaPostCard = (link: Element, platform: string, base: string): Element | null => {
|
||||
if (!link.querySelector("time[datetime]") || link.closest("[hidden], [aria-hidden='true'], script, style, template")) return null;
|
||||
@@ -257,7 +263,10 @@ export const metaPostCard = (link: Element, platform: string, base: string): Ele
|
||||
return socialPlatform(url.href) === platform && (platform === "threads" ? /^\/@[^/]+\/?$/.test(url.pathname) : /^\/[^/]+\/?$/.test(url.pathname));
|
||||
} catch { return false; }
|
||||
});
|
||||
if (card.matches('article, [role="article"]') || (profile && card.querySelectorAll('button, [role="button"]').length >= 3)) return card;
|
||||
// Instagram's detail timestamp lives in the action footer. Keep walking
|
||||
// until the card also contains the media, rather than returning that footer.
|
||||
if (card.matches('article, [role="article"]') || (profile && card.querySelectorAll('button, [role="button"]').length >= 3
|
||||
&& (platform !== "instagram" || instagramCardHasMedia(card, base)))) return card;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -313,8 +322,18 @@ const mediaImages = (item: RecordValue, base: string): string[] | null => {
|
||||
}
|
||||
return images.filter(Boolean);
|
||||
};
|
||||
export const readMetaSocial = (doc: Document, target: PlatformTarget): PlatformRead => {
|
||||
const media = metaMedia(doc, target);
|
||||
export const instagramPostCard = (doc: Document, target: PlatformTarget): Element | null => {
|
||||
if (target.platform !== "instagram") return null;
|
||||
for (const link of ownPostLinks(doc.documentElement, "instagram")) {
|
||||
if (!sameTarget(postLinkUrl(link, target.url), target)) continue;
|
||||
const card = metaPostCard(link, "instagram", target.url);
|
||||
if (card && !platformElementHidden(card) && instagramCardHasMedia(card, target.url)) return card;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const readMetaSocial = (doc: Document, target: PlatformTarget, detail?: Document): PlatformRead => {
|
||||
const media = metaMedia(detail || doc, target);
|
||||
if (!media) return { ok: false, reason: "unreadable" };
|
||||
const username = text(record(media.user)?.username);
|
||||
if (target.platform === "threads" && username && username.toLowerCase() !== target.id.split("/")[0]) return { ok:false, reason:"changed" };
|
||||
@@ -324,7 +343,9 @@ export const readMetaSocial = (doc: Document, target: PlatformTarget): PlatformR
|
||||
if (target.platform === "instagram" && Array.isArray(media.carousel_media)) {
|
||||
captions.push(...media.carousel_media.map((slide) => text(record(record(slide)?.caption)?.text)));
|
||||
}
|
||||
if (!readableMetaCard(doc,target,captions)) return {ok:false,reason:"unreadable"};
|
||||
// A fetched, identity-matched detail supplies the full caption for a visible
|
||||
// feed preview. Do not require that preview to contain the untruncated text.
|
||||
if (detail ? !instagramPostCard(doc, target) : !readableMetaCard(doc,target,captions)) return {ok:false,reason:"unreadable"};
|
||||
const slides = Array.from(new Set(captions.slice(1).filter(Boolean)));
|
||||
const combined = slides.join("\n\n");
|
||||
const normalized = (value: string) => value.replace(/\s+/g,"");
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
// CI_BUILD_NUMBER into the archive when present.
|
||||
// Last completed ASC store build: 48 (2026-09-14, Xcode Cloud, v1.70.0).
|
||||
// Build 49 is reserved for the 1.74.0 App Store submission.
|
||||
MARKETING_VERSION = 2.12.0
|
||||
MARKETING_VERSION = 2.14.0
|
||||
CURRENT_PROJECT_VERSION = 49
|
||||
@@ -812,7 +812,7 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 2.12.0;
|
||||
MARKETING_VERSION = 2.14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -902,7 +902,7 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 2.12.0;
|
||||
MARKETING_VERSION = 2.14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
|
||||
+541
-541
File diff suppressed because it is too large.
Load diff
Vendored
+541
-541
File diff suppressed because it is too large.
Load diff
@@ -36,6 +36,12 @@ const iosMarkdownManager = createEdgeEverMarkdownManager({
|
||||
});
|
||||
|
||||
describe("iOS document nodes share the web schema", () => {
|
||||
test("keeps literal ranges and double-tilde strike consistent with the shared codec (#698)", () => {
|
||||
for (const source of ["成本 1~2~3 元", "10~20, 30~40", "~~a~b~~", "~a~~b~~c~"]) {
|
||||
expect(iosMarkdownManager.parse(source)).toEqual(markdownToDoc(source));
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the shared MergeDivider implementation", () => {
|
||||
expect(MergeDivider).toBe(SharedMergeDivider);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "EdgeEver",
|
||||
"slug": "edgeever",
|
||||
"scheme": "edgeever",
|
||||
"version": "2.12.0",
|
||||
"version": "2.14.0",
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"android": {
|
||||
"package": "org.edgeever.mobile",
|
||||
"versionCode": 249,
|
||||
"versionCode": 251,
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#16A06E",
|
||||
"backgroundImage": "./assets/icon.png",
|
||||
|
||||
@@ -18,6 +18,10 @@ import zhihuClipImg from "../../../../docs/assets/best-practices/original-image/
|
||||
import zhihuInNoteImg from "../../../../docs/assets/best-practices/original-image/web-clipper/zhihu/zhihu-in-note.png";
|
||||
import redditClipImg from "../../../../docs/assets/best-practices/original-image/web-clipper/reddit/reddit.png";
|
||||
import redditInNoteImg from "../../../../docs/assets/best-practices/original-image/web-clipper/reddit/reddit-in-note.png";
|
||||
import insClipImg from "../../../../docs/assets/best-practices/original-image/web-clipper/instgram/ins.png";
|
||||
import insInNoteImg from "../../../../docs/assets/best-practices/original-image/web-clipper/instgram/ins-in-note.png";
|
||||
import githubClipImg from "../../../../docs/assets/best-practices/original-image/web-clipper/github/github.png";
|
||||
import githubInNoteImg from "../../../../docs/assets/best-practices/original-image/web-clipper/github/github-in-note.png";
|
||||
import aiMindmapImg from "../../../../docs/assets/best-practices/original-image/ai-diagram/ai-mindmap.png";
|
||||
import aiFlowchartImg from "../../../../docs/assets/best-practices/original-image/ai-diagram/ai-flowchart.png";
|
||||
import aiArchitectureImg from "../../../../docs/assets/best-practices/original-image/ai-diagram/ai-architecture.png";
|
||||
@@ -46,6 +50,8 @@ export type ShowcaseCaseId =
|
||||
| "twitter"
|
||||
| "zhihu"
|
||||
| "reddit"
|
||||
| "instagram"
|
||||
| "github"
|
||||
| "ai-diagrams"
|
||||
| "ai-infographics"
|
||||
| "ai-database"
|
||||
@@ -96,6 +102,14 @@ export const showcaseCaseAssets: Record<ShowcaseCaseId, ShowcaseCaseAssetConfig>
|
||||
layout: "stack",
|
||||
images: [redditClipImg, redditInNoteImg],
|
||||
},
|
||||
instagram: {
|
||||
layout: "stack",
|
||||
images: [insClipImg, insInNoteImg],
|
||||
},
|
||||
github: {
|
||||
layout: "stack",
|
||||
images: [githubClipImg, githubInNoteImg],
|
||||
},
|
||||
"ai-diagrams": {
|
||||
layout: "stack",
|
||||
images: [aiMindmapImg, aiFlowchartImg, aiArchitectureImg],
|
||||
@@ -350,6 +364,45 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "instagram",
|
||||
number: "09",
|
||||
title: "Instagram 帖子与轮播多图剪藏",
|
||||
lead: "使用 EdgeEver Web Clipper 插件在 Instagram 帖子或轮播页面右键点击「保存帖子到 EdgeEver」。",
|
||||
bullets: [
|
||||
"提取作者账号、正文描述、发布时间与原文链接,保留话题标签与提及元数据。",
|
||||
"多图轮播(Carousel)内容完整提取并自动排版为多栏画廊。",
|
||||
],
|
||||
media: [
|
||||
{
|
||||
caption: "1. 右键保存 Instagram 帖子",
|
||||
alt: "右键保存 Instagram 帖子到 EdgeEver",
|
||||
},
|
||||
{
|
||||
caption: "2. 包含完整图集与元数据的笔记效果",
|
||||
alt: "EdgeEver 剪藏后的 Instagram 帖子笔记效果",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
number: "10",
|
||||
title: "GitHub 仓库元数据剪藏",
|
||||
lead: "在 GitHub 仓库页面右键点击「保存仓库信息到 EdgeEver」。",
|
||||
bullets: [
|
||||
"提取仓库名称(Owner/Repo)、简介、官方主页、编程语言、许可证与话题标签,生成结构化技术卡片。",
|
||||
],
|
||||
media: [
|
||||
{
|
||||
caption: "1. 右键保存仓库信息",
|
||||
alt: "右键保存 GitHub 仓库信息到 EdgeEver",
|
||||
},
|
||||
{
|
||||
caption: "2. 结构化仓库卡片预览",
|
||||
alt: "EdgeEver 剪藏后的 GitHub 仓库卡片笔记",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -360,7 +413,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
cases: [
|
||||
{
|
||||
id: "ai-diagrams",
|
||||
number: "09",
|
||||
number: "11",
|
||||
title: "AI 对话生成思维导图、流程图与架构图",
|
||||
lead: "在 AI 伴随助手(或连接的本地 Agent)中输入自然语言指令,在笔记内直接生成可交互图表。",
|
||||
bullets: [
|
||||
@@ -383,7 +436,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "ai-infographics",
|
||||
number: "10",
|
||||
number: "12",
|
||||
title: "AI 生成结构化信息图(时间线、对比矩阵、象限图等)",
|
||||
lead: "利用内置模板库,在 AI 助手对话中描述要呈现的主题与重点,快速生成结构化视觉笔记。",
|
||||
bullets: [
|
||||
@@ -403,7 +456,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "ai-database",
|
||||
number: "11",
|
||||
number: "13",
|
||||
title: "AI 对话构建多维表格",
|
||||
lead: "在 AI 助手中发送自然语言指令(如“生成自媒体选题库”或“生成员工信息登记表”),自动搭建多维数据表。",
|
||||
bullets: [
|
||||
@@ -423,7 +476,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "database-form",
|
||||
number: "12",
|
||||
number: "14",
|
||||
title: "多维表格生成公开收集表单",
|
||||
lead: "在多维表格顶部点击「表单收集」,将当前数据表转为公开在线表单。",
|
||||
bullets: [
|
||||
@@ -443,7 +496,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "acp-agent",
|
||||
number: "13",
|
||||
number: "15",
|
||||
title: "连接本机 Agent (ACP) 实现笔记协同",
|
||||
lead: "EdgeEver 支持 ACP (Agent Client Protocol) 协议,允许桌面端连接本机运行的各类 AI Agent(如 Codex、Claude Code、DeepSeek Harness、Antigravity 等)。",
|
||||
bullets: [
|
||||
@@ -471,7 +524,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
cases: [
|
||||
{
|
||||
id: "note-export-image",
|
||||
number: "14",
|
||||
number: "16",
|
||||
title: "笔记导出多主题长图与代码卡片",
|
||||
lead: "使用「分享为图片」功能,将笔记排版为高分辨率长图或卡片。",
|
||||
bullets: [
|
||||
@@ -491,7 +544,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "poster-studio",
|
||||
number: "15",
|
||||
number: "17",
|
||||
title: "笔记制作社交媒体海报与封面",
|
||||
lead: "在笔记操作菜单中选择「制作海报」(或通过「新建 → 海报」),将笔记标题与要点导入海报设计工作台。",
|
||||
bullets: [
|
||||
@@ -507,7 +560,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "wechat-mp-copy",
|
||||
number: "16",
|
||||
number: "18",
|
||||
title: "复制笔记至微信公众号排版",
|
||||
lead: "在笔记菜单中选择「复制到公众号 / 博客」,将笔记转换为内联 CSS 样式的富文本。",
|
||||
bullets: [
|
||||
@@ -534,7 +587,7 @@ const zhCnCategories: ShowcaseCategoryCopy[] = [
|
||||
cases: [
|
||||
{
|
||||
id: "plugin-ecosystem",
|
||||
number: "17",
|
||||
number: "19",
|
||||
title: "开放插件 API 与自定义插件开发",
|
||||
lead: "EdgeEver 内置插件扩展管理,支持通过 GitHub 仓库或 Manifest 地址一键安装,配置在 Web 与桌面端跨设备隔离同步。",
|
||||
bullets: [
|
||||
@@ -745,6 +798,45 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "instagram",
|
||||
number: "09",
|
||||
title: "Instagram Post & Carousel Clipping",
|
||||
lead: "Right-click any Instagram post or carousel and select \"Save Post to EdgeEver\" with EdgeEver Web Clipper.",
|
||||
bullets: [
|
||||
"Extracts author handle, caption text, timestamp, and source URL while preserving hashtags and mention metadata.",
|
||||
"Captures full multi-image carousel sets, formatted automatically into a clean multi-column gallery layout.",
|
||||
],
|
||||
media: [
|
||||
{
|
||||
caption: "1. Right-Click Save Instagram Post",
|
||||
alt: "Right-click save Instagram post to EdgeEver",
|
||||
},
|
||||
{
|
||||
caption: "2. Note with Metadata & Multi-Image Gallery",
|
||||
alt: "Instagram post clipped into EdgeEver note",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
number: "10",
|
||||
title: "GitHub Repository Metadata Clipping",
|
||||
lead: "Right-click anywhere on the GitHub repository page and select \"Save Repository Info to EdgeEver\".",
|
||||
bullets: [
|
||||
"Extracts repository name (Owner/Repo), description, homepage, language, license, and topic tags into a technical card note.",
|
||||
],
|
||||
media: [
|
||||
{
|
||||
caption: "1. Right-Click Save Repository Info",
|
||||
alt: "Right-click save GitHub repo info to EdgeEver",
|
||||
},
|
||||
{
|
||||
caption: "2. Structured Repository Card Note",
|
||||
alt: "EdgeEver clipped GitHub repository card note effect",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -755,7 +847,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
cases: [
|
||||
{
|
||||
id: "ai-diagrams",
|
||||
number: "09",
|
||||
number: "11",
|
||||
title: "AI Conversational Generation of Mind Maps, Flowcharts & Architecture Diagrams",
|
||||
lead: "Prompt the AI Co-pilot (or connected local Agent) with natural language to create editable diagrams directly in notes.",
|
||||
bullets: [
|
||||
@@ -778,7 +870,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "ai-infographics",
|
||||
number: "10",
|
||||
number: "12",
|
||||
title: "AI Generation of Structured Infographics (Timelines, Matrices & Quadrants)",
|
||||
lead: "Use built-in infographic templates (timelines, comparison matrices, quadrants, and hierarchy charts) by prompting the AI Co-pilot with your topic.",
|
||||
bullets: [
|
||||
@@ -798,7 +890,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "ai-database",
|
||||
number: "11",
|
||||
number: "13",
|
||||
title: "AI Conversational Database Table Generation",
|
||||
lead: "Send a prompt to the AI Co-pilot (e.g., \"Create a database table for social media content planning\" or \"Generate an employee onboarding tracker\") to construct complete data tables.",
|
||||
bullets: [
|
||||
@@ -818,7 +910,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "database-form",
|
||||
number: "12",
|
||||
number: "14",
|
||||
title: "Public Online Form Collection from Database Tables",
|
||||
lead: "Click \"Form Collection\" at the top of any database table to generate a public online data entry form.",
|
||||
bullets: [
|
||||
@@ -838,7 +930,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "acp-agent",
|
||||
number: "13",
|
||||
number: "15",
|
||||
title: "Connecting Local Agents via ACP for Note Interaction",
|
||||
lead: "EdgeEver natively supports ACP (Agent Client Protocol), allowing desktop clients to connect with local AI agent environments (such as Codex, Claude Code, DeepSeek Harness, Antigravity, etc.).",
|
||||
bullets: [
|
||||
@@ -866,7 +958,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
cases: [
|
||||
{
|
||||
id: "note-export-image",
|
||||
number: "14",
|
||||
number: "16",
|
||||
title: "Note Export to Long Image & Code Cards",
|
||||
lead: "Use the \"Share as Image\" feature to render notes into high-resolution long images or cards.",
|
||||
bullets: [
|
||||
@@ -886,7 +978,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "poster-studio",
|
||||
number: "15",
|
||||
number: "17",
|
||||
title: "Social Media Poster & Cover Design from Notes",
|
||||
lead: "Click \"Create Poster\" in any note's action menu (or via \"New → Poster\") to load key takeaways and titles into the Poster Design Studio.",
|
||||
bullets: [
|
||||
@@ -902,7 +994,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
},
|
||||
{
|
||||
id: "wechat-mp-copy",
|
||||
number: "16",
|
||||
number: "18",
|
||||
title: "Copying Notes to WeChat Official Account & Blogs",
|
||||
lead: "Select \"Copy to Official Account / Blog\" from the note menu to convert notes into rich text with inline CSS.",
|
||||
bullets: [
|
||||
@@ -929,7 +1021,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
cases: [
|
||||
{
|
||||
id: "plugin-ecosystem",
|
||||
number: "17",
|
||||
number: "19",
|
||||
title: "Open Plugin API & Custom Plugin Development",
|
||||
lead: "EdgeEver features an extension manager supporting one-click installation via GitHub repository URLs or Manifest links, with cross-device workspace synchronization between Web and Desktop.",
|
||||
bullets: [
|
||||
@@ -968,7 +1060,7 @@ const enUsCategories: ShowcaseCategoryCopy[] = [
|
||||
export const bestPracticesPageCopy: Record<SiteLocale, BestPracticesPageLocaleCopy> = {
|
||||
"zh-CN": {
|
||||
eyebrow: "BEST PRACTICES & SHOWCASE",
|
||||
title: "EdgeEver 17 个真机场景与实操指南",
|
||||
title: "EdgeEver 19 个真机场景与实操指南",
|
||||
description:
|
||||
"涵盖三栏沉浸写作、微信与全渠道结构化剪藏、AI 对话生成导图/信息图/多维表格、本机 ACP Agent 协同、海报与长图分享及开放插件生态的完整实机演示。",
|
||||
zoomHint: "点击任意截图可放大查看高清细节",
|
||||
@@ -981,7 +1073,7 @@ export const bestPracticesPageCopy: Record<SiteLocale, BestPracticesPageLocaleCo
|
||||
},
|
||||
"en-US": {
|
||||
eyebrow: "BEST PRACTICES & SHOWCASE",
|
||||
title: "17 Real-World EdgeEver Workflows & Showcase",
|
||||
title: "19 Real-World EdgeEver Workflows & Showcase",
|
||||
description:
|
||||
"A visual walkthrough of core EdgeEver workflows across desktop and mobile: three-pane writing, omnichannel clipping, AI diagrams & databases, local ACP agents, poster studio, and open plugins.",
|
||||
zoomHint: "Click any screenshot to inspect full-resolution details",
|
||||
@@ -994,7 +1086,7 @@ export const bestPracticesPageCopy: Record<SiteLocale, BestPracticesPageLocaleCo
|
||||
},
|
||||
ja: {
|
||||
eyebrow: "BEST PRACTICES & SHOWCASE",
|
||||
title: "EdgeEver 17 の実機デモとベストプラクティス",
|
||||
title: "EdgeEver 19 の実機デモとベストプラクティス",
|
||||
description:
|
||||
"クラシックな三ペイン執筆、マルチチャネル Web クリッピング、AI による図解・インフォグラフィック・多次元テーブル生成、ローカル ACP Agent 連携、ポスター作成、プラグイン拡張までの実機ワークフロー集です。",
|
||||
zoomHint: "スクリーンショットをクリックすると拡大表示できます",
|
||||
@@ -1007,7 +1099,7 @@ export const bestPracticesPageCopy: Record<SiteLocale, BestPracticesPageLocaleCo
|
||||
},
|
||||
pl: {
|
||||
eyebrow: "BEST PRACTICES & SHOWCASE",
|
||||
title: "17 praktycznych scenariuszy i prezentacja EdgeEver",
|
||||
title: "19 praktycznych scenariuszy i prezentacja EdgeEver",
|
||||
description:
|
||||
"Wizualny przewodnik po najważniejszych przepływach pracy w EdgeEver na komputerze i telefonie: pisanie w trzech panelach, wycinanie treści z sieci, diagramy i bazy danych AI, lokalni agenci ACP, studio plakatów i wtyczki.",
|
||||
zoomHint: "Kliknij dowolny zrzut ekranu, aby powiększyć",
|
||||
|
||||
@@ -55,7 +55,7 @@ export const jaSiteCopy = {
|
||||
heading: "セルフホストの知識管理と AI のために作り直した",
|
||||
subheading:
|
||||
"広告、端末数の制限、高いサブスクはやめましょう。速さ、開放性、自分で守れるプライバシーを前提にしています。",
|
||||
showcaseCta: "17 の実機デモとベストプラクティスを見る",
|
||||
showcaseCta: "19 の実機デモとベストプラクティスを見る",
|
||||
card1: {
|
||||
badge: "クラシック UI",
|
||||
title: "三ペインはそのまま、より軽く速く",
|
||||
@@ -273,7 +273,7 @@ export const jaSiteCopy = {
|
||||
cta: "移行ガイドを読む",
|
||||
},
|
||||
{
|
||||
title: "17 の実機デモとベストプラクティス",
|
||||
title: "19 の実機デモとベストプラクティス",
|
||||
summary: "三ペイン執筆、マルチチャネル取り込み、AI 図解・多次元テーブル、ローカル ACP 連携、ポスター作成の実機例。",
|
||||
href: "/guides/best-practices",
|
||||
cta: "実機デモを見る",
|
||||
|
||||
@@ -55,7 +55,7 @@ export const plSiteCopy = {
|
||||
heading: "Zbudowany od nowa z myślą o samodzielnym hostingu wiedzy i pracy z AI",
|
||||
subheading:
|
||||
"Bez reklam, limitów urządzeń i drogich subskrypcji. Każdy szczegół służy szybkiemu, otwartemu i prywatnemu „drugiemu mózgowi”.",
|
||||
showcaseCta: "Zobacz 17 praktycznych scenariuszy i zrzutów ekranu",
|
||||
showcaseCta: "Zobacz 19 praktycznych scenariuszy i zrzutów ekranu",
|
||||
card1: {
|
||||
badge: "Klasyczny interfejs",
|
||||
title: "Klasyczny układ trzech paneli – szybszy i lżejszy",
|
||||
@@ -273,7 +273,7 @@ export const plSiteCopy = {
|
||||
cta: "Przeczytaj poradnik migracji",
|
||||
},
|
||||
{
|
||||
title: "17 praktycznych scenariuszy",
|
||||
title: "19 praktycznych scenariuszy",
|
||||
summary: "Wizualny przegląd pisania w trzech panelach, wycinania treści, diagramów i tabel AI, lokalnych agentów ACP i plakatów.",
|
||||
href: "/guides/best-practices",
|
||||
cta: "Zobacz przykłady",
|
||||
|
||||
@@ -96,7 +96,8 @@ describe("site locale routing", () => {
|
||||
collectKeys(bestPracticesPageCopy["zh-CN"]),
|
||||
);
|
||||
const cases = bestPracticesPageCopy[locale].categories.flatMap((c) => c.cases);
|
||||
expect(cases).toHaveLength(17);
|
||||
// Every configured showcase appears exactly once in each locale.
|
||||
expect(cases.map((item) => item.id).sort()).toEqual(Object.keys(showcaseCaseAssets).sort());
|
||||
for (const item of cases) {
|
||||
const assets = showcaseCaseAssets[item.id];
|
||||
expect(assets).toBeDefined();
|
||||
|
||||
@@ -145,7 +145,7 @@ export const siteCopy = {
|
||||
eyebrow: "WHY EDGEEVER",
|
||||
heading: "重新定义自托管知识工作区",
|
||||
subheading: "告别商业笔记的臃肿广告、设备限制与昂贵订阅。每一个设计细节,都旨在打造流畅、开放、隐私可控且原生支持 AI 的第二大脑。",
|
||||
showcaseCta: "查看 17 个真机案例演示与最佳实践",
|
||||
showcaseCta: "查看 19 个真机案例演示与最佳实践",
|
||||
card1: {
|
||||
badge: "经典重现",
|
||||
title: "经典三栏与专注模式,熟悉更轻快",
|
||||
@@ -362,7 +362,7 @@ export const siteCopy = {
|
||||
cta: "查看迁移指南",
|
||||
},
|
||||
{
|
||||
title: "17 个真机场景与实操指南",
|
||||
title: "19 个真机场景与实操指南",
|
||||
summary: "浏览三栏写作、微信与全渠道剪藏、AI 导图/信息图/多维表格、本机 ACP 协同及海报排版实机演示。",
|
||||
href: "/guides/best-practices",
|
||||
cta: "查看实机案例",
|
||||
@@ -428,7 +428,7 @@ export const siteCopy = {
|
||||
eyebrow: "WHY EDGEEVER",
|
||||
heading: "Rebuilt for Self-Hosted Knowledge & AI Workflows",
|
||||
subheading: "Say goodbye to ads, device caps, and expensive subscriptions. Every detail is crafted for a fast, open, and private second brain.",
|
||||
showcaseCta: "Explore 17 real-world workflows & screenshots",
|
||||
showcaseCta: "Explore 19 real-world workflows & screenshots",
|
||||
card1: {
|
||||
badge: "Classic UI",
|
||||
title: "Classic Three-Pane Layout, Faster & Lighter",
|
||||
@@ -645,7 +645,7 @@ export const siteCopy = {
|
||||
cta: "Read migration guide",
|
||||
},
|
||||
{
|
||||
title: "17 Real-World Workflows",
|
||||
title: "19 Real-World Workflows",
|
||||
summary: "Visual walkthrough of three-pane editing, omnichannel clipping, AI diagrams & databases, local ACP agents, and poster design.",
|
||||
href: "/guides/best-practices",
|
||||
cta: "View showcase",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AI_ASSISTANT_LAUNCHER_POSITION } from "@/lib/ai-assistant-launcher";
|
||||
import { editorClipboardTextSerializer } from "@/lib/editor-clipboard";
|
||||
import { useRef, useState, useEffect, useLayoutEffect, useCallback, useMemo, lazy, Suspense, type CSSProperties, type FocusEvent as ReactFocusEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
ImageUploadPlaceholderExtension,
|
||||
addImageUploadPlaceholder,
|
||||
createResourceUploadPlaceholder,
|
||||
getImageUploadPlaceholderPosition,
|
||||
removeImageUploadPlaceholder,
|
||||
waitForImageSourceReady,
|
||||
updateImageUploadPlaceholder,
|
||||
@@ -1050,7 +1052,7 @@ const RichEditorPane = ({
|
||||
}
|
||||
}, [repository, t]);
|
||||
|
||||
const insertResourceFiles = useCallback((files: File[]) => {
|
||||
const insertResourceFiles = useCallback((files: File[], dropPosition?: number) => {
|
||||
const currentMemo = memoRef.current;
|
||||
const currentEditor = editorRef.current;
|
||||
|
||||
@@ -1067,7 +1069,7 @@ const RichEditorPane = ({
|
||||
memoRef.current?.id,
|
||||
);
|
||||
const interactionVersionAtRequest = editorCanvasInteractionVersionRef.current;
|
||||
const placeholderPosition = currentEditor.state.selection.from;
|
||||
const placeholderPosition = dropPosition ?? currentEditor.state.selection.from;
|
||||
const placeholderByFile = new Map(files.map((file) => [file,
|
||||
createResourceUploadPlaceholder(file, {
|
||||
imagePreparing: t("editor.uploadState.imagePreparing"),
|
||||
@@ -1095,7 +1097,7 @@ const RichEditorPane = ({
|
||||
|
||||
// Read the selection only after earlier resource insertions complete.
|
||||
// Rapid consecutive pastes otherwise race with the same stale cursor.
|
||||
const insertionTarget = getResourceInsertionTarget(insertionEditor.state.selection);
|
||||
const insertionTarget = dropPosition ?? getResourceInsertionTarget(insertionEditor.state.selection);
|
||||
setImageUploadState("uploading");
|
||||
const imageReadiness: Promise<void>[] = [];
|
||||
|
||||
@@ -1182,8 +1184,16 @@ const RichEditorPane = ({
|
||||
});
|
||||
|
||||
if (content.length > 0) {
|
||||
// Drop anchors follow every document transaction, including edits made
|
||||
// while this upload was queued. Never fall back to an unrelated caret
|
||||
// if the user deleted the drop location or reset the document.
|
||||
const mappedDropPosition = dropPosition === undefined ? undefined
|
||||
: getImageUploadPlaceholderPosition(activeEditor.state, placeholders[0]!.id);
|
||||
if (mappedDropPosition === null) {
|
||||
throw new Error("The attachment drop location no longer exists");
|
||||
}
|
||||
const safeInsertionTarget = clampResourceInsertionTarget(
|
||||
insertionTarget,
|
||||
mappedDropPosition ?? insertionTarget,
|
||||
activeEditor.state.doc.content.size,
|
||||
);
|
||||
const updateSelection = shouldSelectInsertedResources(
|
||||
@@ -1277,6 +1287,7 @@ const RichEditorPane = ({
|
||||
: { type: "doc", content: [{ type: "paragraph" }] },
|
||||
editable: Boolean(memo && !effectiveReadOnly && editorIsHydratedForCurrentMemo),
|
||||
editorProps: {
|
||||
clipboardTextSerializer: editorClipboardTextSerializer,
|
||||
attributes: {
|
||||
class: "edgeever-note-rich-editor prose prose-slate max-w-none focus:outline-none min-h-[240px] lg:min-h-[180px]",
|
||||
spellcheck: spellcheckEnabled ? "true" : "false",
|
||||
@@ -1424,7 +1435,12 @@ const RichEditorPane = ({
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
insertResourceFiles(getResourceFilesFromDataTransfer(event.dataTransfer));
|
||||
// Use the same coordinate lookup as ProseMirror's external drop cursor.
|
||||
// A drop inserts at a point; it must never replace the old text selection.
|
||||
const dropPosition = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
if (dropPosition) {
|
||||
insertResourceFiles(getResourceFilesFromDataTransfer(event.dataTransfer), dropPosition.pos);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
@@ -3956,7 +3972,9 @@ const RichEditorPane = ({
|
||||
to: stored?.to ?? draft.to,
|
||||
pos: stored?.pos ?? draft.pos,
|
||||
});
|
||||
editor.chain().focus(stored?.from ?? draft.from ?? null, { scrollIntoView: true }).run();
|
||||
// Keep the selection the insert left after the formula; focusing
|
||||
// `from` put the caret back in front of it, so typing went there.
|
||||
editor.chain().focus(null, { scrollIntoView: true }).run();
|
||||
}}
|
||||
onRemove={
|
||||
mathFormulaDraft && typeof mathFormulaDraft.pos === "number"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import katex from "katex";
|
||||
import { Sigma } from "lucide-react";
|
||||
import { FlaskConical, Sigma } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,6 +13,17 @@ import {
|
||||
import type { MathFormulaDraft, MathFormulaKind } from "@/components/editor/math-formula";
|
||||
import { MathVisualInput } from "@/components/editor/MathVisualInput";
|
||||
import { hasMathPlaceholders, renderMathPreview } from "@/components/editor/math-formula-preview";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
CHEMISTRY_EXAMPLES,
|
||||
insertChemistryExample,
|
||||
isChemistryLatex,
|
||||
mathFormulaSubjectOf,
|
||||
type MathFormulaSubject,
|
||||
} from "@/components/editor/chemistry-formula";
|
||||
|
||||
const SEGMENT_ACTIVE = "flex flex-1 items-center justify-center gap-1.5 rounded-sm bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-950";
|
||||
const SEGMENT_IDLE = "flex flex-1 items-center justify-center gap-1.5 rounded-sm px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50";
|
||||
|
||||
export const MathFormulaDialog = ({
|
||||
open,
|
||||
@@ -34,17 +45,40 @@ export const MathFormulaDialog = ({
|
||||
const [kind, setKind] = useState<MathFormulaKind>(draft?.kind ?? "inline");
|
||||
const [latex, setLatex] = useState(draft?.latex ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [subject, setSubject] = useState<MathFormulaSubject>(mathFormulaSubjectOf(draft?.latex ?? ""));
|
||||
const [inputMode, setInputMode] = useState<"visual" | "source">("visual");
|
||||
const editing = typeof draft?.pos === "number";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const initialLatex = draft?.latex ?? "";
|
||||
setKind(draft?.kind ?? "inline");
|
||||
setLatex(draft?.latex ?? "");
|
||||
setLatex(initialLatex);
|
||||
setError(null);
|
||||
setInputMode("visual");
|
||||
setSubject(mathFormulaSubjectOf(initialLatex));
|
||||
// The visual math editor does not understand \ce or \pu and would rewrite them.
|
||||
setInputMode(isChemistryLatex(initialLatex) ? "source" : "visual");
|
||||
}, [draft, open]);
|
||||
|
||||
const changeSubject = (next: MathFormulaSubject) => {
|
||||
setSubject(next);
|
||||
setError(null);
|
||||
setInputMode(next === "chemistry" || isChemistryLatex(latex) ? "source" : "visual");
|
||||
};
|
||||
|
||||
const insertExample = (example: string) => {
|
||||
const input = latexInputRef.current;
|
||||
const start = input?.selectionStart ?? latex.length;
|
||||
const end = input?.selectionEnd ?? latex.length;
|
||||
const next = insertChemistryExample(latex, start, end, example);
|
||||
setLatex(next.value);
|
||||
setError(null);
|
||||
window.requestAnimationFrame(() => {
|
||||
latexInputRef.current?.focus();
|
||||
latexInputRef.current?.setSelectionRange(next.caret, next.caret);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && inputMode === "source") latexInputRef.current?.focus();
|
||||
}, [inputMode, open]);
|
||||
@@ -84,13 +118,29 @@ export const MathFormulaDialog = ({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* min-w-0: a long formula scrolls inside the preview instead of widening the dialog. */}
|
||||
<form
|
||||
className="space-y-4 px-5 py-4"
|
||||
className="min-w-0 space-y-4 px-5 py-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<div className="flex rounded-md border border-slate-200 p-0.5" role="group" aria-label={t("mathFormulaDialog.subjectLabel")}>
|
||||
{(["math", "chemistry"] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={subject === value ? SEGMENT_ACTIVE : SEGMENT_IDLE}
|
||||
aria-pressed={subject === value}
|
||||
onClick={() => changeSubject(value)}
|
||||
>
|
||||
{value === "math" ? <Sigma className="h-3.5 w-3.5" aria-hidden="true" /> : <FlaskConical className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{value === "math" ? t("mathFormulaDialog.subjectMath") : t("mathFormulaDialog.subjectChemistry")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!editing && (
|
||||
<div className="flex rounded-md border border-slate-200 p-0.5">
|
||||
{(["inline", "block"] as const).map((value) => (
|
||||
@@ -116,12 +166,14 @@ export const MathFormulaDialog = ({
|
||||
<label htmlFor={latexId} className="text-xs font-medium text-slate-600">
|
||||
{t(inputMode === "visual" ? "mathFormulaDialog.visualLabel" : "mathFormulaDialog.latexLabel")}
|
||||
</label>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => {
|
||||
setInputMode(inputMode === "visual" ? "source" : "visual");
|
||||
setError(null);
|
||||
}}>
|
||||
{t(inputMode === "visual" ? "mathFormulaDialog.sourceMode" : "mathFormulaDialog.visualMode")}
|
||||
</Button>
|
||||
{subject === "math" && !isChemistryLatex(latex) ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => {
|
||||
setInputMode(inputMode === "visual" ? "source" : "visual");
|
||||
setError(null);
|
||||
}}>
|
||||
{t(inputMode === "visual" ? "mathFormulaDialog.sourceMode" : "mathFormulaDialog.visualMode")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{inputMode === "visual" ? (open ? (
|
||||
<MathVisualInput
|
||||
@@ -142,7 +194,7 @@ export const MathFormulaDialog = ({
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="w-full resize-y rounded-md border border-slate-200 bg-card px-3 py-2 font-mono text-sm text-slate-900 outline-none ring-slate-900/15 placeholder:text-slate-400 focus:border-slate-900 focus:ring-2"
|
||||
placeholder={t("mathFormulaDialog.latexPlaceholder")}
|
||||
placeholder={subject === "chemistry" ? "\\ce{2H2 + O2 -> 2H2O}" : t("mathFormulaDialog.latexPlaceholder")}
|
||||
aria-invalid={Boolean(error) || undefined}
|
||||
onChange={(event) => {
|
||||
setLatex(event.target.value);
|
||||
@@ -151,8 +203,37 @@ export const MathFormulaDialog = ({
|
||||
/>
|
||||
)}
|
||||
{inputMode === "source" && incomplete && <p className="text-xs leading-5 text-slate-500" role="status">{t("mathFormulaDialog.incomplete")}</p>}
|
||||
{subject === "chemistry" ? (
|
||||
<p className="text-xs leading-5 text-slate-500">{t("mathFormulaDialog.chemistryHint")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{subject === "chemistry" ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-slate-600">{t("mathFormulaDialog.chemistryExamplesLabel")}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CHEMISTRY_EXAMPLES.map((example) => (
|
||||
<Tooltip key={example.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs font-normal"
|
||||
onClick={() => insertExample(example.latex)}
|
||||
>
|
||||
{t(`mathFormulaDialog.chemistryExamples.${example.key}`)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="font-mono">{example.latex}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p id={previewId} className="text-xs font-medium text-slate-600">
|
||||
{t("mathFormulaDialog.preview")}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { shareQrCodeFileName, shareQrCodePngBlob } from "./ShareQrCode.tsx";
|
||||
|
||||
const source = (name) => readFileSync(new URL(`./${name}`, import.meta.url), "utf8");
|
||||
|
||||
@@ -37,4 +38,65 @@ describe("share link QR code", () => {
|
||||
expect(markup).not.toContain("<title");
|
||||
expect(markup).toContain("<path");
|
||||
});
|
||||
|
||||
test("offers PNG and SVG downloads of a print-size code with a quiet zone", () => {
|
||||
const component = source("ShareQrCode.tsx");
|
||||
expect(component).toContain('t("sharing.qrCodeDownloadPng")');
|
||||
expect(component).toContain('t("sharing.qrCodeDownloadSvg")');
|
||||
expect(component).toContain("marginSize={QR_DOWNLOAD_MARGIN}");
|
||||
|
||||
const markup = renderToStaticMarkup(createElement(QRCodeSVG, {
|
||||
value: "https://notes.example/share/abc",
|
||||
size: 1024,
|
||||
level: "M",
|
||||
marginSize: 4,
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
}));
|
||||
expect(markup).toContain('xmlns="http://www.w3.org/2000/svg"');
|
||||
expect(markup).toContain('width="1024"');
|
||||
});
|
||||
|
||||
test("names the downloaded file after the share address", () => {
|
||||
expect(shareQrCodeFileName("https://notes.example/share/meeting-notes", "png")).toBe("edgeever-share-qr-meeting-notes.png");
|
||||
expect(shareQrCodeFileName("https://notes.example/share/notebook/abc_DEF-1", "svg")).toBe("edgeever-share-qr-abc_DEF-1.svg");
|
||||
expect(shareQrCodeFileName("https://notes.example/s/%3Cx%3E", "png")).toBe("edgeever-share-qr-3Cx3E.png");
|
||||
expect(shareQrCodeFileName("not a url", "png")).toBe("edgeever-share-qr.png");
|
||||
});
|
||||
|
||||
test("exports 1024 physical pixels at different screen densities without resizing the source", async () => {
|
||||
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
|
||||
try {
|
||||
for (const ratio of [1, 1.25, 2, 3]) {
|
||||
const sourceCanvas = { width: 1024 * ratio, height: 1024 * ratio };
|
||||
let drawnSource;
|
||||
const context = {
|
||||
imageSmoothingEnabled: true,
|
||||
drawImage(source) { drawnSource = source; },
|
||||
};
|
||||
const exportCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => context,
|
||||
toBlob(callback, type) {
|
||||
callback(new Blob([`${this.width}x${this.height}`], { type }));
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: { createElement: () => exportCanvas },
|
||||
});
|
||||
|
||||
const blob = await shareQrCodePngBlob(sourceCanvas);
|
||||
expect(blob.type).toBe("image/png");
|
||||
expect(await blob.text()).toBe("1024x1024");
|
||||
expect(drawnSource).toBe(sourceCanvas);
|
||||
expect(context.imageSmoothingEnabled).toBe(false);
|
||||
expect(sourceCanvas.width).toBe(1024 * ratio);
|
||||
expect(sourceCanvas.height).toBe(1024 * ratio);
|
||||
}
|
||||
} finally {
|
||||
if (originalDocument) Object.defineProperty(globalThis, "document", originalDocument);
|
||||
else delete globalThis.document;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,38 @@
|
||||
import { ChevronDown, QrCode } from "lucide-react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { ChevronDown, Download, QrCode } from "lucide-react";
|
||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { canvasToPngBlob, downloadBlob } from "@/lib/infographic-image-export";
|
||||
|
||||
// Large enough for print, with the standard four-module quiet zone around it.
|
||||
const QR_DOWNLOAD_SIZE = 1024;
|
||||
const QR_DOWNLOAD_MARGIN = 4;
|
||||
|
||||
export const shareQrCodePngBlob = (source: HTMLCanvasElement) => {
|
||||
// QRCodeCanvas scales its backing store by devicePixelRatio. Export physical
|
||||
// pixels at a fixed size regardless of screen density or browser zoom.
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = QR_DOWNLOAD_SIZE;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("canvas");
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.drawImage(source, 0, 0, QR_DOWNLOAD_SIZE, QR_DOWNLOAD_SIZE);
|
||||
return canvasToPngBlob(canvas);
|
||||
};
|
||||
|
||||
/** File name from the last path segment of the link: the custom address or the token. */
|
||||
export const shareQrCodeFileName = (url: string, extension: "png" | "svg") => {
|
||||
let segment = "";
|
||||
try {
|
||||
segment = new URL(url).pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
} catch {
|
||||
// Keep the generic name for a value that is not a URL.
|
||||
}
|
||||
const safe = segment.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 48);
|
||||
return `edgeever-share-qr${safe ? `-${safe}` : ""}.${extension}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scannable share link for opening a share on another device. Callers render it
|
||||
@@ -10,6 +40,19 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
||||
*/
|
||||
export const ShareQrCode = ({ url, hint }: { url: string; hint: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const downloadPng = async () => {
|
||||
if (!canvasRef.current) return;
|
||||
downloadBlob(await shareQrCodePngBlob(canvasRef.current), shareQrCodeFileName(url, "png"));
|
||||
};
|
||||
|
||||
const downloadSvg = () => {
|
||||
if (!svgRef.current) return;
|
||||
const markup = new XMLSerializer().serializeToString(svgRef.current);
|
||||
downloadBlob(new Blob([markup], { type: "image/svg+xml;charset=utf-8" }), shareQrCodeFileName(url, "svg"));
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible>
|
||||
@@ -36,6 +79,38 @@ export const ShareQrCode = ({ url, hint }: { url: string; hint: string }) => {
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="text-center text-xs leading-5 text-muted-foreground">{hint}</figcaption>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void downloadPng()}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("sharing.qrCodeDownloadPng")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={downloadSvg}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("sharing.qrCodeDownloadSvg")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Download sources: the same code at print size with a quiet zone. */}
|
||||
<div hidden aria-hidden="true">
|
||||
<QRCodeCanvas
|
||||
ref={canvasRef}
|
||||
value={url}
|
||||
size={QR_DOWNLOAD_SIZE}
|
||||
level="M"
|
||||
marginSize={QR_DOWNLOAD_MARGIN}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#000000"
|
||||
/>
|
||||
<QRCodeSVG
|
||||
ref={svgRef}
|
||||
value={url}
|
||||
size={QR_DOWNLOAD_SIZE}
|
||||
level="M"
|
||||
marginSize={QR_DOWNLOAD_MARGIN}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#000000"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -29,8 +29,7 @@ const ImageGalleryNodeView = ({ editor, node, selected, updateAttributes }: Node
|
||||
|
||||
const setGalleryAttributes = useCallback((attributes: { layout?: ImageGalleryLayout; fit?: ImageGalleryFit }) => {
|
||||
updateAttributes(attributes);
|
||||
editor.commands.focus(undefined, { scrollIntoView: false });
|
||||
}, [editor, updateAttributes]);
|
||||
}, [updateAttributes]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
@@ -39,6 +38,7 @@ const ImageGalleryNodeView = ({ editor, node, selected, updateAttributes }: Node
|
||||
data-image-count={node.childCount}
|
||||
data-image-gallery-layout={layout}
|
||||
data-image-gallery-fit={fit}
|
||||
tabIndex={isEditable ? 0 : undefined}
|
||||
>
|
||||
{isEditable ? (
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type MouseEvent, type Pointer
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer, useEditorState, type NodeViewProps } from "@tiptap/react";
|
||||
import { AlignLeft, AlignCenter, AlignRight, Maximize2 } from "lucide-react";
|
||||
import { AlignLeft, AlignCenter, AlignRight, Maximize2, MoveHorizontal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getImageReferrerPolicy, IMAGE_GALLERY_NODE_TYPE, imageAlignmentMargins, parseImageAlignment, type ImageAlignment } from "@edgeever/shared";
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isImageInGallery } from "./image-editing-context";
|
||||
import { createImageSelectionPlugin, imageSelectionKey } from "./image-selection";
|
||||
import { createImageDragPlugin } from "./image-drag";
|
||||
|
||||
export type ImageMenuRequestDetail = {
|
||||
element: HTMLElement;
|
||||
@@ -292,7 +293,8 @@ const ResizableImageNodeView = ({
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
draggable={editable}
|
||||
data-drag-handle={editable ? "" : undefined}
|
||||
referrerPolicy={getImageReferrerPolicy(src)}
|
||||
onClick={(event) => {
|
||||
// A positioned figure does not occupy the full block wrapper. Select
|
||||
@@ -349,6 +351,19 @@ const ResizableImageNodeView = ({
|
||||
<TooltipContent side="top">{t(`editor.imageAlignments.${value}`)}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="edgeever-image-resize-handle"
|
||||
aria-label={t("editor.resizeImage")}
|
||||
onPointerDown={startResize}
|
||||
>
|
||||
<MoveHorizontal aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t("editor.resizeImage")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
{canGroupAdjacentImages ? (
|
||||
@@ -362,19 +377,6 @@ const ResizableImageNodeView = ({
|
||||
<span>{t("editor.imageGallery.group")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="edgeever-image-resize-handle"
|
||||
aria-label={t("editor.resizeImage")}
|
||||
onPointerDown={startResize}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t("editor.resizeImage")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>)}
|
||||
</div>
|
||||
<div ref={actionsRef} className="edgeever-image-resource-actions" />
|
||||
@@ -391,7 +393,7 @@ const ResizableImageNodeView = ({
|
||||
|
||||
export const ResizableImage = Image.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [...(this.parent?.() ?? []), createImageSelectionPlugin()];
|
||||
return [...(this.parent?.() ?? []), createImageSelectionPlugin(), createImageDragPlugin()];
|
||||
},
|
||||
addPasteRules() {
|
||||
return [createMarkdownImagePasteRule(this.type)];
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CHEMISTRY_EXAMPLES, insertChemistryExample, isChemistryLatex, mathFormulaSubjectOf } from "./chemistry-formula.ts";
|
||||
import { renderMathPreview } from "./math-formula-preview.ts";
|
||||
|
||||
const locales = ["en-US", "pl", "zh-CN", "ja", "ru"].map((name) =>
|
||||
readFileSync(new URL(`../../../../../packages/shared/src/i18n/${name}.ts`, import.meta.url), "utf8"));
|
||||
|
||||
describe("chemistry formulas in the formula dialog", () => {
|
||||
test("recognizes mhchem formulas so they open in the chemistry mode", () => {
|
||||
for (const latex of ["\\ce{H2O}", "x + \\ce{CO2}", "\\pu{5 mol}", "\\ce {NaCl}"]) {
|
||||
expect(isChemistryLatex(latex)).toBe(true);
|
||||
expect(mathFormulaSubjectOf(latex)).toBe("chemistry");
|
||||
}
|
||||
for (const latex of ["", "E = mc^2", "\\cent", "\\cos x", "\\put"]) {
|
||||
expect(isChemistryLatex(latex)).toBe(false);
|
||||
expect(mathFormulaSubjectOf(latex)).toBe("math");
|
||||
}
|
||||
});
|
||||
|
||||
test("every example renders inline and as a display formula, with a label in every locale", () => {
|
||||
expect(CHEMISTRY_EXAMPLES.length).toBeGreaterThanOrEqual(8);
|
||||
for (const example of CHEMISTRY_EXAMPLES) {
|
||||
expect(isChemistryLatex(example.latex)).toBe(true);
|
||||
expect(renderMathPreview(example.latex, "inline")).toContain('class="katex"');
|
||||
expect(renderMathPreview(example.latex, "block")).toContain("katex-display");
|
||||
for (const catalog of locales) expect(catalog).toMatch(new RegExp(`\\n\\s+${example.key}: "`));
|
||||
}
|
||||
});
|
||||
|
||||
test("the preview renders reactions with arrows and charges instead of an error", () => {
|
||||
const html = renderMathPreview("\\ce{2H2 + O2 -> 2H2O}", "inline");
|
||||
expect(html).toContain("katex");
|
||||
expect(html).not.toContain("#cc0000");
|
||||
expect(renderMathPreview("\\ce{H2O", "inline")).toBe("");
|
||||
});
|
||||
|
||||
// `|` marks the caret (or `[…]` a selection) in the field before the example is inserted.
|
||||
const insertAt = (template, example) => {
|
||||
const start = template.includes("[") ? template.indexOf("[") : template.indexOf("|");
|
||||
const value = template.replace(/[|[\]]/g, "");
|
||||
const end = template.includes("]") ? template.indexOf("]") - 1 : start;
|
||||
const result = insertChemistryExample(value, start, end, example);
|
||||
return `${result.value.slice(0, result.caret)}|${result.value.slice(result.caret)}`;
|
||||
};
|
||||
|
||||
test("inserts an example into an empty field, at the caret, or inside an open command of the same kind", () => {
|
||||
expect(insertAt("|", "\\ce{H2O}")).toBe("\\ce{H2O}|");
|
||||
expect(insertAt("Water:|", "\\ce{H2O}")).toBe("Water: \\ce{H2O}|");
|
||||
expect(insertAt("a |b", "\\ce{H2O}")).toBe("a \\ce{H2O} |b");
|
||||
expect(insertAt("\\ce{A -> |2H2O}", "\\pu{1 mol}")).toBe("\\ce{A -> \\pu{1 mol} |2H2O}");
|
||||
expect(insertAt("\\ce{A + |B}", "\\ce{H2O}")).toBe("\\ce{A + H2O |B}");
|
||||
expect(insertAt("\\ce{A -> |}", "\\ce{H2O}")).toBe("\\ce{A -> H2O|}");
|
||||
expect(insertAt("\\pu{5 |}", "\\pu{123 kJ/mol}")).toBe("\\pu{5 123 kJ/mol|}");
|
||||
expect(insertAt("[\\ce{X}]", "\\pu{1 mol}")).toBe("\\pu{1 mol}|");
|
||||
});
|
||||
|
||||
test("keeps the whole command when the caret is inside a different command", () => {
|
||||
// Review case: Ion inside \pu used to become \pu{5 SO4^2-} ("Double superscript").
|
||||
expect(insertAt("\\pu{5 |}", "\\ce{SO4^2-}")).toBe("\\pu{5 \\ce{SO4^2-}|}");
|
||||
expect(insertAt("\\ce{A -> |}", "\\pu{123 kJ/mol}")).toBe("\\ce{A -> \\pu{123 kJ/mol}|}");
|
||||
expect(renderMathPreview("\\pu{5 \\ce{SO4^2-}}", "inline")).toContain('class="katex"');
|
||||
expect(renderMathPreview("\\ce{A -> \\pu{123 kJ/mol}}", "inline")).toContain('class="katex"');
|
||||
});
|
||||
|
||||
test("finds the enclosing command past nested groups and escaped braces", () => {
|
||||
expect(insertAt("\\ce{^{14}_{6}C + |}", "\\ce{H2O}")).toBe("\\ce{^{14}_{6}C + H2O|}");
|
||||
expect(insertAt("\\ce{^{14}_{6}C + |}", "\\pu{1 mol}")).toBe("\\ce{^{14}_{6}C + \\pu{1 mol}|}");
|
||||
// The innermost open command decides: \ce nested inside \pu.
|
||||
expect(insertAt("\\pu{5 \\ce{A + |}}", "\\ce{H2O}")).toBe("\\pu{5 \\ce{A + H2O|}}");
|
||||
expect(insertAt("\\pu{5 \\ce{A} |}", "\\ce{H2O}")).toBe("\\pu{5 \\ce{A} \\ce{H2O}|}");
|
||||
// A closed command before the caret does not count.
|
||||
expect(insertAt("\\pu{5 mol} and |", "\\ce{H2O}")).toBe("\\pu{5 mol} and \\ce{H2O}|");
|
||||
expect(insertAt("\\ce{\\{ |}", "\\ce{H2O}")).toBe("\\ce{\\{ H2O|}");
|
||||
});
|
||||
|
||||
test("every example inserted into every kind of context still renders", () => {
|
||||
const contexts = ["|", "x = |", "\\ce{A -> |}", "\\ce{^{14}_{6}C + |}", "\\pu{5 |}", "\\ce{H2O} and |", "\\pu{5 \\ce{A + |}}"];
|
||||
for (const context of contexts) {
|
||||
for (const example of CHEMISTRY_EXAMPLES) {
|
||||
const result = insertAt(context, example.latex).replace("|", "");
|
||||
expect({ context, example: example.key, rendered: renderMathPreview(result, "inline") !== "" })
|
||||
.toEqual({ context, example: example.key, rendered: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the dialog keeps chemistry out of the visual math editor and uses shadcn tooltips", () => {
|
||||
const dialog = readFileSync(new URL("../dialogs/MathFormulaDialog.tsx", import.meta.url), "utf8");
|
||||
expect(dialog).toContain('setInputMode(isChemistryLatex(initialLatex) ? "source" : "visual")');
|
||||
expect(dialog).toContain('subject === "math" && !isChemistryLatex(latex)');
|
||||
expect(dialog).toContain("<TooltipContent>");
|
||||
expect(dialog).not.toMatch(/\btitle=/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export type MathFormulaSubject = "math" | "chemistry";
|
||||
|
||||
/** Formulas written with the KaTeX mhchem commands `\ce{…}` or `\pu{…}`. */
|
||||
export const isChemistryLatex = (latex: string) => /\\(?:ce|pu)\s*\{/.test(latex);
|
||||
|
||||
export const mathFormulaSubjectOf = (latex: string): MathFormulaSubject =>
|
||||
isChemistryLatex(latex) ? "chemistry" : "math";
|
||||
|
||||
export type ChemistryExample = {
|
||||
/** Key under `mathFormulaDialog.chemistryExamples`. */
|
||||
key: string;
|
||||
latex: string;
|
||||
};
|
||||
|
||||
/** One-click starting points that cover the mhchem syntax people need most. */
|
||||
export const CHEMISTRY_EXAMPLES: readonly ChemistryExample[] = [
|
||||
{ key: "formula", latex: "\\ce{H2O}" },
|
||||
{ key: "ion", latex: "\\ce{SO4^2-}" },
|
||||
{ key: "reaction", latex: "\\ce{2H2 + O2 -> 2H2O}" },
|
||||
{ key: "equilibrium", latex: "\\ce{N2 + 3H2 <=> 2NH3}" },
|
||||
{ key: "conditions", latex: "\\ce{CaCO3 ->[\\Delta] CaO + CO2}" },
|
||||
{ key: "gasPrecipitate", latex: "\\ce{Ag+ + Cl- -> AgCl v}" },
|
||||
{ key: "states", latex: "\\ce{NaCl(s) -> Na+(aq) + Cl-(aq)}" },
|
||||
{ key: "isotope", latex: "\\ce{^{14}_{6}C}" },
|
||||
{ key: "hydrate", latex: "\\ce{CuSO4*5H2O}" },
|
||||
{ key: "units", latex: "\\pu{123 kJ/mol}" },
|
||||
];
|
||||
|
||||
type ChemistryCommand = "ce" | "pu";
|
||||
|
||||
const exampleCommand = (example: string) => /^\\(ce|pu)\{([\s\S]*)\}$/.exec(example) as
|
||||
| (RegExpExecArray & [string, ChemistryCommand, string])
|
||||
| null;
|
||||
|
||||
/**
|
||||
* The `\ce{…}` or `\pu{…}` whose braces are still open at the end of `before`,
|
||||
* found by brace depth so nested groups such as `^{14}_{6}` are skipped.
|
||||
*/
|
||||
const enclosingCommand = (before: string): ChemistryCommand | null => {
|
||||
const open: Array<ChemistryCommand | null> = [];
|
||||
for (let index = 0; index < before.length; index += 1) {
|
||||
const char = before[index];
|
||||
if (char === "\\") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
const command = /\\(ce|pu)\s*$/.exec(before.slice(0, index))?.[1] as ChemistryCommand | undefined;
|
||||
open.push(command ?? null);
|
||||
} else if (char === "}") {
|
||||
open.pop();
|
||||
}
|
||||
}
|
||||
for (let level = open.length - 1; level >= 0; level -= 1) {
|
||||
if (open[level]) return open[level]!;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inserts an example at the caret. An empty field takes the example as is. A
|
||||
* caret inside an open command of the same kind (`\ce` in `\ce`, `\pu` in
|
||||
* `\pu`) gets only the example's content, so the result stays one command.
|
||||
* Any other position keeps the whole command: `\ce` syntax inside `\pu` (or
|
||||
* the reverse) is not valid and cannot be rendered.
|
||||
*/
|
||||
export const insertChemistryExample = (
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
example: string,
|
||||
) => {
|
||||
if (!value.trim()) return { value: example, caret: example.length };
|
||||
const before = value.slice(0, selectionStart);
|
||||
const after = value.slice(selectionEnd);
|
||||
const parsed = exampleCommand(example);
|
||||
const sameCommand = parsed !== null && enclosingCommand(before) === parsed[1];
|
||||
const text = sameCommand ? parsed[2] : example;
|
||||
// Keep the example apart from the text around it, but not from a closing brace.
|
||||
const leading = before && !/\s$/.test(before) ? " " : "";
|
||||
const trailing = after && !/^[\s}]/.test(after) ? " " : "";
|
||||
const inserted = `${leading}${text}${trailing}`;
|
||||
return { value: `${before}${inserted}${after}`, caret: before.length + inserted.length };
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
import { history, undo, redo } from "@tiptap/pm/history";
|
||||
import { ImageGallery } from "@edgeever/shared";
|
||||
import { createImageDragPlugin, imageDragKey, moveImage } from "./image-drag";
|
||||
|
||||
const schema = getSchema([StarterKit, Image.extend({ addAttributes() {
|
||||
return { ...this.parent(), width: { default: null }, alignment: { default: null } };
|
||||
} }), ImageGallery]);
|
||||
const image = (src) => schema.nodes.image.create({ src, alt: `alt ${src}`, title: `title ${src}`, width: 42, alignment: "right" });
|
||||
const paragraph = (text) => schema.nodes.paragraph.create(null, schema.text(text));
|
||||
const gallery = (...names) => schema.nodes.edgeeverImageGallery.create({ layout: "3", fit: "height" }, names.map(image));
|
||||
const state = (...nodes) => EditorState.create({ doc: schema.nodes.doc.create(null, nodes), plugins: [history(), createImageDragPlugin()] });
|
||||
const move = (before, from, to) => {
|
||||
const tr = moveImage(before, from, to);
|
||||
expect(tr).not.toBeNull();
|
||||
const next = before.applyTransaction(tr).state;
|
||||
next.doc.check();
|
||||
return next;
|
||||
};
|
||||
const sources = (doc) => { const result = []; doc.descendants((node) => { if (node.type.name === "image") result.push(node.attrs.src); }); return result; };
|
||||
|
||||
describe("existing image dragging (#708)", () => {
|
||||
test("moves between paragraphs in either direction without changing image attributes", () => {
|
||||
const before = state(paragraph("a"), image("/resources/original"), paragraph("b"));
|
||||
const after = move(before, 3, 0);
|
||||
expect(after.doc.firstChild).toBe(before.doc.child(1));
|
||||
expect(after.doc.toJSON()).toEqual(schema.nodes.doc.create(null, [image("/resources/original"), paragraph("a"), paragraph("b")]).toJSON());
|
||||
expect(move(after, 0, after.doc.content.size).doc.lastChild).toBe(before.doc.child(1));
|
||||
});
|
||||
test("reorders A,B to B,A in a gallery and preserves gallery presentation", () => {
|
||||
const before = state(gallery("A", "B"));
|
||||
const after = move(before, 1, 3);
|
||||
expect(sources(after.doc)).toEqual(["B", "A"]);
|
||||
expect(after.doc.firstChild.attrs).toEqual(before.doc.firstChild.attrs);
|
||||
expect(after.doc.firstChild.lastChild).toBe(before.doc.firstChild.firstChild);
|
||||
expect(move(after, 2, 1).doc.toJSON()).toEqual(before.doc.toJSON());
|
||||
});
|
||||
test("moving the only gallery image out removes the empty shell", () => {
|
||||
const before = state(gallery("A"), paragraph("text"));
|
||||
const after = move(before, 1, before.doc.content.size);
|
||||
expect(after.doc.toJSON()).toEqual(schema.nodes.doc.create(null, [paragraph("text"), image("A")]).toJSON());
|
||||
});
|
||||
test("moves into and between galleries without placeholders or duplicates", () => {
|
||||
let next = move(state(gallery("A"), gallery("B", "C")), 1, 5);
|
||||
expect(next.doc.childCount).toBe(1);
|
||||
expect(sources(next.doc)).toEqual(["B", "A", "C"]);
|
||||
next = move(state(image("A"), gallery("B", "C")), 0, 3);
|
||||
expect(next.doc.childCount).toBe(1);
|
||||
expect(sources(next.doc)).toEqual(["B", "A", "C"]);
|
||||
});
|
||||
test("one undo restores the original document; redo repeats the move", () => {
|
||||
const before = state(gallery("A"), paragraph("text"), gallery("B", "C"));
|
||||
let next = move(before, 1, 11);
|
||||
const moved = next.doc.toJSON();
|
||||
expect(undo(next, (tr) => { next = next.applyTransaction(tr).state; })).toBe(true);
|
||||
expect(next.doc.toJSON()).toEqual(before.doc.toJSON());
|
||||
expect(redo(next, (tr) => { next = next.applyTransaction(tr).state; })).toBe(true);
|
||||
expect(next.doc.toJSON()).toEqual(moved);
|
||||
});
|
||||
test("rejects self drops, adjacent no-ops, text positions and invalid positions", () => {
|
||||
const before = state(image("A"), paragraph("text"));
|
||||
for (const to of [-1, 0, 1, 3, 999]) expect(moveImage(before, 0, to)).toBeNull();
|
||||
expect(moveImage(before, 1, 0)).toBeNull();
|
||||
expect(moveImage(before, -1, 0)).toBeNull();
|
||||
expect(moveImage(before, 999, 0)).toBeNull();
|
||||
});
|
||||
test("undoing a move does not undo the text typed immediately before it", () => {
|
||||
let next = state(paragraph("a"), image("A"));
|
||||
next = next.apply(next.tr.insertText(" typed", 2));
|
||||
const beforeMove = next.doc.toJSON();
|
||||
next = move(next, next.doc.firstChild.nodeSize, 0);
|
||||
expect(undo(next, (tr) => { next = next.applyTransaction(tr).state; })).toBe(true);
|
||||
expect(next.doc.toJSON()).toEqual(beforeMove);
|
||||
expect(next.doc.textContent).toBe("a typed");
|
||||
});
|
||||
test("leaves external file drops and readonly views to their existing handlers", () => {
|
||||
const plugin = createImageDragPlugin();
|
||||
let next = state(image("A"), paragraph("text"));
|
||||
next = next.apply(next.tr.setMeta(imageDragKey, 0));
|
||||
const external = { editable: true, state: next, dragging: null };
|
||||
expect(plugin.props.handleDrop(external, {}, null, true)).toBe(false);
|
||||
const readonly = { ...external, editable: false, dragging: { slice: next.selection.content(), move: true } };
|
||||
expect(plugin.props.handleDrop(readonly, {}, null, true)).toBe(false);
|
||||
});
|
||||
test("maps the drag source through other edits and cancels if the source is deleted", () => {
|
||||
let next = state(paragraph("a"), image("A"));
|
||||
next = next.apply(next.tr.setMeta(imageDragKey, 3));
|
||||
next = next.apply(next.tr.insertText("hello", 1));
|
||||
expect(imageDragKey.getState(next)).toBe(8);
|
||||
next = next.apply(next.tr.delete(8, 9));
|
||||
expect(imageDragKey.getState(next)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { NodeSelection, Plugin, PluginKey, type EditorState } from "@tiptap/pm/state";
|
||||
import { closeHistory } from "@tiptap/pm/history";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared";
|
||||
import { imageSelectionKey } from "./image-selection";
|
||||
|
||||
export const imageDragKey = new PluginKey<number | null>("imageDrag");
|
||||
|
||||
/** Move the original node, preserving resource references and every attribute. */
|
||||
export function moveImage(state: EditorState, from: number, target: number) {
|
||||
if (from < 0 || from >= state.doc.content.size) return null;
|
||||
const image = state.doc.nodeAt(from);
|
||||
if (image?.type.name !== "image" || target < 0 || target > state.doc.content.size) return null;
|
||||
if (target >= from && target <= from + image.nodeSize) return null;
|
||||
|
||||
const $from = state.doc.resolve(from);
|
||||
const tr = closeHistory(state.tr);
|
||||
// Deleting a gallery's last child alone would cause schema filling to create
|
||||
// an empty image. Remove its container in the same transaction instead.
|
||||
if ($from.parent.type.name === IMAGE_GALLERY_NODE_TYPE && $from.parent.childCount === 1) {
|
||||
tr.delete($from.before(), $from.after());
|
||||
} else {
|
||||
tr.delete(from, from + image.nodeSize);
|
||||
}
|
||||
const position = tr.mapping.map(target);
|
||||
const $target = tr.doc.resolve(position);
|
||||
if (!$target.parent.canReplaceWith($target.index(), $target.index(), image.type)) return null;
|
||||
tr.insert(position, image);
|
||||
tr.setSelection(NodeSelection.create(tr.doc, position));
|
||||
return tr.setMeta(imageSelectionKey, null).setMeta(imageDragKey, null).scrollIntoView();
|
||||
}
|
||||
|
||||
type DropTarget = { position: number; left: number; top: number; width: number; height: number };
|
||||
|
||||
function imagePosition(view: EditorView, element: Element) {
|
||||
const figure = element.closest(".edgeever-image-node");
|
||||
if (!figure || !view.dom.contains(figure)) return null;
|
||||
const position = view.posAtDOM(figure, 0);
|
||||
return view.state.doc.nodeAt(position)?.type.name === "image" ? { figure, position } : null;
|
||||
}
|
||||
|
||||
function dropTarget(view: EditorView, event: DragEvent): DropTarget | null {
|
||||
const element = event.target instanceof Element ? event.target : null;
|
||||
let hit = element && imagePosition(view, element);
|
||||
// Grid gaps belong to the nearest image, rather than the gallery container.
|
||||
const galleryElement = element?.closest("[data-edgeever-image-gallery]");
|
||||
if (!hit && galleryElement && view.dom.contains(galleryElement)) {
|
||||
let distance = Infinity;
|
||||
for (const figure of galleryElement.querySelectorAll(".edgeever-image-node")) {
|
||||
const rect = figure.getBoundingClientRect();
|
||||
const dx = Math.max(rect.left - event.clientX, 0, event.clientX - rect.right);
|
||||
const dy = Math.max(rect.top - event.clientY, 0, event.clientY - rect.bottom);
|
||||
const candidateDistance = dx * dx + dy * dy;
|
||||
if (candidateDistance < distance) {
|
||||
distance = candidateDistance;
|
||||
hit = imagePosition(view, figure);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hit) {
|
||||
const rect = hit.figure.getBoundingClientRect();
|
||||
const gallery = hit.figure.closest("[data-edgeever-image-gallery]");
|
||||
// A one-column gallery uses top/bottom edges; a grid uses left/right edges.
|
||||
const horizontal = gallery && Array.from(gallery.querySelectorAll(".edgeever-image-node")).some((sibling) => {
|
||||
const other = sibling.getBoundingClientRect();
|
||||
return Math.abs(other.left - rect.left) > 1 && other.top < rect.bottom && other.bottom > rect.top;
|
||||
});
|
||||
const after = horizontal ? event.clientX > rect.left + rect.width / 2 : event.clientY > rect.top + rect.height / 2;
|
||||
return {
|
||||
position: hit.position + (after ? 1 : 0),
|
||||
left: horizontal ? (after ? rect.right : rect.left) : rect.left,
|
||||
top: horizontal ? rect.top : (after ? rect.bottom : rect.top),
|
||||
width: horizontal ? 3 : rect.width,
|
||||
height: horizontal ? rect.height : 3,
|
||||
};
|
||||
}
|
||||
|
||||
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
if (!coords) return null;
|
||||
const $pos = view.state.doc.resolve(coords.pos);
|
||||
const imageType = view.state.schema.nodes.image;
|
||||
// Snap to block boundaries instead of splitting the paragraph under the mouse.
|
||||
for (let depth = $pos.depth; depth > 0; depth -= 1) {
|
||||
const before = $pos.before(depth);
|
||||
const parent = view.state.doc.resolve(before);
|
||||
if (!parent.parent.canReplaceWith(parent.index(), parent.index(), imageType)) continue;
|
||||
const dom = view.nodeDOM(before);
|
||||
if (!(dom instanceof HTMLElement)) continue;
|
||||
const rect = dom.getBoundingClientRect();
|
||||
const after = event.clientY > rect.top + rect.height / 2;
|
||||
return { position: after ? $pos.after(depth) : before, left: rect.left, top: after ? rect.bottom : rect.top, width: rect.width, height: 3 };
|
||||
}
|
||||
if ($pos.parent.canReplaceWith($pos.index(), $pos.index(), imageType)) {
|
||||
const rect = view.dom.getBoundingClientRect();
|
||||
const caret = view.coordsAtPos(coords.pos);
|
||||
return { position: coords.pos, left: rect.left, top: caret.top, width: rect.width, height: 3 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createImageDragPlugin() {
|
||||
let marker: HTMLDivElement | null = null;
|
||||
const clearMarker = () => { marker?.remove(); marker = null; };
|
||||
const reset = (view: EditorView) => {
|
||||
clearMarker();
|
||||
if (imageDragKey.getState(view.state) !== null) view.dispatch(view.state.tr.setMeta(imageDragKey, null));
|
||||
};
|
||||
return new Plugin<number | null>({
|
||||
key: imageDragKey,
|
||||
state: {
|
||||
init: () => null,
|
||||
apply(tr, previous) {
|
||||
const requested = tr.getMeta(imageDragKey) as number | null | undefined;
|
||||
if (requested !== undefined) return requested;
|
||||
if (previous === null) return null;
|
||||
const mapped = tr.mapping.mapResult(previous);
|
||||
return mapped.deleted || tr.doc.nodeAt(mapped.pos)?.type.name !== "image" ? null : mapped.pos;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
dragstart(view, event) {
|
||||
if (!view.editable || !(event.target instanceof Element)) return false;
|
||||
const hit = imagePosition(view, event.target);
|
||||
if (!hit || !event.target.closest("[data-drag-handle]")) return false;
|
||||
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, hit.position))
|
||||
.setMeta(imageDragKey, hit.position).setMeta(imageSelectionKey, null));
|
||||
return false; // Let ProseMirror serialize the node and start its internal drag.
|
||||
},
|
||||
dragover(view, event) {
|
||||
const from = imageDragKey.getState(view.state);
|
||||
if (!view.editable || from == null || !view.dragging) { clearMarker(); return false; }
|
||||
const target = dropTarget(view, event);
|
||||
if (!target || !moveImage(view.state, from, target.position)) { clearMarker(); return false; }
|
||||
event.preventDefault();
|
||||
if (!marker) {
|
||||
marker = view.dom.ownerDocument.createElement("div");
|
||||
marker.className = "edgeever-image-drop-indicator";
|
||||
marker.setAttribute("aria-hidden", "true");
|
||||
view.dom.ownerDocument.body.appendChild(marker);
|
||||
}
|
||||
Object.assign(marker.style, { left: `${target.left}px`, top: `${target.top}px`, width: `${target.width}px`, height: `${target.height}px` });
|
||||
return true;
|
||||
},
|
||||
dragleave(view, event) {
|
||||
if (!(event.relatedTarget instanceof globalThis.Node) || !view.dom.contains(event.relatedTarget)) clearMarker();
|
||||
return false;
|
||||
},
|
||||
dragend(view) { reset(view); return false; },
|
||||
drop() { clearMarker(); return false; },
|
||||
},
|
||||
handleDrop(view, event, _slice, moved) {
|
||||
const from = imageDragKey.getState(view.state);
|
||||
if (!view.editable || from == null || !view.dragging) return false;
|
||||
if (!moved) { reset(view); return false; } // Preserve the editor's modifier-key copy gesture.
|
||||
const target = dropTarget(view, event);
|
||||
const transaction = target && moveImage(view.state, from, target.position);
|
||||
if (transaction) {
|
||||
view.dispatch(transaction);
|
||||
// Keep the drag separate from subsequent typing in the undo history.
|
||||
view.dispatch(closeHistory(view.state.tr));
|
||||
view.focus();
|
||||
} else reset(view);
|
||||
return true; // Invalid/self drops must never fall through to insert a copy.
|
||||
},
|
||||
},
|
||||
view(view) {
|
||||
const clear = () => clearMarker();
|
||||
view.dom.ownerDocument.addEventListener("drop", clear);
|
||||
view.dom.ownerDocument.addEventListener("dragend", clear);
|
||||
return {
|
||||
update(next) { if (imageDragKey.getState(next.state) == null || !next.editable) clearMarker(); },
|
||||
destroy() {
|
||||
clearMarker();
|
||||
view.dom.ownerDocument.removeEventListener("drop", clear);
|
||||
view.dom.ownerDocument.removeEventListener("dragend", clear);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createFileUploadPlaceholder,
|
||||
createImageUploadPlaceholderPlugin,
|
||||
createResourceUploadPlaceholder,
|
||||
getImageUploadPlaceholderPosition,
|
||||
removeImageUploadPlaceholder,
|
||||
updateImageUploadPlaceholder,
|
||||
} from "./image-upload-placeholder.ts";
|
||||
@@ -128,6 +129,7 @@ describe("resource upload placeholder", () => {
|
||||
?.find(undefined, undefined, (spec) => spec.id === placeholder.id) ?? [];
|
||||
expect(initialDecorations).toHaveLength(1);
|
||||
expect(initialDecorations[0]?.from).toBe(1);
|
||||
expect(getImageUploadPlaceholderPosition(editor.state, placeholder.id)).toBe(1);
|
||||
|
||||
editor.view.dispatch(editor.state.tr.insertText("A", 1));
|
||||
const mappedDecorations = IMAGE_UPLOAD_PLACEHOLDER_PLUGIN_KEY
|
||||
@@ -135,8 +137,48 @@ describe("resource upload placeholder", () => {
|
||||
?.find(undefined, undefined, (spec) => spec.id === placeholder.id) ?? [];
|
||||
expect(mappedDecorations).toHaveLength(1);
|
||||
expect(mappedDecorations[0]?.from).toBe(2);
|
||||
expect(getImageUploadPlaceholderPosition(editor.state, placeholder.id)).toBe(2);
|
||||
|
||||
removeImageUploadPlaceholder(editor, placeholder);
|
||||
expect(IMAGE_UPLOAD_PLACEHOLDER_PLUGIN_KEY.getState(editor.state)?.find()).toHaveLength(0);
|
||||
expect(getImageUploadPlaceholderPosition(editor.state, placeholder.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// #709: dropped files use a mapped point, never the previous replacement selection.
|
||||
describe("drop upload anchors", () => {
|
||||
const makeEditor = () => {
|
||||
const schema = new Schema({ nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { content: "inline*", group: "block" },
|
||||
text: { group: "inline" },
|
||||
} });
|
||||
const editor = {
|
||||
state: EditorState.create({ schema,
|
||||
doc: schema.node("doc", null, [schema.node("paragraph", null, schema.text("before after"))]),
|
||||
plugins: [createImageUploadPlaceholderPlugin()],
|
||||
}),
|
||||
view: { dispatch(tr) { editor.state = editor.state.apply(tr); } },
|
||||
};
|
||||
return editor;
|
||||
};
|
||||
|
||||
test("retains distinct queued drop points through edits before and between them", () => {
|
||||
const editor = makeEditor();
|
||||
addImageUploadPlaceholder(editor, { ...placeholder, id: "first" }, 5);
|
||||
addImageUploadPlaceholder(editor, { ...placeholder, id: "second" }, 10);
|
||||
editor.view.dispatch(editor.state.tr.insertText("ABC", 1));
|
||||
expect(getImageUploadPlaceholderPosition(editor.state, "first")).toBe(8);
|
||||
expect(getImageUploadPlaceholderPosition(editor.state, "second")).toBe(13);
|
||||
editor.view.dispatch(editor.state.tr.insertText("XY", 9));
|
||||
expect(getImageUploadPlaceholderPosition(editor.state, "first")).toBe(8);
|
||||
expect(getImageUploadPlaceholderPosition(editor.state, "second")).toBe(15);
|
||||
});
|
||||
|
||||
test("does not revive an anchor whose containing content was deleted", () => {
|
||||
const editor = makeEditor();
|
||||
addImageUploadPlaceholder(editor, placeholder, 5);
|
||||
editor.view.dispatch(editor.state.tr.delete(2, 8));
|
||||
expect(getImageUploadPlaceholderPosition(editor.state, placeholder.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Extension, type Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { SUPPORTED_PASTE_IMAGE_TYPES } from "./editor-pane-helpers";
|
||||
|
||||
@@ -223,3 +223,8 @@ export const waitForImageSourceReady = async (source: string, timeoutMs = 4_000)
|
||||
if (image.complete) finish();
|
||||
});
|
||||
};
|
||||
|
||||
/** Resolve an upload anchor after all edits made since the file was dropped. */
|
||||
export const getImageUploadPlaceholderPosition = (state: EditorState, id: string): number | null =>
|
||||
IMAGE_UPLOAD_PLACEHOLDER_PLUGIN_KEY.getState(state)
|
||||
?.find(undefined, undefined, (spec) => spec.id === id)[0]?.from ?? null;
|
||||
@@ -1,4 +1,5 @@
|
||||
import katex from "katex";
|
||||
import "katex/contrib/mhchem";
|
||||
import type { MathFormulaKind } from "./math-formula";
|
||||
|
||||
export const hasMathPlaceholders = (latex: string) => /\\placeholder\b/.test(latex);
|
||||
|
||||
@@ -118,4 +118,53 @@ describe("math formula commands", () => {
|
||||
expect(applyMathFormula(editor, { kind: "inline", latex: " " })).toBe(false);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test("continues after a newly inserted formula instead of in front of it (#719)", () => {
|
||||
const inline = createEditor({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Energy is " }] }] });
|
||||
inline.commands.setTextSelection(11);
|
||||
expect(applyMathFormula(inline, { kind: "inline", latex: "E = mc^2" })).toBe(true);
|
||||
inline.commands.insertContent({ type: "text", text: "AFTER" });
|
||||
expect(inline.getJSON().content[0].content).toEqual([
|
||||
{ type: "text", text: "Energy is " },
|
||||
{ type: "inlineMath", attrs: { latex: "E = mc^2" } },
|
||||
{ type: "text", text: "AFTER" },
|
||||
]);
|
||||
inline.destroy();
|
||||
|
||||
for (const content of [
|
||||
[{ type: "paragraph", content: [{ type: "text", text: "Energy is " }] }],
|
||||
[{ type: "paragraph", content: [{ type: "text", text: "Energy is " }] }, { type: "paragraph", content: [{ type: "text", text: "next" }] }],
|
||||
]) {
|
||||
const block = createEditor({ type: "doc", content });
|
||||
block.commands.setTextSelection(11);
|
||||
expect(applyMathFormula(block, { kind: "block", latex: "E = mc^2" })).toBe(true);
|
||||
// Typing must not replace the selected formula.
|
||||
block.commands.insertContent({ type: "text", text: "AFTER" });
|
||||
const types = block.getJSON().content.map((node) => node.type === "blockMath" ? "blockMath" : (node.content ?? []).map((child) => child.text).join(""));
|
||||
expect(types).toEqual(content.length === 1 ? ["Energy is ", "blockMath", "AFTER"] : ["Energy is ", "blockMath", "AFTER", "next"]);
|
||||
expect(block.getJSON().content.find((node) => node.type === "blockMath")?.attrs).toEqual({ latex: "E = mc^2" });
|
||||
block.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("continues after an edited formula instead of in front of it or replacing it (#719)", () => {
|
||||
const editor = createEditor({ type: "doc", content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "A " }, { type: "inlineMath", attrs: { latex: "x" } }, { type: "text", text: " B" }] },
|
||||
{ type: "blockMath", attrs: { latex: "y" } },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "C" }] },
|
||||
] });
|
||||
const outline = () => editor.getJSON().content.map((node) => node.type === "blockMath"
|
||||
? `[block:${node.attrs.latex}]`
|
||||
: (node.content ?? []).map((child) => child.text ?? `[inline:${child.attrs.latex}]`).join(""));
|
||||
|
||||
expect(applyMathFormula(editor, { kind: "inline", latex: "z^2", pos: 3 })).toBe(true);
|
||||
editor.commands.insertContent({ type: "text", text: "Q" });
|
||||
expect(outline()).toEqual(["A [inline:z^2]Q B", "[block:y]", "C"]);
|
||||
|
||||
const blockPos = editor.state.doc.child(0).nodeSize;
|
||||
expect(applyMathFormula(editor, { kind: "block", latex: "w", pos: blockPos })).toBe(true);
|
||||
editor.commands.insertContent({ type: "text", text: "R" });
|
||||
expect(outline()).toEqual(["A [inline:z^2]Q B", "[block:w]", "R", "C"]);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { NodeSelection, TextSelection, type Transaction } from "@tiptap/pm/state";
|
||||
import {
|
||||
BLOCK_MATH_NODE_TYPE,
|
||||
INLINE_MATH_NODE_TYPE,
|
||||
@@ -36,14 +37,40 @@ export const resolveMathFormulaTarget = (editor: Editor, pos?: number): MathForm
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Puts the caret right after the formula at `formulaPos`, so the next key
|
||||
* continues the text instead of landing in front of it or replacing it. A
|
||||
* display formula continues in an empty paragraph after it, so typing does not
|
||||
* join the following text either.
|
||||
*/
|
||||
const placeCaretAfterFormula = (tr: Transaction, formulaPos: number) => {
|
||||
const node = tr.doc.nodeAt(formulaPos);
|
||||
if (!node) return;
|
||||
const after = formulaPos + node.nodeSize;
|
||||
if (node.type.name !== BLOCK_MATH_NODE_TYPE) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, after));
|
||||
return;
|
||||
}
|
||||
const next = tr.doc.resolve(after).nodeAfter;
|
||||
if (next?.type.name !== "paragraph" || next.content.size > 0) {
|
||||
tr.insert(after, tr.doc.type.schema.nodes.paragraph!.create());
|
||||
}
|
||||
tr.setSelection(TextSelection.create(tr.doc, after + 1));
|
||||
};
|
||||
|
||||
export const applyMathFormula = (editor: Editor, draft: MathFormulaDraft) => {
|
||||
const latex = draft.latex.trim();
|
||||
if (!latex) return false;
|
||||
|
||||
if (typeof draft.pos === "number") {
|
||||
return draft.kind === "inline"
|
||||
? editor.commands.updateInlineMath({ latex, pos: draft.pos })
|
||||
: editor.commands.updateBlockMath({ latex, pos: draft.pos });
|
||||
const pos = draft.pos;
|
||||
const chain = editor.chain();
|
||||
return (draft.kind === "inline" ? chain.updateInlineMath({ latex, pos }) : chain.updateBlockMath({ latex, pos }))
|
||||
.command(({ tr }) => {
|
||||
placeCaretAfterFormula(tr, tr.mapping.map(pos));
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const from = draft.from ?? editor.state.selection.from;
|
||||
@@ -52,7 +79,18 @@ export const applyMathFormula = (editor: Editor, draft: MathFormulaDraft) => {
|
||||
? { type: INLINE_MATH_NODE_TYPE, attrs: { latex } }
|
||||
: { type: BLOCK_MATH_NODE_TYPE, attrs: { latex } };
|
||||
|
||||
return editor.chain().insertContentAt(from === to ? from : { from, to }, content).run();
|
||||
return editor.chain()
|
||||
.insertContentAt(from === to ? from : { from, to }, content)
|
||||
.command(({ tr }) => {
|
||||
// An inline formula already leaves the caret after it; a display formula
|
||||
// is left selected as a node.
|
||||
const { selection } = tr;
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === BLOCK_MATH_NODE_TYPE) {
|
||||
placeCaretAfterFormula(tr, selection.from);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
export const deleteMathFormula = (editor: Editor, target: Pick<MathFormulaTarget, "kind" | "pos">) => {
|
||||
|
||||
@@ -105,7 +105,8 @@ export const DesktopScheduledBackupCard = () => {
|
||||
<label className="grid gap-1.5 text-xs font-medium text-slate-700">
|
||||
<span>{t("desktopBackup.retention")}</span>
|
||||
<select className="h-8 rounded-md border border-input bg-card px-2.5 text-xs text-slate-800 outline-none focus:border-slate-900 focus:ring-1 focus:ring-slate-900" value={config.retention} onChange={(event) => patch({ retention: Number(event.target.value) as DesktopBackupConfig["retention"] })}>
|
||||
{([3, 5, 10, 20] as const).map((count) => <option key={count} value={count}>{t("desktopBackup.copies", { count })}</option>)}
|
||||
{(config.retention === 10 || config.retention === 20) ? <option value={config.retention} disabled>{t("desktopBackup.copies", { count: config.retention })}</option> : null}
|
||||
{([1, 3, 5] as const).map((count) => <option key={count} value={count}>{t("desktopBackup.copies", { count })}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -83,9 +83,10 @@ export const useWorkspaceQueuedSync = ({
|
||||
const hasRemote = current.notebooks.some((notebook) => notebook.id === remoteId);
|
||||
return {
|
||||
...current,
|
||||
notebooks: hasRemote
|
||||
notebooks: (hasRemote
|
||||
? current.notebooks.filter((notebook) => notebook.id !== temporaryId)
|
||||
: current.notebooks.map((notebook) => notebook.id === temporaryId ? { ...notebook, id: remoteId } : notebook),
|
||||
: current.notebooks.map((notebook) => notebook.id === temporaryId ? { ...notebook, id: remoteId } : notebook))
|
||||
.map((notebook) => notebook.parentId === temporaryId ? { ...notebook, parentId: remoteId } : notebook),
|
||||
};
|
||||
});
|
||||
setSelectedNotebookId((current) => current === temporaryId ? remoteId : current);
|
||||
@@ -146,7 +147,6 @@ export const useWorkspaceQueuedSync = ({
|
||||
if (!result || !["memo.merge", "notebook.create", "notebook.update", "template.create", "template.update", "resource.create"].includes(item.kind)) return;
|
||||
const {
|
||||
deleteLocalMemo,
|
||||
deleteLocalNotebook,
|
||||
deleteLocalTemplate,
|
||||
getLocalMemo,
|
||||
putLocalMemo: putMemo,
|
||||
@@ -168,8 +168,8 @@ export const useWorkspaceQueuedSync = ({
|
||||
if (source) await putMemo(localDataScope, { ...source, mergedIntoMemoId: (result as MemoDetail).id });
|
||||
}));
|
||||
} else if (item.kind === "notebook.create" && "name" in result && temporaryId) {
|
||||
await deleteLocalNotebook(localDataScope, temporaryId);
|
||||
await putLocalNotebook(localDataScope, result as Notebook);
|
||||
// The sync core committed the mirror, references, mapping and
|
||||
// acknowledgement together before notifying the live UI.
|
||||
notifyNotebookIdRemapped(temporaryId, (result as Notebook).id);
|
||||
} else if (item.kind === "notebook.update" && "name" in result) {
|
||||
await putLocalNotebook(localDataScope, result as Notebook);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { getTextBetween } from "@tiptap/core";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import { AllSelection, EditorState, TextSelection } from "@tiptap/pm/state";
|
||||
import { CellSelection, tableNodes } from "@tiptap/pm/tables";
|
||||
import { editorClipboardTextSerializer, serializeEditorTextRange } from "./editor-clipboard.ts";
|
||||
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
text: { group: "inline" },
|
||||
paragraph: { content: "inline*", group: "block" },
|
||||
blockquote: { content: "block+", group: "block" },
|
||||
bulletList: { content: "listItem+", group: "block" },
|
||||
listItem: { content: "paragraph block*" },
|
||||
codeBlock: { content: "text*", group: "block", code: true },
|
||||
hardBreak: { inline: true, group: "inline", toText: () => "\n" },
|
||||
...tableNodes({ tableGroup: "block", cellContent: "block+" }),
|
||||
},
|
||||
});
|
||||
const node = (type, content = []) => schema.node(type, null, content);
|
||||
const p = (text) => node("paragraph", text ? [schema.text(text)] : []);
|
||||
const item = (text) => node("listItem", [p(text)]);
|
||||
const copy = (doc, selection = new AllSelection(doc)) => editorClipboardTextSerializer(
|
||||
selection.content(), { state: EditorState.create({ doc, selection }) },
|
||||
);
|
||||
|
||||
test("quoted lists reproduce accumulated default whitespace and copy with one newline per text block", () => {
|
||||
const doc = node("doc", [node("blockquote", [
|
||||
p("4 个月的数据:"),
|
||||
node("bulletList", [item("GitHub 突破 3,000 Star"), item("全服第一")]),
|
||||
p("鸣谢背后的神仙战友:"),
|
||||
node("bulletList", [item("感谢小伙伴"), item("感谢波兰和德国老哥")]),
|
||||
])]);
|
||||
expect(getTextBetween(doc, { from: 0, to: doc.content.size })).toContain("\n\n\n\n");
|
||||
expect(copy(doc)).toBe("4 个月的数据:\nGitHub 突破 3,000 Star\n全服第一\n鸣谢背后的神仙战友:\n感谢小伙伴\n感谢波兰和德国老哥");
|
||||
});
|
||||
|
||||
test("nested lists and quotes do not create leading or repeated separators", () => {
|
||||
const doc = node("doc", [node("blockquote", [node("bulletList", [
|
||||
node("listItem", [p("Parent"), node("bulletList", [item("Child")])]), item("Next"),
|
||||
])])]);
|
||||
expect(copy(doc)).toBe("Parent\nChild\nNext");
|
||||
});
|
||||
|
||||
test("intentional empty paragraphs, hard breaks and code whitespace survive", () => {
|
||||
const doc = node("doc", [p("First"), p(""),
|
||||
node("paragraph", [schema.text("A"), node("hardBreak"), schema.text("B")]),
|
||||
node("codeBlock", [schema.text(" code\n\n indented")]), p(""),
|
||||
]);
|
||||
expect(copy(doc)).toBe("First\n\nA\nB\n code\n\n indented\n");
|
||||
});
|
||||
|
||||
test("partial selections copy only selected text, including across paragraphs", () => {
|
||||
const doc = node("doc", [p("Alpha"), p("Beta")]);
|
||||
expect(copy(doc, TextSelection.create(doc, 3, 10))).toBe("pha\nBe");
|
||||
expect(serializeEditorTextRange(doc, 2, 4)).toBe("lp");
|
||||
});
|
||||
|
||||
test("table cell selections omit unselected cells and keep document order", () => {
|
||||
const cell = (text) => node("table_cell", [p(text)]);
|
||||
const doc = node("doc", [node("table", [
|
||||
node("table_row", [cell("A"), cell("skip1")]),
|
||||
node("table_row", [cell("B"), cell("skip2")]),
|
||||
])]);
|
||||
const positions = [];
|
||||
doc.descendants((n, pos) => { if (n.type.name === "table_cell") positions.push(pos); });
|
||||
expect(copy(doc, CellSelection.create(doc, positions[2], positions[0]))).toBe("A\nB");
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getTextSerializersFromSchema, type TextSerializer } from "@tiptap/core";
|
||||
import type { Node } from "@tiptap/pm/model";
|
||||
import type { EditorProps } from "@tiptap/pm/view";
|
||||
|
||||
/** Container nodes (quotes, lists, items) must not each add a blank line. */
|
||||
export const serializeEditorTextRange = (doc: Node, from: number, to: number): string => {
|
||||
const serializers: Partial<Record<string, TextSerializer>> = getTextSerializersFromSchema(doc.type.schema);
|
||||
let text = "";
|
||||
let firstBlock = true;
|
||||
doc.nodesBetween(from, to, (node, pos, parent, index) => {
|
||||
const serializer = serializers[node.type.name];
|
||||
if (node.isBlock && (node.isTextblock || serializer || (node.isLeaf && node.type.spec.leafText))) {
|
||||
if (!firstBlock) text += "\n";
|
||||
firstBlock = false;
|
||||
}
|
||||
if (serializer && parent) {
|
||||
text += serializer({ node, pos, parent, index, range: { from, to } });
|
||||
return false;
|
||||
}
|
||||
if (node.isText) {
|
||||
text += node.text!.slice(Math.max(from, pos) - pos, to - pos);
|
||||
} else if (node.isLeaf && node.type.spec.leafText) {
|
||||
text += node.type.spec.leafText(node);
|
||||
}
|
||||
});
|
||||
return text;
|
||||
};
|
||||
|
||||
export const editorClipboardTextSerializer: NonNullable<EditorProps["clipboardTextSerializer"]> = (_slice, view) => {
|
||||
const { doc, selection } = view.state;
|
||||
// Cell selections expose disjoint ranges: do not include the cells between them.
|
||||
return [...selection.ranges]
|
||||
.sort((a, b) => a.$from.pos - b.$from.pos)
|
||||
.map(({ $from, $to }) => serializeEditorTextRange(doc, $from.pos, $to.pos))
|
||||
.join("\n");
|
||||
};
|
||||
@@ -10,7 +10,7 @@ const GANTT_SOURCE = `gantt
|
||||
Compatibility fallback :fallback, 2026-07-30, 1d`;
|
||||
|
||||
describe("Mermaid renderer fallback", () => {
|
||||
test("routes the reported subgraph to Mermaid even when the compact renderer would succeed", async () => {
|
||||
test("keeps the reported Chinese subgraph on the compact renderer", async () => {
|
||||
const source = `flowchart LR
|
||||
subgraph 日志开启建议
|
||||
A[生产环境] -->|不开启| B[查询日志]
|
||||
@@ -31,12 +31,12 @@ describe("Mermaid renderer fallback", () => {
|
||||
source,
|
||||
renderBeautiful: () => {
|
||||
compactRenderCount += 1;
|
||||
return renderMermaidSVG(source);
|
||||
return compactRender.stdout;
|
||||
},
|
||||
renderOfficial: () => '<svg data-renderer="mermaid"></svg>',
|
||||
});
|
||||
expect(compactRenderCount).toBe(0);
|
||||
expect(svg).toContain('data-renderer="mermaid"');
|
||||
expect(compactRenderCount).toBe(1);
|
||||
expect(svg).toBe(compactRender.stdout);
|
||||
});
|
||||
|
||||
test("does not route a comment mentioning subgraphs to the official renderer", async () => {
|
||||
|
||||
@@ -9,14 +9,9 @@ interface MermaidRenderFallbackOptions {
|
||||
}
|
||||
|
||||
export const renderMermaidWithFallback = async ({
|
||||
source = "",
|
||||
renderBeautiful,
|
||||
renderOfficial,
|
||||
}: MermaidRenderFallbackOptions) => {
|
||||
// The compact renderer offsets subgraph nodes without moving their edge endpoints.
|
||||
// It can return a valid SVG with disconnected arrows, so exceptions are insufficient.
|
||||
if (/^\s*subgraph\b/m.test(source)) return renderOfficial();
|
||||
|
||||
try {
|
||||
return await renderBeautiful();
|
||||
} catch {
|
||||
@@ -40,6 +35,8 @@ export const renderThemedMermaidSvg = (
|
||||
) => renderMermaidWithFallback({
|
||||
source,
|
||||
renderBeautiful: async () => {
|
||||
// Repository dependency patches preserve CJK group IDs and ELK edge offsets
|
||||
// (upstream PRs #132 and #152). Keep supported subgraphs on this renderer.
|
||||
const { renderMermaidSVG, THEMES } = await (compactModule ??= import("beautiful-mermaid"));
|
||||
return scaleMermaidSvg(renderMermaidSVG(source, {
|
||||
...THEMES[theme], ...palette, transparent: true, font, padding,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
test("patched compact renderer preserves Chinese groups and cross-container edge geometry in source and browser bundles", () => {
|
||||
// ELK must initialize without the partial window globals installed by other suites.
|
||||
const result = spawnSync(process.execPath, ["--eval", `
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parseMermaid, renderMermaidSVG } from "beautiful-mermaid";
|
||||
const entry = import.meta.resolve("beautiful-mermaid");
|
||||
const { layoutGraphSync } = await import(new URL("./layout.ts", entry).href);
|
||||
const browser = await import(new URL("../dist/index.js", entry).href);
|
||||
const onBoundary = (p, n) => {
|
||||
const near = (a, b) => Math.abs(a - b) < 1.5;
|
||||
return ((near(p.x, n.x) || near(p.x, n.x + n.width)) && p.y >= n.y - 1.5 && p.y <= n.y + n.height + 1.5)
|
||||
|| ((near(p.y, n.y) || near(p.y, n.y + n.height)) && p.x >= n.x - 1.5 && p.x <= n.x + n.width + 1.5);
|
||||
};
|
||||
for (const direction of ["LR", "RL", "TD", "BT"]) {
|
||||
for (const body of [
|
||||
'subgraph 日志开启建议\\nA[生产环境] -->|不开启| B[查询日志]\\nC[开发测试] -->|按需开启| B\\nE[审计场景] -->|按需开启| B\\nend',
|
||||
'subgraph outer["Outer"]\\na[A]\\nsubgraph inner["Inner"]\\nb[B]\\nend\\nend\\na -->|yes| b',
|
||||
'subgraph 外层["外层分组"]\\na[A]\\nsubgraph 内层["内层分组"]\\nb[B]\\nend\\nend\\na -->|yes| b',
|
||||
'a[A] -->|yes| b[B]',
|
||||
]) {
|
||||
const source = 'flowchart ' + direction + '\\n' + body;
|
||||
const graph = parseMermaid(source);
|
||||
for (const group of graph.subgraphs) assert(group.id, 'empty Chinese group id');
|
||||
const positioned = layoutGraphSync(graph);
|
||||
const nodes = new Map(positioned.nodes.map(n => [n.id, n]));
|
||||
for (const edge of positioned.edges) {
|
||||
assert(onBoundary(edge.points[0], nodes.get(edge.source)), direction + ': detached start');
|
||||
assert(onBoundary(edge.points.at(-1), nodes.get(edge.target)), direction + ': detached end');
|
||||
}
|
||||
assert.equal(renderMermaidSVG(source), browser.renderMermaidSVG(source), 'Bun and browser patches differ');
|
||||
}
|
||||
}
|
||||
// Exercise the actual demo without rewriting its topology or saved content.
|
||||
const seed = readFileSync(new URL('../../../api/src/demo-seed-data.ts', import.meta.url), 'utf8');
|
||||
const start = seed.indexOf('flowchart TD', seed.indexOf('## 2. 全景架构与生态联动'));
|
||||
const source = seed.slice(start, seed.indexOf('\\n' + String.fromCharCode(92, 96).repeat(3), start));
|
||||
const svg = renderMermaidSVG(source);
|
||||
assert.equal(svg, browser.renderMermaidSVG(source));
|
||||
assert(svg.includes('全端客户端矩阵'));
|
||||
assert(svg.includes('AI 智能体与外部生态'));
|
||||
console.log('geometry and source/browser parity passed');
|
||||
`], { cwd: import.meta.dir, encoding: "utf8", timeout: 30_000 });
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("geometry and source/browser parity passed");
|
||||
});
|
||||
@@ -127,12 +127,21 @@ export const normalizeMermaidSvgForViewer = (
|
||||
) => {
|
||||
if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") return svg;
|
||||
|
||||
const document = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
const root = document.documentElement;
|
||||
if (root.tagName.toLowerCase() !== "svg" || document.querySelector("parsererror")) return svg;
|
||||
const parser = new DOMParser();
|
||||
const document = parser.parseFromString(svg, "image/svg+xml");
|
||||
let root: Element | null = document.documentElement;
|
||||
if (document.querySelector("parsererror")) {
|
||||
// Official Mermaid labels can contain HTML void tags such as <br>. Inline
|
||||
// SVG accepts them, but a standalone SVG image requires well-formed XML.
|
||||
// Parse in an inert HTML document, then serialize the SVG with XHTML
|
||||
// namespaces and closed void tags before handing it to the image viewer.
|
||||
root = parser.parseFromString(svg, "text/html").body.firstElementChild;
|
||||
if (root?.namespaceURI !== "http://www.w3.org/2000/svg") return svg;
|
||||
}
|
||||
if (root?.tagName.toLowerCase() !== "svg") return svg;
|
||||
|
||||
// Mermaid emits width="100%", which gives Blob-backed images incorrect intrinsic dimensions.
|
||||
root.setAttribute("width", String(presentation.width));
|
||||
root.setAttribute("height", String(presentation.height));
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
return new XMLSerializer().serializeToString(root);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import "fake-indexeddb/auto";
|
||||
const { localDb } = await import("./local-db.ts");
|
||||
const { api } = await import("./api.ts");
|
||||
const { createLocalNotebook, createLocalMemo, getLocalMemo } = await import("./local-mirror.ts");
|
||||
const { queueLocalAction, queueMemoCreate, syncQueuedChanges } = await import("./sync-queue.ts");
|
||||
const { acknowledgeNotebookId, notebookRemapTables, repairQueuedNotebookReferences } = await import("./notebook-id-remap.ts");
|
||||
const scope = "notebook-remap-test";
|
||||
const original = { createNotebook: api.createNotebook, createMemo: api.createMemo };
|
||||
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||
afterEach(async () => {
|
||||
Object.assign(api, original);
|
||||
if (navigatorDescriptor) Object.defineProperty(globalThis, "navigator", navigatorDescriptor);
|
||||
else delete globalThis.navigator;
|
||||
await Promise.all(localDb.tables.map((table) => table.clear()));
|
||||
});
|
||||
const online = () => Object.defineProperty(globalThis, "navigator", { configurable: true, value: { onLine: true } });
|
||||
const enqueueNotebook = async (name, parentId) => {
|
||||
const notebook = await createLocalNotebook(scope, { name, parentId });
|
||||
await queueLocalAction(scope, "notebook.create", notebook.id, { name, parentId, temporaryId: notebook.id });
|
||||
return notebook;
|
||||
};
|
||||
const enqueueMemo = async (notebookId) => {
|
||||
const memo = await createLocalMemo(scope, { notebookId, title: "Keep my note" });
|
||||
await queueMemoCreate(scope, { temporaryId: memo.id, notebookId, title: memo.title, tags: [], createdAt: memo.createdAt, updatedAt: memo.updatedAt });
|
||||
return memo;
|
||||
};
|
||||
|
||||
test("slow notebook creation remaps existing and in-flight child writes without a UI callback", async () => {
|
||||
online();
|
||||
const parent = await enqueueNotebook("parent");
|
||||
const child = await enqueueNotebook("child", parent.id);
|
||||
const memo = await enqueueMemo(child.id);
|
||||
let lateMemo;
|
||||
const sent = [];
|
||||
api.createNotebook = async (input) => {
|
||||
sent.push(input);
|
||||
if (input.name === "parent") {
|
||||
// User keeps writing while the parent's network request is in flight.
|
||||
lateMemo = await enqueueMemo(parent.id);
|
||||
return { notebook: { ...parent, id: "nb_parent" } };
|
||||
}
|
||||
expect(input.parentId).toBe("nb_parent");
|
||||
return { notebook: { ...child, id: "nb_child", parentId: "nb_parent" } };
|
||||
};
|
||||
api.createMemo = async (input) => {
|
||||
expect(["nb_parent", "nb_child"]).toContain(input.notebookId);
|
||||
return { memo: { ...memo, ...input, id: `memo_${input.notebookId}`, revision: 1, contentHash: "hash" } };
|
||||
};
|
||||
// Equal timestamps can put children before parents; subsequent runs must settle.
|
||||
for (let index = 0; index < 4; index++) await syncQueuedChanges({ scope });
|
||||
expect(await localDb.syncQueue.count()).toBe(0);
|
||||
expect(sent).toHaveLength(2);
|
||||
expect((await getLocalMemo(scope, memo.id)).notebookId).toBe("nb_child");
|
||||
expect((await getLocalMemo(scope, lateMemo.id)).notebookId).toBe("nb_parent");
|
||||
expect(await localDb.notebooks.get([scope, parent.id])).toBeUndefined();
|
||||
expect((await localDb.notebooks.get([scope, "nb_child"])).parentId).toBe("nb_parent");
|
||||
});
|
||||
|
||||
test("failed parents and unmapped legacy orphans stay local without invalid API requests", async () => {
|
||||
online();
|
||||
const parent = await enqueueNotebook("parent");
|
||||
const memo = await enqueueMemo(parent.id);
|
||||
await enqueueNotebook("child", parent.id);
|
||||
await enqueueMemo("local_legacy_parent_without_mapping");
|
||||
api.createNotebook = async () => { throw new Error("offline"); };
|
||||
api.createMemo = async () => { throw new Error("must not send a temporary notebook id"); };
|
||||
const result = await syncQueuedChanges({ scope });
|
||||
expect(result).toMatchObject({ attempted: 1, failed: 1 });
|
||||
expect((await localDb.syncQueue.get(`memo.create:${memo.id}`)).attemptCount).toBe(0);
|
||||
expect((await getLocalMemo(scope, memo.id)).title).toBe("Keep my note");
|
||||
});
|
||||
|
||||
test("persisted mappings repair delayed errors, batch targets and stale-tab writes after reopening", async () => {
|
||||
const parent = await createLocalNotebook(scope, { name: "parent" });
|
||||
await localDb.transaction("rw", notebookRemapTables, () => acknowledgeNotebookId(scope, parent.id, { ...parent, id: "nb_parent" }));
|
||||
localDb.close();
|
||||
await localDb.open();
|
||||
const kinds = ["memo.create", "memo.moveBatch", "memo.merge", "notebook.create", "notebook.update", "notebook.delete"];
|
||||
for (const kind of kinds) {
|
||||
await queueLocalAction(scope, kind, parent.id, {
|
||||
notebookId: parent.id, parentId: parent.id, notebookIds: [parent.id, "nb_other"],
|
||||
});
|
||||
}
|
||||
await queueLocalAction("another-account", "memo.moveBatch", "other", { notebookId: parent.id });
|
||||
await localDb.syncQueue.toCollection().modify({ status: "error", lastError: "Notebook not found", nextAttemptAt: "2099-01-01T00:00:00Z" });
|
||||
await repairQueuedNotebookReferences(scope);
|
||||
const repaired = (await localDb.syncQueue.toArray()).filter((item) => item.scope === scope);
|
||||
expect(repaired).toHaveLength(kinds.length);
|
||||
for (const item of repaired) {
|
||||
expect(item).toMatchObject({ status: "pending", nextAttemptAt: null, lastError: null });
|
||||
expect(item.payload).toMatchObject({ notebookId: "nb_parent", parentId: "nb_parent", notebookIds: ["nb_parent", "nb_other"] });
|
||||
if (item.kind === "notebook.update" || item.kind === "notebook.delete") expect(item.memoId).toBe("nb_parent");
|
||||
}
|
||||
expect((await localDb.syncQueue.toArray()).find((item) => item.scope === "another-account").payload.notebookId).toBe(parent.id);
|
||||
});
|
||||
|
||||
test("acknowledgement failure rolls back notebook mapping, local relationships and queue rewrites", async () => {
|
||||
online();
|
||||
const parent = await enqueueNotebook("parent");
|
||||
const memo = await enqueueMemo(parent.id);
|
||||
const child = await enqueueNotebook("child", parent.id);
|
||||
api.createNotebook = async () => ({ notebook: { ...parent, id: "nb_parent" } });
|
||||
const failDelete = () => { throw new Error("injected acknowledgement failure"); };
|
||||
localDb.syncQueue.hook("deleting", failDelete);
|
||||
try {
|
||||
expect(await syncQueuedChanges({ scope })).toMatchObject({ synced: 0, failed: 1 });
|
||||
} finally {
|
||||
localDb.syncQueue.hook("deleting").unsubscribe(failDelete);
|
||||
}
|
||||
expect(await localDb.syncMeta.count()).toBe(0);
|
||||
expect(await localDb.notebooks.get([scope, "nb_parent"])).toBeUndefined();
|
||||
expect(await localDb.notebooks.get([scope, parent.id])).toBeDefined();
|
||||
expect((await localDb.notebooks.get([scope, child.id])).parentId).toBe(parent.id);
|
||||
expect((await getLocalMemo(scope, memo.id)).notebookId).toBe(parent.id);
|
||||
expect((await localDb.syncQueue.get(`memo.create:${memo.id}`)).payload.notebookId).toBe(parent.id);
|
||||
});
|
||||
|
||||
test("remapping preserves drafts, conflicts, in-flight claims and content containing the temporary id", async () => {
|
||||
const parent = await createLocalNotebook(scope, { name: "parent" });
|
||||
const memo = await enqueueMemo(parent.id);
|
||||
const draft = { memoId: memo.id, title: "Latest unsaved edit", contentJson: { type: "doc", content: [] }, tagsText: "", updatedAt: new Date().toISOString() };
|
||||
await localDb.drafts.put(draft);
|
||||
await queueLocalAction(scope, "notebook.update", parent.id, { notebookId: parent.id, name: parent.id });
|
||||
const conflict = (await localDb.syncQueue.toArray()).find((item) => item.kind === "notebook.update");
|
||||
await localDb.syncQueue.update(conflict.id, { status: "conflict", lastError: "Keep this conflict" });
|
||||
await queueLocalAction(scope, "memo.moveBatch", "claimed", { notebookId: parent.id });
|
||||
const claimed = (await localDb.syncQueue.toArray()).find((item) => item.kind === "memo.moveBatch");
|
||||
await localDb.syncQueue.update(claimed.id, { status: "syncing", claimId: "another-tab" });
|
||||
await localDb.transaction("rw", notebookRemapTables, () => acknowledgeNotebookId(scope, parent.id, { ...parent, id: "nb_parent" }));
|
||||
expect(await localDb.drafts.get(memo.id)).toEqual(draft);
|
||||
expect(await localDb.syncQueue.get(conflict.id)).toMatchObject({ status: "conflict", lastError: "Keep this conflict", payload: { notebookId: "nb_parent", name: parent.id } });
|
||||
expect(await localDb.syncQueue.get(claimed.id)).toMatchObject({ status: "syncing", claimId: "another-tab", payload: { notebookId: parent.id } });
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Notebook } from "@edgeever/shared";
|
||||
import { localDb, type SyncQueueItem } from "@/lib/local-db";
|
||||
|
||||
// Keep notebook mappings separate from the memo mappings observed by editors.
|
||||
const mappingPrefix = "notebook-id:";
|
||||
export const notebookRemapTables = [localDb.syncQueue, localDb.syncMeta, localDb.notebooks, localDb.memos];
|
||||
|
||||
const notebookReferences = (item: SyncQueueItem) => {
|
||||
const payload = item.payload as Record<string, unknown>;
|
||||
return [payload.notebookId, payload.parentId,
|
||||
...(Array.isArray(payload.notebookIds) ? payload.notebookIds : []),
|
||||
...(item.kind === "notebook.update" || item.kind === "notebook.delete" ? [item.memoId] : []),
|
||||
].filter((id): id is string => typeof id === "string");
|
||||
};
|
||||
|
||||
export const hasTemporaryNotebookDependency = (item: SyncQueueItem) =>
|
||||
notebookReferences(item).some((id) => id.startsWith("local_"));
|
||||
|
||||
// Called inside a transaction, including the create acknowledgement when present.
|
||||
const rewriteNotebookReferences = async (scope: string, temporaryId: string, remoteId: string) => {
|
||||
for (const item of await localDb.syncQueue.toArray()) {
|
||||
if (item.scope !== scope || item.status === "syncing" || !notebookReferences(item).includes(temporaryId)) continue;
|
||||
const payload = { ...item.payload } as Record<string, unknown>;
|
||||
for (const field of ["notebookId", "parentId"]) {
|
||||
if (payload[field] === temporaryId) payload[field] = remoteId;
|
||||
}
|
||||
if (Array.isArray(payload.notebookIds)) {
|
||||
payload.notebookIds = payload.notebookIds.map((id) => id === temporaryId ? remoteId : id);
|
||||
}
|
||||
await localDb.syncQueue.put({
|
||||
...item,
|
||||
memoId: item.kind.startsWith("notebook.") && item.memoId === temporaryId ? remoteId : item.memoId,
|
||||
payload,
|
||||
// Dependency errors can retry immediately; preserve genuine conflicts.
|
||||
...(item.status === "error" ? {
|
||||
status: "pending" as const, nextAttemptAt: null, lastError: null,
|
||||
lastErrorCode: null, lastErrorDetails: null, claimId: null,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
await localDb.memos.where("scope").equals(scope).filter((memo) => memo.notebookId === temporaryId)
|
||||
.modify((memo) => { memo.notebookId = remoteId; });
|
||||
await localDb.notebooks.where("scope").equals(scope).filter((notebook) => notebook.parentId === temporaryId)
|
||||
.modify({ parentId: remoteId });
|
||||
};
|
||||
|
||||
export const acknowledgeNotebookId = async (scope: string, temporaryId: string, notebook: Notebook) => {
|
||||
if (temporaryId === notebook.id) return;
|
||||
await localDb.syncMeta.put({
|
||||
scope, key: `${mappingPrefix}${temporaryId}`, value: notebook.id, updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await rewriteNotebookReferences(scope, temporaryId, notebook.id);
|
||||
await localDb.notebooks.delete([scope, temporaryId]);
|
||||
await localDb.notebooks.put({ ...notebook, scope });
|
||||
};
|
||||
|
||||
// Replay persisted mappings after reload, including writes from a stale UI/tab.
|
||||
export const repairQueuedNotebookReferences = async (scope?: string, itemId?: string) => {
|
||||
await localDb.transaction("rw", notebookRemapTables, async () => {
|
||||
const item = itemId ? await localDb.syncQueue.get(itemId) : undefined;
|
||||
if (itemId && (!item || !hasTemporaryNotebookDependency(item))) return;
|
||||
const queued = item ? [item] : await localDb.syncQueue.toArray();
|
||||
const references = new Set(queued
|
||||
.filter((entry) => !scope || entry.scope === scope)
|
||||
.flatMap((entry) => notebookReferences(entry)
|
||||
.filter((id) => id.startsWith("local_"))
|
||||
.map((id) => JSON.stringify([entry.scope, id]))));
|
||||
if (references.size === 0) return;
|
||||
const mappings = scope
|
||||
? await localDb.syncMeta.where("scope").equals(scope).toArray()
|
||||
: await localDb.syncMeta.toArray();
|
||||
for (const mapping of mappings) {
|
||||
if (!mapping.key.startsWith(mappingPrefix)) continue;
|
||||
const temporaryId = mapping.key.slice(mappingPrefix.length);
|
||||
if (!references.has(JSON.stringify([mapping.scope, temporaryId]))) continue;
|
||||
await rewriteNotebookReferences(mapping.scope, temporaryId, mapping.value);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -400,7 +400,8 @@ describe("web sync conflict recovery", () => {
|
||||
test("replays offline appearance edits and explicit resets without dropping fields", async () => {
|
||||
const { createLocalNotebook, updateLocalNotebook, getLocalNotebook } = await import("./local-mirror.ts");
|
||||
const scope = createLocalDataScope("https://demo.edgeever.org", "appearance-user");
|
||||
const notebook = await createLocalNotebook(scope, { name: "Heimnetz" });
|
||||
const notebook = { ...await createLocalNotebook(scope, { name: "Heimnetz" }), id: "nb_appearance" };
|
||||
await localDb.notebooks.put({ ...notebook, scope });
|
||||
const original = api.updateNotebook;
|
||||
const previousNavigator = globalThis.navigator;
|
||||
globalThis.navigator = { onLine: true };
|
||||
|
||||
@@ -33,6 +33,7 @@ import { isBrowserOffline } from "@/lib/network-status";
|
||||
import { parseTagsText } from "@/lib/utils";
|
||||
import { createClientUuid } from "@/lib/client-id";
|
||||
import { notebookDeleteIdsFromPayload } from "@/lib/notebook-delete";
|
||||
import { acknowledgeNotebookId, hasTemporaryNotebookDependency, notebookRemapTables, repairQueuedNotebookReferences } from "@/lib/notebook-id-remap";
|
||||
|
||||
export type { SyncQueueSummary, SyncRunResult } from "@edgeever/shared";
|
||||
export type SyncQueueResult = MemoDetail | Notebook | MemoTemplate | Resource | null;
|
||||
@@ -390,6 +391,7 @@ const runQueuedChanges = async (options: {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await repairQueuedNotebookReferences(options.scope);
|
||||
const candidates = (await localDb.syncQueue.where("status").anyOf("pending", "error").toArray())
|
||||
.filter((item) => !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now)
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
@@ -413,6 +415,9 @@ const runQueuedChanges = async (options: {
|
||||
}
|
||||
|
||||
for (const candidate of items) {
|
||||
// Creates earlier in this batch may have resolved this dependency. Also
|
||||
// repair actions written by another tab since the initial queue snapshot.
|
||||
await repairQueuedNotebookReferences(candidate.scope, candidate.id);
|
||||
const item = await claimQueueItem(candidate.id);
|
||||
if (!item) {
|
||||
continue;
|
||||
@@ -487,6 +492,8 @@ const claimQueueItem = (id: string) =>
|
||||
if (!item || (item.status !== "pending" && item.status !== "error")) {
|
||||
return null;
|
||||
}
|
||||
if (hasTemporaryNotebookDependency(item)) return null;
|
||||
if (["memo.update", "memo.delete", "memo.restore"].includes(item.kind) && item.memoId.startsWith("local_")) return null;
|
||||
|
||||
const claimId = createClientUuid();
|
||||
const claimedItem: SyncQueueItem = {
|
||||
@@ -515,13 +522,18 @@ const deletedMemoIdsFromAction = (item: SyncQueueItem): string[] => {
|
||||
};
|
||||
|
||||
const acknowledgeClaimedQueueItem = (item: SyncQueueItem, result: SyncQueueResult): Promise<MemoUpdateAcknowledgement> =>
|
||||
localDb.transaction("rw", [localDb.syncQueue, localDb.drafts, localDb.memos], async () => {
|
||||
localDb.transaction("rw", item.kind === "notebook.create"
|
||||
? [...notebookRemapTables, localDb.drafts]
|
||||
: [localDb.syncQueue, localDb.drafts, localDb.memos], async () => {
|
||||
const current = await localDb.syncQueue.get(item.id);
|
||||
const memo = result && item.kind === "memo.update" && "contentHash" in result
|
||||
? result as MemoDetail
|
||||
: null;
|
||||
|
||||
if (current?.claimId === item.claimId && current.status === "syncing") {
|
||||
if (item.kind === "notebook.create" && result && "name" in result && item.scope) {
|
||||
await acknowledgeNotebookId(item.scope, item.memoId, result as Notebook);
|
||||
}
|
||||
await localDb.syncQueue.delete(item.id);
|
||||
// A successful delete supersedes older conflicted edits to those notes.
|
||||
// Keep the draft until the server has confirmed the delete, so a failed
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type NoteProsePaletteColors,
|
||||
} from "@edgeever/shared";
|
||||
import katex from "katex";
|
||||
import "katex/contrib/mhchem";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import { toCanvas } from "html-to-image";
|
||||
import { marked } from "marked";
|
||||
|
||||
@@ -1981,12 +1981,12 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--brand-green-border);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ProseMirror img.ProseMirror-selectednode {
|
||||
outline: 2px solid var(--brand-green);
|
||||
outline: 2px solid rgb(var(--slate-500-rgb) / 0.6);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@@ -1998,7 +1998,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
height: clamp(12rem, 42vw, 28rem);
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--brand-green-border);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(110deg, transparent 24%, rgb(255 255 255 / 0.72) 42%, transparent 60%) -18rem 0 / 18rem 100% no-repeat,
|
||||
@@ -2038,9 +2038,9 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid rgb(var(--slate-300-rgb) / 0.8);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
background: hsl(var(--card));
|
||||
padding: 0.4rem 0.75rem;
|
||||
color: #475569;
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow: 0 4px 16px rgb(15 23 42 / 0.12);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
@@ -2051,8 +2051,8 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
.ProseMirror .edgeever-image-upload-placeholder__spinner {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border: 2px solid rgb(var(--brand-green-rgb) / 0.2);
|
||||
border-top-color: var(--brand-green);
|
||||
border: 2px solid rgb(var(--slate-500-rgb) / 0.2);
|
||||
border-top-color: rgb(var(--slate-500-rgb));
|
||||
border-radius: 999px;
|
||||
animation: edgeever-image-placeholder-spin 700ms linear infinite;
|
||||
}
|
||||
@@ -2065,7 +2065,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
min-height: 3.5rem;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--brand-green-border);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(var(--slate-100-rgb) / 0.55);
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 0.08);
|
||||
@@ -2077,7 +2077,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
flex: none;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
color: var(--brand-green);
|
||||
color: rgb(var(--slate-500-rgb));
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-file-upload-placeholder__icon svg {
|
||||
@@ -2136,8 +2136,24 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-node img[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-node img[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.edgeever-image-drop-indicator {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
background: hsl(var(--foreground));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-node.is-selected img {
|
||||
outline: 2px solid var(--brand-green);
|
||||
outline: 2px solid rgb(var(--slate-500-rgb) / 0.6);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@@ -2151,11 +2167,11 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
margin-top: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--card));
|
||||
padding: 0.3rem;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
|
||||
line-height: 1;
|
||||
color: hsl(var(--foreground));
|
||||
font-family: var(--ui-font-family, Inter, ui-sans-serif, system-ui, sans-serif);
|
||||
@@ -2175,7 +2191,7 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
|
||||
.ProseMirror .edgeever-image-resource-actions:not(:empty) {
|
||||
width: 100%;
|
||||
border-top: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
padding-top: 0.3rem;
|
||||
}
|
||||
|
||||
@@ -2184,11 +2200,11 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
z-index: 2;
|
||||
border: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
background: hsl(var(--card));
|
||||
padding: 0.3rem;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -2286,12 +2302,17 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-gallery__group + .edgeever-image-gallery__group {
|
||||
border-inline-start: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
|
||||
border-inline-start: 1px solid hsl(var(--border));
|
||||
padding-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-gallery__toolbar {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
inset-inline-start: 0;
|
||||
z-index: 10;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: fit-content;
|
||||
@@ -2299,11 +2320,26 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid rgba(var(--brand-green-200-rgb), 0.95);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
background: hsl(var(--card));
|
||||
padding: 0.3rem;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
/* Bridge the gap so the pointer can reach the floating toolbar. */
|
||||
.ProseMirror .edgeever-image-gallery__toolbar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 100%;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-gallery:hover > .edgeever-image-gallery__toolbar,
|
||||
.ProseMirror .edgeever-image-gallery:focus-within > .edgeever-image-gallery__toolbar {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-gallery__button {
|
||||
@@ -2311,21 +2347,24 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
padding: 0 0.5rem;
|
||||
color: #475569;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-gallery__button:hover,
|
||||
.ProseMirror .edgeever-image-gallery__button:hover {
|
||||
background: var(--workspace-hover);
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-gallery__button:focus-visible,
|
||||
.ProseMirror .edgeever-image-gallery__button.is-active {
|
||||
background: var(--brand-green-muted);
|
||||
color: var(--brand-green-text);
|
||||
background: color-mix(in srgb, var(--workspace-selection) 65%, hsl(var(--card)));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-gallery__button:focus-visible {
|
||||
outline: 2px solid var(--brand-green);
|
||||
outline: 2px solid rgb(var(--slate-500-rgb));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -2350,17 +2389,17 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: #475569;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-preview-button:hover,
|
||||
.ProseMirror .edgeever-image-preview-button:focus-visible {
|
||||
background: var(--brand-green-muted);
|
||||
color: var(--brand-green-text);
|
||||
background: var(--workspace-hover);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-preview-button:focus-visible {
|
||||
outline: 2px solid var(--brand-green);
|
||||
outline: 2px solid rgb(var(--slate-500-rgb));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -2393,17 +2432,22 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: #475569;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-alignment:hover {
|
||||
background: var(--workspace-hover);
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-alignment:hover,
|
||||
.ProseMirror .edgeever-image-alignment.is-active {
|
||||
background: var(--brand-green-muted);
|
||||
color: var(--brand-green-text);
|
||||
background: color-mix(in srgb, var(--workspace-selection) 65%, hsl(var(--card)));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-alignment:focus-visible {
|
||||
outline: 2px solid var(--brand-green);
|
||||
.ProseMirror .edgeever-image-alignment:focus-visible,
|
||||
.ProseMirror .edgeever-image-preset:focus-visible,
|
||||
.ProseMirror .edgeever-image-resize-handle:focus-visible {
|
||||
outline: 2px solid rgb(var(--slate-500-rgb));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -2414,29 +2458,36 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: #475569;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-preset:hover,
|
||||
.ProseMirror .edgeever-image-preset:hover {
|
||||
background: var(--workspace-hover);
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-preset.is-active {
|
||||
background: var(--brand-green-muted);
|
||||
color: var(--brand-green-text);
|
||||
background: color-mix(in srgb, var(--workspace-selection) 65%, hsl(var(--card)));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-resize-handle {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: inline-flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(135deg, transparent 46%, var(--brand-green) 48%, var(--brand-green) 54%, transparent 56%),
|
||||
linear-gradient(135deg, transparent 62%, rgba(var(--brand-green-600-rgb), 0.8) 64%, rgba(var(--brand-green-600-rgb), 0.8) 70%, transparent 72%);
|
||||
background: transparent;
|
||||
color: hsl(var(--foreground));
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.ProseMirror .edgeever-image-resize-handle:hover {
|
||||
background-color: var(--brand-green-muted);
|
||||
background-color: var(--workspace-hover);
|
||||
}
|
||||
|
||||
.ProseMirror.edgeever-note-rich-editor p.is-empty::before {
|
||||
|
||||
@@ -4,14 +4,19 @@ import { readFileSync } from "node:fs";
|
||||
const globals = readFileSync(new URL("./globals.css", import.meta.url), "utf8");
|
||||
|
||||
describe("image gallery layout", () => {
|
||||
test("reserves a wrapping toolbar row instead of overlaying image controls", () => {
|
||||
test("floats the wrapping toolbar above the images and reveals it on hover or keyboard focus", () => {
|
||||
const toolbar = globals.match(/\.ProseMirror \.edgeever-image-gallery__toolbar \{([^}]+)\}/)?.[1];
|
||||
expect(toolbar).toContain("position: relative;");
|
||||
expect(toolbar).toContain("position: absolute;");
|
||||
expect(toolbar).toContain("bottom: 100%;");
|
||||
expect(toolbar).toContain("flex-wrap: wrap;");
|
||||
expect(toolbar).toContain("max-width: 100%;");
|
||||
expect(toolbar).toContain("margin-bottom: 0.5rem;");
|
||||
expect(toolbar).not.toContain("position: absolute");
|
||||
expect(toolbar).toContain("visibility: hidden;");
|
||||
expect(toolbar).toContain("pointer-events: none;");
|
||||
expect(toolbar).not.toContain("opacity: 0");
|
||||
expect(globals).toMatch(
|
||||
/\.ProseMirror \.edgeever-image-gallery:hover > \.edgeever-image-gallery__toolbar,\s*\.ProseMirror \.edgeever-image-gallery:focus-within > \.edgeever-image-gallery__toolbar \{\s*visibility: visible;\s*pointer-events: auto;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("fills React image node grid cells without exposing a persistent background", () => {
|
||||
|
||||
Reference in new issue
Block a user