chore: 1.0.0 release
This commit is contained in:
+798
@@ -0,0 +1,798 @@
|
||||
import {
|
||||
assertWritableChangePath,
|
||||
basename,
|
||||
dirname,
|
||||
extractPagePaths,
|
||||
joinDeckPath,
|
||||
normalizeRelativePath,
|
||||
titleFromManifest,
|
||||
} from "./lib.js";
|
||||
|
||||
const EDITOR_ORIGIN = "https://www.kimi.com";
|
||||
const PENPAL_MODULE = "https://statics.moonshot.cn/neo-design/assets/penpal-C4NjirZE.js";
|
||||
const MAX_TEXT_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_CHANGE_COUNT = 600;
|
||||
|
||||
const elements = {
|
||||
frame: document.querySelector("#editor"),
|
||||
openFolder: document.querySelector("#open-folder"),
|
||||
openDemo: document.querySelector("#open-demo"),
|
||||
reload: document.querySelector("#reload-deck"),
|
||||
documentTitle: document.querySelector("#document-title"),
|
||||
documentPath: document.querySelector("#document-path"),
|
||||
connectionStatus: document.querySelector("#connection-status"),
|
||||
connectionLabel: document.querySelector("#connection-label"),
|
||||
saveState: document.querySelector("#save-state"),
|
||||
loadingCard: document.querySelector("#loading-card"),
|
||||
loadingTitle: document.querySelector("#loading-title"),
|
||||
loadingMessage: document.querySelector("#loading-message"),
|
||||
activityPanel: document.querySelector("#activity-panel"),
|
||||
activityList: document.querySelector("#activity-list"),
|
||||
toggleActivity: document.querySelector("#toggle-activity"),
|
||||
closeActivity: document.querySelector("#close-activity"),
|
||||
openDialog: document.querySelector("#open-dialog"),
|
||||
closeOpenDialog: document.querySelector("#close-open-dialog"),
|
||||
uploadDropzone: document.querySelector("#upload-dropzone"),
|
||||
chooseWritableFolder: document.querySelector("#choose-writable-folder"),
|
||||
uploadFolder: document.querySelector("#upload-folder"),
|
||||
deckDialog: document.querySelector("#deck-dialog"),
|
||||
deckOptions: document.querySelector("#deck-options"),
|
||||
folderFallback: document.querySelector("#folder-fallback"),
|
||||
toastRegion: document.querySelector("#toast-region"),
|
||||
};
|
||||
|
||||
const state = {
|
||||
remote: null,
|
||||
connection: null,
|
||||
source: "demo",
|
||||
directoryHandle: null,
|
||||
fileIndex: new Map(),
|
||||
memoryFiles: new Map(),
|
||||
manifestPath: "presentation.pptd",
|
||||
manifestDirectory: "",
|
||||
manifestContent: "",
|
||||
deckTitle: "演示文稿",
|
||||
lastDeckPayload: null,
|
||||
saveQueue: Promise.resolve(),
|
||||
imageCache: new Map(),
|
||||
dragDepth: 0,
|
||||
readOnlyFallback: false,
|
||||
};
|
||||
|
||||
function setConnection(kind, label) {
|
||||
elements.connectionStatus.className = `status-pill status-${kind}`;
|
||||
elements.connectionLabel.textContent = label;
|
||||
document.documentElement.dataset.connection = kind;
|
||||
}
|
||||
|
||||
function setSaveState(label, kind = "idle") {
|
||||
elements.saveState.textContent = label;
|
||||
elements.saveState.dataset.kind = kind;
|
||||
document.documentElement.dataset.saveState = kind;
|
||||
}
|
||||
|
||||
function setLoading(visible, title, message) {
|
||||
elements.loadingCard.classList.toggle("is-hidden", !visible);
|
||||
elements.loadingCard.setAttribute("aria-hidden", String(!visible));
|
||||
if (title) elements.loadingTitle.textContent = title;
|
||||
if (message) elements.loadingMessage.textContent = message;
|
||||
}
|
||||
|
||||
function formatTime(date = new Date()) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function addActivity(message, tone = "info") {
|
||||
const item = document.createElement("li");
|
||||
item.dataset.tone = tone;
|
||||
item.innerHTML = `<time>${formatTime()}</time><span></span>`;
|
||||
item.querySelector("span").textContent = message;
|
||||
elements.activityList.prepend(item);
|
||||
while (elements.activityList.children.length > 40) {
|
||||
elements.activityList.lastElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function toast(message, tone = "info") {
|
||||
const item = document.createElement("div");
|
||||
item.className = `toast toast-${tone}`;
|
||||
item.textContent = message;
|
||||
elements.toastRegion.append(item);
|
||||
requestAnimationFrame(() => item.classList.add("is-visible"));
|
||||
setTimeout(() => {
|
||||
item.classList.remove("is-visible");
|
||||
setTimeout(() => item.remove(), 220);
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
function editorQuery() {
|
||||
return new URLSearchParams({
|
||||
sdkMode: "ppt-editor",
|
||||
pptPlatform: "neodeck-local",
|
||||
functional: JSON.stringify({
|
||||
fullscreen: true,
|
||||
present: true,
|
||||
export: true,
|
||||
close: false,
|
||||
annotation: false,
|
||||
feedback: false,
|
||||
share: false,
|
||||
versionHistory: false,
|
||||
}),
|
||||
sdkSaveMode: "external",
|
||||
sdkImageMode: "external",
|
||||
});
|
||||
}
|
||||
|
||||
function demoDeck() {
|
||||
const manifest = JSON.stringify({
|
||||
version: "v2",
|
||||
title: "NeoDeck Local",
|
||||
size: [960, 540],
|
||||
pages: ["pages/01.page", "pages/02.page"],
|
||||
});
|
||||
const pages = [
|
||||
{
|
||||
path: "pages/01.page",
|
||||
content: JSON.stringify({
|
||||
pageType: "content",
|
||||
background: { color: "#f7f8fc" },
|
||||
elements: [
|
||||
{
|
||||
elementId: "eyebrow",
|
||||
elementType: "text",
|
||||
bounds: [92, 96, 776, 40],
|
||||
content: {
|
||||
text: '<p><span style="font-size:16px;color:#6c5ce7;font-weight:700;letter-spacing:2px">NEODECK LOCAL</span></p>',
|
||||
},
|
||||
},
|
||||
{
|
||||
elementId: "title",
|
||||
elementType: "text",
|
||||
bounds: [92, 155, 776, 142],
|
||||
content: {
|
||||
text: '<p><span style="font-size:52px;color:#171923;font-weight:700">直接打开和保存<br/>本地 PPTD</span></p>',
|
||||
},
|
||||
},
|
||||
{
|
||||
elementId: "subtitle",
|
||||
elementType: "text",
|
||||
bounds: [94, 330, 660, 58],
|
||||
content: {
|
||||
text: '<p><span style="font-size:20px;color:#667085">第三方宿主 · 本地文件 · Kimi neo-ppt 编辑内核</span></p>',
|
||||
},
|
||||
},
|
||||
],
|
||||
animations: [
|
||||
{
|
||||
elementId: "title",
|
||||
effect: "fade-in",
|
||||
trigger: "onClick",
|
||||
direction: "up",
|
||||
easing: "ease-in-out",
|
||||
durationMs: 700,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "pages/02.page",
|
||||
content: JSON.stringify({
|
||||
pageType: "content",
|
||||
background: { color: "#171923" },
|
||||
elements: [
|
||||
{
|
||||
elementId: "second-title",
|
||||
elementType: "text",
|
||||
bounds: [100, 120, 760, 96],
|
||||
content: {
|
||||
text: '<p><span style="font-size:42px;color:#ffffff;font-weight:700">文件系统桥接已经就绪</span></p>',
|
||||
},
|
||||
},
|
||||
{
|
||||
elementId: "steps",
|
||||
elementType: "text",
|
||||
bounds: [102, 250, 730, 165],
|
||||
content: {
|
||||
text: '<p><span style="font-size:22px;color:#cbd5e1">① 选择完整的 PPTD 项目文件夹</span></p><p><span style="font-size:22px;color:#cbd5e1">② 编辑器自动读取清单、页面与素材</span></p><p><span style="font-size:22px;color:#cbd5e1">③ 自动保存变更到原目录</span></p>',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
return {
|
||||
id: "neodeck-demo",
|
||||
title: "NeoDeck Local",
|
||||
manifestPath: "presentation.pptd",
|
||||
manifestContent: manifest,
|
||||
pages,
|
||||
basePath: "",
|
||||
isCreate: true,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRemote() {
|
||||
if (!state.remote) throw new Error("编辑器还没有连接完成");
|
||||
return state.remote;
|
||||
}
|
||||
|
||||
async function setDeck(payload, sourceLabel) {
|
||||
const remote = requireRemote();
|
||||
setLoading(true, "正在载入 PPTD", sourceLabel);
|
||||
state.lastDeckPayload = payload;
|
||||
state.manifestContent = payload.manifestContent;
|
||||
state.manifestPath = payload.manifestPath;
|
||||
state.manifestDirectory = dirname(payload.manifestPath);
|
||||
state.deckTitle = payload.title;
|
||||
elements.documentTitle.textContent = payload.title;
|
||||
elements.documentPath.textContent = sourceLabel;
|
||||
elements.reload.disabled = false;
|
||||
|
||||
await remote.setPPTD(payload.id, {
|
||||
pptdContent: payload.manifestContent,
|
||||
pages: payload.pages,
|
||||
basePath: payload.basePath,
|
||||
pptdPath: payload.manifestPath,
|
||||
isCreate: true,
|
||||
});
|
||||
await remote.setEditable(true);
|
||||
const status = await remote.getSlideStatus();
|
||||
setLoading(false);
|
||||
addActivity(`已载入「${payload.title}」,共 ${payload.pages.length} 页`, "success");
|
||||
document.documentElement.dataset.deckStatus = "ready";
|
||||
window.dispatchEvent(new CustomEvent("neodeck:ready", { detail: status }));
|
||||
}
|
||||
|
||||
async function openDemo() {
|
||||
state.source = "demo";
|
||||
state.directoryHandle = null;
|
||||
state.fileIndex.clear();
|
||||
state.imageCache.clear();
|
||||
state.memoryFiles.clear();
|
||||
state.readOnlyFallback = false;
|
||||
const payload = demoDeck();
|
||||
state.memoryFiles.set(payload.manifestPath, payload.manifestContent);
|
||||
for (const page of payload.pages) state.memoryFiles.set(page.path, page.content);
|
||||
await setDeck(payload, "内置示例 · 修改保存在本页内存");
|
||||
setSaveState("示例 · 内存保存", "memory");
|
||||
}
|
||||
|
||||
async function indexDirectory(directoryHandle) {
|
||||
const index = new Map();
|
||||
async function walk(handle, prefix = "") {
|
||||
for await (const [name, entry] of handle.entries()) {
|
||||
if (name === ".DS_Store") continue;
|
||||
const path = prefix ? `${prefix}/${name}` : name;
|
||||
if (entry.kind === "directory") await walk(entry, path);
|
||||
else index.set(normalizeRelativePath(path), entry);
|
||||
}
|
||||
}
|
||||
await walk(directoryHandle);
|
||||
return index;
|
||||
}
|
||||
|
||||
function indexFallbackFiles(fileList) {
|
||||
const index = new Map();
|
||||
const files = [...fileList];
|
||||
const firstPath = files[0]?.webkitRelativePath || files[0]?.name || "";
|
||||
const rootName = firstPath.includes("/") ? firstPath.split("/")[0] : "";
|
||||
for (const file of files) {
|
||||
let path = file.webkitRelativePath || file.name;
|
||||
if (rootName && path.startsWith(`${rootName}/`)) path = path.slice(rootName.length + 1);
|
||||
index.set(normalizeRelativePath(path), { kind: "file", getFile: async () => file });
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
async function chooseManifest(paths) {
|
||||
if (paths.length === 1) return paths[0];
|
||||
elements.deckOptions.replaceChildren();
|
||||
const choice = new Promise((resolve) => {
|
||||
for (const path of paths) {
|
||||
const button = document.createElement("button");
|
||||
button.className = "deck-option";
|
||||
button.type = "button";
|
||||
button.innerHTML = `<span class="deck-file-icon">P</span><span><strong></strong><small></small></span><span>›</span>`;
|
||||
button.querySelector("strong").textContent = basename(path);
|
||||
button.querySelector("small").textContent = path;
|
||||
button.addEventListener("click", () => {
|
||||
elements.deckDialog.close();
|
||||
resolve(path);
|
||||
});
|
||||
elements.deckOptions.append(button);
|
||||
}
|
||||
elements.deckDialog.addEventListener(
|
||||
"close",
|
||||
() => {
|
||||
if (elements.deckDialog.returnValue === "cancel") resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
elements.deckDialog.showModal();
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function textFromIndexedFile(path) {
|
||||
const entry = state.fileIndex.get(normalizeRelativePath(path));
|
||||
if (!entry) throw new Error(`找不到文件:${path}`);
|
||||
const file = await entry.getFile();
|
||||
if (file.size > MAX_TEXT_BYTES) throw new Error(`文件超过 20 MiB:${path}`);
|
||||
return file.text();
|
||||
}
|
||||
|
||||
async function loadManifest(manifestPath, sourceLabel) {
|
||||
const manifestContent = await textFromIndexedFile(manifestPath);
|
||||
const pagePaths = extractPagePaths(manifestContent);
|
||||
const manifestDirectory = dirname(manifestPath);
|
||||
const pages = [];
|
||||
const missing = [];
|
||||
for (const pagePath of pagePaths.slice(0, 500)) {
|
||||
const indexedPath = joinDeckPath(manifestDirectory, pagePath);
|
||||
try {
|
||||
pages.push({ path: pagePath, content: await textFromIndexedFile(indexedPath) });
|
||||
} catch {
|
||||
missing.push(pagePath);
|
||||
}
|
||||
}
|
||||
if (pages.length === 0) throw new Error(".pptd 清单引用的页面均无法读取");
|
||||
if (missing.length) toast(`跳过 ${missing.length} 个无法读取的页面`, "warning");
|
||||
|
||||
const fallbackTitle = basename(manifestPath).replace(/\.pptd$/i, "");
|
||||
const title = titleFromManifest(manifestContent, fallbackTitle);
|
||||
await setDeck(
|
||||
{
|
||||
id: fallbackTitle,
|
||||
title,
|
||||
manifestPath,
|
||||
manifestContent,
|
||||
pages,
|
||||
basePath: manifestDirectory ? `${manifestDirectory}/` : "",
|
||||
isCreate: true,
|
||||
},
|
||||
sourceLabel,
|
||||
);
|
||||
setSaveState(state.readOnlyFallback ? "只读打开" : "自动保存已开启", state.readOnlyFallback ? "readonly" : "saved");
|
||||
}
|
||||
|
||||
async function openDirectoryHandle(directoryHandle, label = directoryHandle.name) {
|
||||
setLoading(true, "正在扫描文件夹", label);
|
||||
let permission = await directoryHandle.queryPermission?.({ mode: "readwrite" });
|
||||
if (permission !== "granted") permission = await directoryHandle.requestPermission?.({ mode: "readwrite" });
|
||||
if (permission !== "granted") throw new Error("没有获得文件夹读写权限");
|
||||
|
||||
state.source = "directory";
|
||||
state.directoryHandle = directoryHandle;
|
||||
state.readOnlyFallback = false;
|
||||
state.fileIndex = await indexDirectory(directoryHandle);
|
||||
state.imageCache.clear();
|
||||
const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd"));
|
||||
if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件");
|
||||
const manifestPath = await chooseManifest(manifests.sort());
|
||||
if (!manifestPath) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await loadManifest(manifestPath, `${label}/${manifestPath}`);
|
||||
}
|
||||
|
||||
async function openFallbackFiles(files) {
|
||||
state.source = "fallback";
|
||||
state.directoryHandle = null;
|
||||
state.readOnlyFallback = true;
|
||||
state.fileIndex = indexFallbackFiles(files);
|
||||
state.imageCache.clear();
|
||||
const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd"));
|
||||
if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件");
|
||||
const manifestPath = await chooseManifest(manifests.sort());
|
||||
if (manifestPath) await loadManifest(manifestPath, `${manifestPath} · 只读兼容模式`);
|
||||
}
|
||||
|
||||
async function openDirectoryHandleReadOnly(directoryHandle, label = directoryHandle.name) {
|
||||
setLoading(true, "正在扫描文件夹", label);
|
||||
state.source = "fallback";
|
||||
state.directoryHandle = null;
|
||||
state.readOnlyFallback = true;
|
||||
state.fileIndex = await indexDirectory(directoryHandle);
|
||||
state.imageCache.clear();
|
||||
const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd"));
|
||||
if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件");
|
||||
const manifestPath = await chooseManifest(manifests.sort());
|
||||
if (manifestPath) await loadManifest(manifestPath, `${label}/${manifestPath} · 拖放只读模式`);
|
||||
}
|
||||
|
||||
async function pickDirectory() {
|
||||
try {
|
||||
if ("showDirectoryPicker" in window) {
|
||||
const directoryHandle = await window.showDirectoryPicker({ id: "neodeck-project", mode: "readwrite" });
|
||||
setOpenDialog(false);
|
||||
await openDirectoryHandle(directoryHandle);
|
||||
} else {
|
||||
elements.folderFallback.click();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") return;
|
||||
handleError(error, "打开文件夹失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function openDroppedFolder(handles) {
|
||||
const usableHandles = handles.filter(Boolean);
|
||||
if (usableHandles.length !== 1 || usableHandles[0].kind !== "directory") {
|
||||
throw new Error("只能拖入一个完整的 PPTD 项目文件夹,不能单独拖入 .pptd 文件");
|
||||
}
|
||||
|
||||
const directoryHandle = usableHandles[0];
|
||||
try {
|
||||
setOpenDialog(false);
|
||||
await openDirectoryHandle(directoryHandle);
|
||||
} catch (error) {
|
||||
setOpenDialog(false);
|
||||
toast("无法以可写模式载入,正在尝试只读打开", "warning");
|
||||
await openDirectoryHandleReadOnly(directoryHandle);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIndexedPath(requestedPath) {
|
||||
if (typeof requestedPath !== "string" || !requestedPath.trim()) return null;
|
||||
if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath;
|
||||
let candidate = requestedPath.replace(/^file:\/\/+/, "").replaceAll("\\", "/");
|
||||
try {
|
||||
candidate = decodeURIComponent(candidate);
|
||||
} catch {
|
||||
// Preserve literal percent signs.
|
||||
}
|
||||
|
||||
const directCandidates = [];
|
||||
try {
|
||||
directCandidates.push(normalizeRelativePath(candidate));
|
||||
} catch {
|
||||
const parts = candidate.split("/").filter(Boolean);
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
try {
|
||||
directCandidates.push(normalizeRelativePath(parts.slice(index).join("/")));
|
||||
} catch {
|
||||
// Keep looking for a safe suffix.
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
directCandidates.push(joinDeckPath(state.manifestDirectory, candidate));
|
||||
} catch {
|
||||
// Absolute paths may still match a safe indexed suffix below.
|
||||
}
|
||||
for (const path of directCandidates) if (state.fileIndex.has(path)) return path;
|
||||
for (const path of state.fileIndex.keys()) {
|
||||
if (directCandidates.some((candidatePath) => path.endsWith(`/${candidatePath}`) || path === candidatePath)) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileToDataUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(String(reader.result)));
|
||||
reader.addEventListener("error", () => reject(reader.error || new Error("图片读取失败")));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveImage(requestedPath) {
|
||||
if (/^(?:data:image\/|https?:\/\/|blob:)/i.test(requestedPath)) return requestedPath;
|
||||
const path = resolveIndexedPath(requestedPath);
|
||||
if (!path) return "";
|
||||
const entry = state.fileIndex.get(path);
|
||||
const file = await entry.getFile();
|
||||
if (file.size > MAX_IMAGE_BYTES) return "";
|
||||
const cacheKey = `${path}:${file.lastModified}:${file.size}`;
|
||||
if (!state.imageCache.has(cacheKey)) state.imageCache.set(cacheKey, fileToDataUrl(file));
|
||||
return state.imageCache.get(cacheKey);
|
||||
}
|
||||
|
||||
async function getImages(payload = {}) {
|
||||
const paths = Array.isArray(payload.filePath) ? payload.filePath : [];
|
||||
const result = await Promise.all(paths.map((path) => resolveImage(path).catch(() => "")));
|
||||
const misses = result.filter((value) => !value).length;
|
||||
if (misses) addActivity(`${paths.length} 张图片中有 ${misses} 张未找到`, "warning");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getHandleForPath(root, path, create = false) {
|
||||
const parts = normalizeRelativePath(path).split("/");
|
||||
const fileName = parts.pop();
|
||||
let directory = root;
|
||||
for (const part of parts) directory = await directory.getDirectoryHandle(part, { create });
|
||||
return { directory, fileName, file: await directory.getFileHandle(fileName, { create }) };
|
||||
}
|
||||
|
||||
async function readBackup(path) {
|
||||
try {
|
||||
const { file } = await getHandleForPath(state.directoryHandle, path, false);
|
||||
return { existed: true, bytes: await (await file.getFile()).arrayBuffer() };
|
||||
} catch (error) {
|
||||
if (error?.name === "NotFoundError") return { existed: false, bytes: null };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFile(path, content) {
|
||||
const { file } = await getHandleForPath(state.directoryHandle, path, true);
|
||||
const writable = await file.createWritable();
|
||||
try {
|
||||
await writable.write(content);
|
||||
} finally {
|
||||
await writable.close();
|
||||
}
|
||||
state.fileIndex.set(path, file);
|
||||
}
|
||||
|
||||
async function deleteFile(path) {
|
||||
const parts = normalizeRelativePath(path).split("/");
|
||||
const fileName = parts.pop();
|
||||
let directory = state.directoryHandle;
|
||||
for (const part of parts) directory = await directory.getDirectoryHandle(part, { create: false });
|
||||
try {
|
||||
await directory.removeEntry(fileName);
|
||||
} catch (error) {
|
||||
if (error?.name !== "NotFoundError") throw error;
|
||||
}
|
||||
state.fileIndex.delete(path);
|
||||
}
|
||||
|
||||
async function rollbackChanges(backups) {
|
||||
const entries = [...backups.entries()].reverse();
|
||||
for (const [path, backup] of entries) {
|
||||
if (backup.existed) await writeFile(path, backup.bytes);
|
||||
else await deleteFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistChanges(payload = {}) {
|
||||
const changes = Array.isArray(payload.changes) ? payload.changes : [];
|
||||
if (changes.length === 0) return { fileContent: payload.fileContent, lastModifiedTime: Date.now() };
|
||||
if (changes.length > MAX_CHANGE_COUNT) throw new Error(`一次保存不能超过 ${MAX_CHANGE_COUNT} 个文件`);
|
||||
|
||||
const normalized = changes.map((change) => {
|
||||
const path = assertWritableChangePath(state.manifestDirectory, change.path);
|
||||
const operation = change.operate === "delete" ? "delete" : "put";
|
||||
const content = operation === "put" ? String(change.content ?? "") : null;
|
||||
if (content && new Blob([content]).size > MAX_TEXT_BYTES) throw new Error(`保存内容超过 20 MiB:${path}`);
|
||||
return { path, operation, content };
|
||||
});
|
||||
|
||||
if (state.source === "demo") {
|
||||
for (const change of normalized) {
|
||||
if (change.operation === "delete") state.memoryFiles.delete(change.path);
|
||||
else state.memoryFiles.set(change.path, change.content);
|
||||
}
|
||||
setSaveState("刚刚保存到内存", "memory");
|
||||
addActivity(`已在内存保存 ${normalized.length} 项变更`, "success");
|
||||
return { fileContent: payload.fileContent, lastModifiedTime: Date.now() };
|
||||
}
|
||||
|
||||
if (state.readOnlyFallback || !state.directoryHandle) {
|
||||
throw new Error("当前是只读兼容模式,请用支持文件夹授权的 Chromium 浏览器打开");
|
||||
}
|
||||
|
||||
const permission = await state.directoryHandle.queryPermission({ mode: "readwrite" });
|
||||
if (permission !== "granted") throw new Error("项目文件夹的写入权限已经失效");
|
||||
|
||||
setSaveState("正在保存…", "saving");
|
||||
const backups = new Map();
|
||||
try {
|
||||
for (const change of normalized) backups.set(change.path, await readBackup(change.path));
|
||||
for (const change of normalized) {
|
||||
if (change.operation === "delete") await deleteFile(change.path);
|
||||
else await writeFile(change.path, change.content);
|
||||
}
|
||||
} catch (error) {
|
||||
await rollbackChanges(backups).catch((rollbackError) => {
|
||||
addActivity(`回滚未完全成功:${rollbackError.message}`, "error");
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
setSaveState("刚刚已保存", "saved");
|
||||
addActivity(`已保存 ${normalized.length} 个文件变更`, "success");
|
||||
return { fileContent: payload.fileContent, lastModifiedTime: Date.now() };
|
||||
}
|
||||
|
||||
function onSave(payload) {
|
||||
const operation = state.saveQueue.then(() => persistChanges(payload));
|
||||
state.saveQueue = operation.catch(() => undefined);
|
||||
return operation.catch((error) => {
|
||||
setSaveState("保存失败", "error");
|
||||
addActivity(error.message, "error");
|
||||
toast(`保存失败:${error.message}`, "error");
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleFullscreen(value) {
|
||||
if (value === false || document.fullscreenElement) {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
return false;
|
||||
}
|
||||
await document.documentElement.requestFullscreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleError(error, context = "操作失败") {
|
||||
const message = error?.message || String(error);
|
||||
setLoading(false);
|
||||
addActivity(`${context}:${message}`, "error");
|
||||
toast(`${context}:${message}`, "error");
|
||||
console.error(context, error);
|
||||
}
|
||||
|
||||
async function connectEditor() {
|
||||
setConnection("connecting", "连接中");
|
||||
addActivity("正在连接公开 neo-ppt 编辑器");
|
||||
elements.frame.src = `${EDITOR_ORIGIN}/neo-ppt/?${editorQuery()}`;
|
||||
|
||||
try {
|
||||
const [{ i: connect, r: WindowMessenger }] = await Promise.all([
|
||||
import(PENPAL_MODULE),
|
||||
new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("编辑器 iframe 加载超时")), 20_000);
|
||||
elements.frame.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
const messenger = new WindowMessenger({
|
||||
remoteWindow: elements.frame.contentWindow,
|
||||
allowedOrigins: [EDITOR_ORIGIN],
|
||||
});
|
||||
state.connection = connect({
|
||||
messenger,
|
||||
methods: {
|
||||
close() {
|
||||
toast("当前文稿由 NeoDeck Local 托管");
|
||||
},
|
||||
reenter() {
|
||||
return state.lastDeckPayload ? setDeck(state.lastDeckPayload, elements.documentPath.textContent) : undefined;
|
||||
},
|
||||
toggleFullScreen: toggleFullscreen,
|
||||
showFeedback() {},
|
||||
sendPrompt() {
|
||||
toast("AI 提示词回传尚未接入", "warning");
|
||||
},
|
||||
showMessage(payload) {
|
||||
const message = typeof payload === "string" ? payload : payload?.message || payload?.content;
|
||||
if (message) toast(message);
|
||||
},
|
||||
hideMessage() {},
|
||||
onSave,
|
||||
getImages,
|
||||
setAnnotationMode() {},
|
||||
setAnnotationCurrentPage() {},
|
||||
upsertAnnotation() {},
|
||||
removeAnnotation() {},
|
||||
clearAnnotations() {},
|
||||
},
|
||||
});
|
||||
state.remote = await Promise.race([
|
||||
state.connection.promise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("RPC 握手超时")), 20_000)),
|
||||
]);
|
||||
await state.remote.setSlideConfig({ editable: true, locale: "zh-CN", theme: "light" });
|
||||
setConnection("ready", "已连接");
|
||||
addActivity("编辑器 RPC 握手完成", "success");
|
||||
await openDemo();
|
||||
} catch (error) {
|
||||
setConnection("error", "连接失败");
|
||||
setLoading(true, "无法连接编辑器", "请检查网络,或 Kimi 是否更新了公开前端资源。");
|
||||
handleError(error, "编辑器连接失败");
|
||||
}
|
||||
}
|
||||
|
||||
function setActivityPanel(open) {
|
||||
elements.activityPanel.classList.toggle("is-open", open);
|
||||
elements.activityPanel.setAttribute("aria-hidden", String(!open));
|
||||
}
|
||||
|
||||
function setOpenDialog(open) {
|
||||
if (open && !elements.openDialog.open) elements.openDialog.showModal();
|
||||
if (!open && elements.openDialog.open) elements.openDialog.close();
|
||||
elements.openDialog.dataset.dropState = "idle";
|
||||
elements.uploadDropzone.classList.remove("is-dragging");
|
||||
}
|
||||
|
||||
elements.openFolder.addEventListener("click", () => setOpenDialog(true));
|
||||
elements.closeOpenDialog.addEventListener("click", () => setOpenDialog(false));
|
||||
elements.chooseWritableFolder.addEventListener("click", pickDirectory);
|
||||
elements.uploadFolder.addEventListener("click", () => elements.folderFallback.click());
|
||||
elements.uploadDropzone.addEventListener("click", () => elements.folderFallback.click());
|
||||
elements.openDialog.addEventListener("click", (event) => {
|
||||
if (event.target === elements.openDialog) setOpenDialog(false);
|
||||
});
|
||||
elements.openDemo.addEventListener("click", () => openDemo().catch((error) => handleError(error, "示例载入失败")));
|
||||
elements.reload.addEventListener("click", () => {
|
||||
if (!state.lastDeckPayload) return;
|
||||
const reload = state.source === "directory" ? loadManifest(state.manifestPath, elements.documentPath.textContent) : setDeck(state.lastDeckPayload, elements.documentPath.textContent);
|
||||
reload.catch((error) => handleError(error, "重新载入失败"));
|
||||
});
|
||||
elements.toggleActivity.addEventListener("click", () => setActivityPanel(!elements.activityPanel.classList.contains("is-open")));
|
||||
elements.closeActivity.addEventListener("click", () => setActivityPanel(false));
|
||||
elements.folderFallback.addEventListener("change", () => {
|
||||
if (elements.folderFallback.files?.length) {
|
||||
setOpenDialog(false);
|
||||
openFallbackFiles(elements.folderFallback.files).catch((error) => handleError(error, "上传文件夹失败"));
|
||||
}
|
||||
elements.folderFallback.value = "";
|
||||
});
|
||||
|
||||
window.addEventListener("dragenter", (event) => {
|
||||
event.preventDefault();
|
||||
state.dragDepth += 1;
|
||||
setOpenDialog(true);
|
||||
elements.openDialog.dataset.dropState = "active";
|
||||
elements.uploadDropzone.classList.add("is-dragging");
|
||||
});
|
||||
window.addEventListener("dragover", (event) => event.preventDefault());
|
||||
window.addEventListener("dragleave", () => {
|
||||
state.dragDepth = Math.max(0, state.dragDepth - 1);
|
||||
if (state.dragDepth === 0) {
|
||||
elements.openDialog.dataset.dropState = "idle";
|
||||
elements.uploadDropzone.classList.remove("is-dragging");
|
||||
}
|
||||
});
|
||||
window.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
state.dragDepth = 0;
|
||||
elements.openDialog.dataset.dropState = "loading";
|
||||
elements.uploadDropzone.classList.remove("is-dragging");
|
||||
try {
|
||||
const handles = await Promise.all([...event.dataTransfer.items].map((item) => item.getAsFileSystemHandle?.()));
|
||||
if (handles.some(Boolean)) await openDroppedFolder(handles);
|
||||
else if (event.dataTransfer.files?.length) {
|
||||
const files = [...event.dataTransfer.files];
|
||||
const roots = new Set(files.map((file) => file.webkitRelativePath?.split("/")[0]).filter(Boolean));
|
||||
const isSingleFolder = files.length > 0 && roots.size === 1 && files.every((file) => file.webkitRelativePath?.includes("/"));
|
||||
if (!isSingleFolder) throw new Error("只能拖入一个完整的 PPTD 项目文件夹,不能单独拖入 .pptd 文件");
|
||||
setOpenDialog(false);
|
||||
await openFallbackFiles(files);
|
||||
} else throw new Error("请拖入包含 .pptd、pages 和 media 的完整项目文件夹");
|
||||
} catch (error) {
|
||||
handleError(error, "拖放打开失败");
|
||||
} finally {
|
||||
elements.openDialog.dataset.dropState = "idle";
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (elements.saveState.dataset.kind === "saving") {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}
|
||||
});
|
||||
|
||||
window.neoDeck = {
|
||||
openDemo,
|
||||
getSlideStatus: () => state.remote?.getSlideStatus(),
|
||||
get status() {
|
||||
return {
|
||||
connected: Boolean(state.remote),
|
||||
source: state.source,
|
||||
title: state.deckTitle,
|
||||
manifestPath: state.manifestPath,
|
||||
indexedFiles: state.fileIndex.size,
|
||||
saveState: elements.saveState.dataset.kind,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
connectEditor();
|
||||
@@ -0,0 +1,150 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta
|
||||
name="description"
|
||||
content="一个可打开、编辑并保存本地 PPTD 项目的 neo-ppt 第三方宿主。"
|
||||
/>
|
||||
<title>NeoDeck Local</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand" aria-label="NeoDeck Local">
|
||||
<span class="brand-mark">N</span>
|
||||
<span class="brand-copy">
|
||||
<strong>NeoDeck</strong>
|
||||
<small>LOCAL</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" aria-label="文件工具栏">
|
||||
<button class="button button-primary" id="open-folder" type="button">
|
||||
<span class="button-icon" aria-hidden="true">+</span>
|
||||
打开 PPTD 文件夹
|
||||
</button>
|
||||
<button class="button" id="open-demo" type="button">示例</button>
|
||||
<button class="icon-button" id="reload-deck" type="button" title="重新载入当前文稿" disabled>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="document-meta" aria-live="polite">
|
||||
<div class="document-title" id="document-title">等待编辑器连接</div>
|
||||
<div class="document-path" id="document-path">当前使用演示文稿</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<span class="status-pill status-connecting" id="connection-status">
|
||||
<span class="status-dot"></span>
|
||||
<span id="connection-label">连接中</span>
|
||||
</span>
|
||||
<span class="save-state" id="save-state">未保存到本地</span>
|
||||
<button class="icon-button" id="toggle-activity" type="button" title="打开活动记录">☰</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="editor-stage">
|
||||
<iframe
|
||||
id="editor"
|
||||
title="neo-ppt 编辑器"
|
||||
allow="fullscreen; clipboard-read; clipboard-write"
|
||||
></iframe>
|
||||
|
||||
<section class="loading-card" id="loading-card" aria-live="polite">
|
||||
<span class="loading-orbit" aria-hidden="true"></span>
|
||||
<h1 id="loading-title">正在连接 neo-ppt</h1>
|
||||
<p id="loading-message">编辑器就绪后会自动载入示例文稿。</p>
|
||||
</section>
|
||||
|
||||
<aside class="activity-panel" id="activity-panel" aria-hidden="true">
|
||||
<div class="activity-header">
|
||||
<div>
|
||||
<strong>活动记录</strong>
|
||||
<small>连接、加载与保存状态</small>
|
||||
</div>
|
||||
<button class="icon-button" id="close-activity" type="button" aria-label="关闭活动记录">×</button>
|
||||
</div>
|
||||
<ol class="activity-list" id="activity-list"></ol>
|
||||
<div class="activity-footer">
|
||||
文件只会在你授权的项目文件夹内读写;远程编辑器不会获得任意磁盘访问权限。
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog class="open-dialog" id="open-dialog" aria-labelledby="open-dialog-title">
|
||||
<div class="open-dialog-shell">
|
||||
<div class="open-dialog-header">
|
||||
<div class="dialog-heading">
|
||||
<span class="dialog-mark">P</span>
|
||||
<div>
|
||||
<h2 id="open-dialog-title">打开 PPTD 项目文件夹</h2>
|
||||
<p>请选择或拖入包含完整项目结构的文件夹</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="icon-button" id="close-open-dialog" type="button" aria-label="关闭打开文稿弹窗">×</button>
|
||||
</div>
|
||||
|
||||
<button class="upload-dropzone" id="upload-dropzone" type="button">
|
||||
<span class="upload-illustration" aria-hidden="true">
|
||||
<span class="upload-file">P</span>
|
||||
<span class="upload-arrow">↑</span>
|
||||
</span>
|
||||
<strong>拖动 PPTD 项目文件夹到这里</strong>
|
||||
<span>仅接受一个完整文件夹,不支持单独的 .pptd 文件</span>
|
||||
<em>点击上传完整文件夹(只读)</em>
|
||||
</button>
|
||||
|
||||
<div class="open-choice-row">
|
||||
<button class="open-choice open-choice-primary" id="choose-writable-folder" type="button">
|
||||
<span class="choice-icon" aria-hidden="true">▣</span>
|
||||
<span>
|
||||
<strong>选择项目文件夹</strong>
|
||||
<small>授权后支持自动保存</small>
|
||||
</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
<button class="open-choice" id="upload-folder" type="button">
|
||||
<span class="choice-icon choice-icon-muted" aria-hidden="true">↑</span>
|
||||
<span>
|
||||
<strong>上传项目文件夹</strong>
|
||||
<small>兼容模式,只读打开</small>
|
||||
</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="open-dialog-note">
|
||||
<span aria-hidden="true">ⓘ</span>
|
||||
<span>PPTD 项目由 .pptd 清单、pages 页面目录和 media 素材目录组成,必须上传整个文件夹。若提示含系统文件,请移除 .DS_Store。</span>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog class="deck-dialog" id="deck-dialog">
|
||||
<form method="dialog">
|
||||
<div class="dialog-heading">
|
||||
<span class="dialog-mark">P</span>
|
||||
<div>
|
||||
<h2>选择 PPTD 文稿</h2>
|
||||
<p>这个文件夹里发现了多个 .pptd 清单,请选择一个。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="deck-options" id="deck-options"></div>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" value="cancel">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<input id="folder-fallback" type="file" webkitdirectory multiple hidden />
|
||||
<div class="toast-region" id="toast-region" aria-live="polite"></div>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
export function normalizeRelativePath(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError("文件路径必须是字符串");
|
||||
}
|
||||
|
||||
let path = value.trim().replaceAll("\\", "/");
|
||||
try {
|
||||
path = decodeURIComponent(path);
|
||||
} catch {
|
||||
// Keep the original path when it contains a literal percent sign.
|
||||
}
|
||||
|
||||
path = path.replace(/^file:\/\/+/, "").replace(/^\.\//, "");
|
||||
if (!path || path.includes("\0") || path.startsWith("/") || /^[A-Za-z]:\//.test(path)) {
|
||||
throw new Error(`不允许的绝对路径:${value}`);
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
for (const part of path.split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") throw new Error(`不允许越过项目目录:${value}`);
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
if (parts.length === 0) throw new Error(`无效文件路径:${value}`);
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
export function dirname(path) {
|
||||
const normalized = normalizeRelativePath(path);
|
||||
const index = normalized.lastIndexOf("/");
|
||||
return index === -1 ? "" : normalized.slice(0, index);
|
||||
}
|
||||
|
||||
export function basename(path) {
|
||||
const normalized = normalizeRelativePath(path);
|
||||
return normalized.slice(normalized.lastIndexOf("/") + 1);
|
||||
}
|
||||
|
||||
export function joinDeckPath(base, path) {
|
||||
const child = normalizeRelativePath(path);
|
||||
if (!base) return child;
|
||||
const normalizedBase = normalizeRelativePath(base);
|
||||
if (child === normalizedBase || child.startsWith(`${normalizedBase}/`)) return child;
|
||||
return normalizeRelativePath(`${normalizedBase}/${child}`);
|
||||
}
|
||||
|
||||
function unquoteYamlScalar(value) {
|
||||
const text = value.trim();
|
||||
if (text.startsWith('"')) {
|
||||
const match = text.match(/^"(?:\\.|[^"\\])*"/);
|
||||
if (match) {
|
||||
try {
|
||||
return JSON.parse(match[0]);
|
||||
} catch {
|
||||
return match[0].slice(1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.startsWith("'")) {
|
||||
const end = text.indexOf("'", 1);
|
||||
if (end !== -1) return text.slice(1, end).replaceAll("''", "'");
|
||||
}
|
||||
return text.replace(/\s+#.*$/, "").trim();
|
||||
}
|
||||
|
||||
export function extractPagePaths(manifestText) {
|
||||
const trimmed = manifestText.trim();
|
||||
if (!trimmed) throw new Error("PPTD manifest 是空文件");
|
||||
|
||||
if (trimmed.startsWith("{")) {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!Array.isArray(parsed.pages)) throw new Error("PPTD manifest 缺少 pages 数组");
|
||||
return parsed.pages.map((path) => normalizeRelativePath(String(path)));
|
||||
}
|
||||
|
||||
const lines = manifestText.replaceAll("\r\n", "\n").split("\n");
|
||||
const pageHeader = lines.findIndex((line) => /^(\s*)pages\s*:\s*(?:#.*)?$/.test(line));
|
||||
if (pageHeader === -1) throw new Error("PPTD manifest 缺少 pages 列表");
|
||||
|
||||
const headerIndent = lines[pageHeader].match(/^\s*/)[0].length;
|
||||
const pages = [];
|
||||
for (let index = pageHeader + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
||||
const indent = line.match(/^\s*/)[0].length;
|
||||
if (indent <= headerIndent && !/^\s*-/.test(line)) break;
|
||||
const match = line.match(/^\s*-\s*(.+?)\s*$/);
|
||||
if (!match) break;
|
||||
const path = unquoteYamlScalar(match[1]);
|
||||
if (path) pages.push(normalizeRelativePath(path));
|
||||
}
|
||||
|
||||
if (pages.length === 0) throw new Error("PPTD manifest 的 pages 列表为空");
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function titleFromManifest(manifestText, fallback) {
|
||||
const trimmed = manifestText.trim();
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
return JSON.parse(trimmed).title || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
const match = manifestText.match(/^title\s*:\s*(.+?)\s*$/m);
|
||||
return match ? unquoteYamlScalar(match[1]) : fallback;
|
||||
}
|
||||
|
||||
export function assertWritableChangePath(base, requestedPath) {
|
||||
const path = joinDeckPath(base, requestedPath);
|
||||
if (!/\.(?:pptd|page)$/i.test(path)) {
|
||||
throw new Error(`为安全起见,仅允许编辑 .pptd/.page:${requestedPath}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", sans-serif;
|
||||
color: #1d2433;
|
||||
background: #eef1f6;
|
||||
font-synthesis: none;
|
||||
--purple: #6c5ce7;
|
||||
--purple-dark: #5746d8;
|
||||
--border: #e3e7ef;
|
||||
--muted: #7b8495;
|
||||
--shadow: 0 18px 60px rgba(34, 42, 61, 0.16);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(180px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 1px 0 rgba(25, 32, 48, 0.02);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 126px;
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.dialog-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(145deg, #8172f2, #5b49da 72%);
|
||||
box-shadow: 0 7px 18px rgba(108, 92, 231, 0.27);
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-copy small {
|
||||
color: #9aa2b1;
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button,
|
||||
.icon-button {
|
||||
border: 1px solid var(--border);
|
||||
color: #3a4251;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: 150ms ease;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.icon-button:hover {
|
||||
border-color: #cfd5e1;
|
||||
background: #f7f8fb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button:focus-visible,
|
||||
.icon-button:focus-visible,
|
||||
.deck-option:focus-visible {
|
||||
outline: 3px solid rgba(108, 92, 231, 0.24);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.button:disabled,
|
||||
.icon-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 34px;
|
||||
padding: 0 13px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
border-color: var(--purple);
|
||||
color: #fff;
|
||||
background: var(--purple);
|
||||
box-shadow: 0 5px 13px rgba(108, 92, 231, 0.18);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
border-color: var(--purple-dark);
|
||||
background: var(--purple-dark);
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
margin-right: 5px;
|
||||
font-size: 16px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: 9px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-title,
|
||||
.document-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-title {
|
||||
color: #2f3746;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.document-path {
|
||||
margin-top: 2px;
|
||||
color: #9aa2b1;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e7eaf0;
|
||||
border-radius: 999px;
|
||||
color: #697386;
|
||||
background: #f8f9fb;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #f5a623;
|
||||
box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.12);
|
||||
}
|
||||
|
||||
.status-ready .status-dot {
|
||||
background: #20b486;
|
||||
box-shadow: 0 0 0 3px rgba(32, 180, 134, 0.12);
|
||||
}
|
||||
|
||||
.status-error .status-dot {
|
||||
background: #ef5b5b;
|
||||
box-shadow: 0 0 0 3px rgba(239, 91, 91, 0.12);
|
||||
}
|
||||
|
||||
.save-state {
|
||||
min-width: 76px;
|
||||
color: #8992a2;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.save-state[data-kind="saving"] {
|
||||
color: #8b72e8;
|
||||
}
|
||||
|
||||
.save-state[data-kind="saved"] {
|
||||
color: #15956f;
|
||||
}
|
||||
|
||||
.save-state[data-kind="error"] {
|
||||
color: #d94848;
|
||||
}
|
||||
|
||||
.editor-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #f0f2f6;
|
||||
}
|
||||
|
||||
#editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
position: absolute;
|
||||
z-index: 18;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(108, 92, 231, 0.08), transparent 34%),
|
||||
#f7f8fb;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.loading-card.is-hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loading-card h1 {
|
||||
margin: 19px 0 7px;
|
||||
color: #252c3a;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.loading-card p {
|
||||
margin: 0;
|
||||
color: #8a93a3;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-orbit {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 3px solid rgba(108, 92, 231, 0.14);
|
||||
border-top-color: var(--purple);
|
||||
border-radius: 50%;
|
||||
animation: spin 800ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.activity-panel {
|
||||
position: absolute;
|
||||
z-index: 35;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
width: min(350px, calc(100% - 24px));
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(220, 224, 232, 0.9);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: var(--shadow);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(calc(100% + 24px));
|
||||
transition: 200ms ease;
|
||||
}
|
||||
|
||||
.activity-panel.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.activity-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.activity-header div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.activity-header strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.activity-header small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
margin: 0;
|
||||
padding: 9px 16px;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.activity-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 62px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #535d6d;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.activity-list time {
|
||||
color: #a0a8b5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.activity-list li[data-tone="success"] span {
|
||||
color: #148264;
|
||||
}
|
||||
|
||||
.activity-list li[data-tone="warning"] span {
|
||||
color: #a16a0b;
|
||||
}
|
||||
|
||||
.activity-list li[data-tone="error"] span {
|
||||
color: #cc4545;
|
||||
}
|
||||
|
||||
.activity-footer {
|
||||
padding: 13px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: #8b94a3;
|
||||
background: #fafbfc;
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.open-dialog,
|
||||
.deck-dialog {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
color: #273041;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.open-dialog {
|
||||
width: min(620px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.open-dialog::backdrop,
|
||||
.deck-dialog::backdrop {
|
||||
background: rgba(24, 29, 42, 0.38);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.deck-dialog form {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.open-dialog-shell {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.open-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.upload-dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 22px 0 14px;
|
||||
padding: 28px 22px 24px;
|
||||
border: 1.5px dashed #c9c2f3;
|
||||
border-radius: 16px;
|
||||
color: #353d4c;
|
||||
background: linear-gradient(180deg, #faf9ff, #f7f7fb);
|
||||
cursor: pointer;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.upload-dropzone:hover,
|
||||
.upload-dropzone.is-dragging {
|
||||
border-color: var(--purple);
|
||||
background: #f3f0ff;
|
||||
box-shadow: inset 0 0 0 3px rgba(108, 92, 231, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.upload-dropzone strong {
|
||||
margin-top: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.upload-dropzone > span:not(.upload-illustration) {
|
||||
margin-top: 6px;
|
||||
color: #81899a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-dropzone em {
|
||||
margin-top: 12px;
|
||||
color: var(--purple);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.upload-illustration {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 68px;
|
||||
height: 55px;
|
||||
}
|
||||
|
||||
.upload-file {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 5px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #8172f2, #5b49da);
|
||||
box-shadow: 0 10px 25px rgba(108, 92, 231, 0.26);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.upload-arrow {
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
bottom: -2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 4px solid #faf9ff;
|
||||
border-radius: 50%;
|
||||
color: var(--purple);
|
||||
background: #fff;
|
||||
box-shadow: 0 5px 16px rgba(54, 62, 82, 0.14);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.open-choice-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.open-choice {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 13px;
|
||||
color: #303849;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: 150ms ease;
|
||||
}
|
||||
|
||||
.open-choice:hover {
|
||||
border-color: #cfc8f7;
|
||||
background: #faf9ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.open-choice > span:nth-child(2) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.open-choice strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.open-choice small {
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: #8b94a3;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.choice-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: var(--purple);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.choice-icon-muted {
|
||||
color: #6659c8;
|
||||
background: #eeeafd;
|
||||
}
|
||||
|
||||
.open-dialog-note {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 10px;
|
||||
color: #7b8495;
|
||||
background: #f6f7f9;
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.dialog-heading h2,
|
||||
.dialog-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-heading h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.dialog-heading p {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.deck-options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 340px;
|
||||
margin: 20px 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.deck-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-option:hover {
|
||||
border-color: #cfc8f7;
|
||||
background: #faf9ff;
|
||||
}
|
||||
|
||||
.deck-option > span:nth-child(2) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deck-option strong,
|
||||
.deck-option small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deck-option strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.deck-option small {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.deck-file-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: #f06a45;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toast-region {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
max-width: min(420px, calc(100vw - 36px));
|
||||
padding: 11px 14px;
|
||||
border: 1px solid #e3e7ef;
|
||||
border-radius: 11px;
|
||||
color: #394253;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 12px 34px rgba(35, 43, 60, 0.18);
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: 180ms ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toast.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-color: #f1c7c7;
|
||||
color: #b43e3e;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
border-color: #efd8ad;
|
||||
color: #966512;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.topbar {
|
||||
grid-template-columns: auto auto 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.save-state {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.app-shell {
|
||||
grid-template-rows: 52px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.brand-copy,
|
||||
.toolbar .button:not(.button-primary),
|
||||
.toolbar .icon-button,
|
||||
.status-pill {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
max-width: 185px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.open-choice-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user