dsh-plugin-deck-studio 0.2.14 → 0.2.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +331 -5
- package/index.js +380 -12
- package/lib/capabilities.js +3 -2
- package/lib/deck-templates.js +6 -1
- package/lib/pdf-import.js +270 -0
- package/lib/present-native.js +17 -0
- package/lib/review-agent.js +2 -1
- package/package.json +1 -1
- package/ui/workbench.css +17 -1
- package/ui/workbench.html +464 -25
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF 导入(需求 req_28a1eec6eab8,2026-11-06):把目录里的一份 PDF 变成 Deckline 的
|
|
3
|
+
* 「每页一张整页图」deck,顺带抽取同目录里的 md。
|
|
4
|
+
*
|
|
5
|
+
* 设计边界(先看清再改):
|
|
6
|
+
* - **不做 OCR、不做视觉推理**:意图字段只从 md 里「已经写好的」字段抽;抽不到就留占位,
|
|
7
|
+
* 并在 intent.md 末尾写「导入说明(推断 · 待确认)」。看图推意图是下一阶段的事。
|
|
8
|
+
* - **导入的整页图是参考 baseline**,不是最终交付图:PDF 渲染出来的字/版式与源 pptx 不一致时
|
|
9
|
+
* 不能当过审的稿子用(真正的原因见 docs/architecture.md「导入」一节)。
|
|
10
|
+
* - 纯函数(分类 / 抽字段 / 标题启发 / 说明生成)都不碰磁盘,smoke 直接测;
|
|
11
|
+
* IO 只有两处:`listImportDir()` 与 `rasterizePdfToPng()`。
|
|
12
|
+
* - `rasterizePdfToPng()` 依赖 poppler 的 `pdftoppm`(本机 brew install poppler)。
|
|
13
|
+
* 插件本身不引入 npm 依赖,缺二进制时**明确报错**,不静默降级成空 deck。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs";
|
|
18
|
+
import { basename, extname, join } from "node:path";
|
|
19
|
+
import { parseSectionedPages, parseStoryPages } from "./source-adapter.js";
|
|
20
|
+
|
|
21
|
+
export const PDF_IMPORT_DEFAULT_DPI = 120;
|
|
22
|
+
export const PDF_IMPORT_MIN_DPI = 72;
|
|
23
|
+
export const PDF_IMPORT_MAX_DPI = 300;
|
|
24
|
+
/** 一次导入的页数上限:够覆盖常见分享会 deck,又不至于让一次请求跑十几分钟。 */
|
|
25
|
+
export const PDF_IMPORT_MAX_PAGES = 40;
|
|
26
|
+
|
|
27
|
+
/** dpi 规范化:非数字回默认,超范围夹到边界(越界不报错,导入不该因为参数小错失败)。 */
|
|
28
|
+
export function normalizeDpi(value) {
|
|
29
|
+
const parsed = Number.parseInt(value, 10);
|
|
30
|
+
if (!Number.isFinite(parsed)) return PDF_IMPORT_DEFAULT_DPI;
|
|
31
|
+
return Math.min(PDF_IMPORT_MAX_DPI, Math.max(PDF_IMPORT_MIN_DPI, parsed));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 目录里哪些文件是导入源:只要 PDF 与 md(其它文件不猜、不读)。 */
|
|
35
|
+
export function classifyImportNames(names = []) {
|
|
36
|
+
const pdfs = [];
|
|
37
|
+
const markdown = [];
|
|
38
|
+
for (const raw of Array.isArray(names) ? names : []) {
|
|
39
|
+
const name = String(raw ?? "").trim();
|
|
40
|
+
if (!name || name.startsWith(".")) continue;
|
|
41
|
+
if (/\.pdf$/i.test(name)) pdfs.push(name);
|
|
42
|
+
else if (/\.md$/i.test(name)) markdown.push(name);
|
|
43
|
+
}
|
|
44
|
+
return { pdfs: pdfs.sort(), markdown: markdown.sort() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 从 PDF 文件名推 deck 标题(末级兜底,用户填了就用用户的)。 */
|
|
48
|
+
export function titleFromPdfName(fileName = "") {
|
|
49
|
+
const base = basename(String(fileName ?? ""), extname(String(fileName ?? "")));
|
|
50
|
+
return base.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim() || "导入的 deck";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 意图六字段的别名表(中文 / 英文都认,大小写不敏感)。 */
|
|
54
|
+
const INTENT_FIELDS = [
|
|
55
|
+
{ key: "audience", labels: ["听众", "受众", "用户", "audience"] },
|
|
56
|
+
{ key: "topic", labels: ["主题", "话题", "topic"] },
|
|
57
|
+
{ key: "intent", labels: ["意图", "带走", "intent"] },
|
|
58
|
+
{ key: "occasion", labels: ["场合", "时长", "occasion"] },
|
|
59
|
+
{ key: "success", labels: ["成功标准", "success"] },
|
|
60
|
+
{ key: "scopeOut", labels: ["不讲的", "不讲", "边界", "scopeout", "scope-out"] },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** 占位值不算抽取成功(「(待补:…)」正是模板里的空位)。 */
|
|
64
|
+
function isEmptyPlaceholder(value) {
|
|
65
|
+
const text = String(value ?? "").trim();
|
|
66
|
+
if (!text) return true;
|
|
67
|
+
return /^[((]\s*待补/.test(text) || /^[((]\s*未填/.test(text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* frontmatter 的键值对。这里**不复用** source-adapter 的 `parseFrontmatter`:
|
|
72
|
+
* 它只认 ASCII 键名(`^[A-Za-z0-9_-]+`),
|
|
73
|
+
* 而导入目录里的 md 常写 `听众:` / `主题:` 这种中文键——那些正是我们要抽的字段。
|
|
74
|
+
*/
|
|
75
|
+
function readFrontmatterPairs(text = "") {
|
|
76
|
+
const body = String(text ?? "");
|
|
77
|
+
if (!body.startsWith("---")) return [];
|
|
78
|
+
const end = body.indexOf("\n---", 3);
|
|
79
|
+
if (end < 0) return [];
|
|
80
|
+
const pairs = [];
|
|
81
|
+
for (const line of body.slice(4, end).split("\n")) {
|
|
82
|
+
if (!line.trim() || /^\s*[-#]/.test(line)) continue;
|
|
83
|
+
const match = /^\s*([^:\s][^:]{0,31}?)\s*[::]\s*(.*)$/.exec(line);
|
|
84
|
+
if (!match) continue;
|
|
85
|
+
pairs.push([match[1].trim(), match[2].trim()]);
|
|
86
|
+
}
|
|
87
|
+
return pairs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cleanFieldValue(value) {
|
|
91
|
+
return String(value ?? "").replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 从一段 md 里抽意图字段:
|
|
96
|
+
* 1. frontmatter(`audience:` / `听众:` 等);
|
|
97
|
+
* 2. `**听众**:…` 这种结构化字段行(Deckline 自己的 intent.md 就是这个写法)。
|
|
98
|
+
* 只抽「已经写好的」,不推断;返回 `sources` 方便在说明里交代每个字段从哪来。
|
|
99
|
+
*/
|
|
100
|
+
export function extractIntentFields(text = "") {
|
|
101
|
+
const fields = {};
|
|
102
|
+
const sources = {};
|
|
103
|
+
const body = String(text ?? "");
|
|
104
|
+
if (!body.trim()) return { fields, sources };
|
|
105
|
+
|
|
106
|
+
const metaEntries = readFrontmatterPairs(body);
|
|
107
|
+
for (const { key, labels } of INTENT_FIELDS) {
|
|
108
|
+
for (const label of labels) {
|
|
109
|
+
const hit = metaEntries.find(([name]) => String(name).trim().toLowerCase() === label.toLowerCase());
|
|
110
|
+
if (!hit) continue;
|
|
111
|
+
const value = cleanFieldValue(hit[1]);
|
|
112
|
+
if (isEmptyPlaceholder(value)) continue;
|
|
113
|
+
fields[key] = value;
|
|
114
|
+
sources[key] = `frontmatter · ${hit[0]}`;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const line of body.split("\n")) {
|
|
120
|
+
const match = /^\s*[-*]?\s*\*\*([^*]{1,16})\*\*\s*[::]\s*(.+)$/.exec(line);
|
|
121
|
+
if (!match) continue;
|
|
122
|
+
const label = match[1].trim();
|
|
123
|
+
const value = cleanFieldValue(match[2]);
|
|
124
|
+
if (isEmptyPlaceholder(value)) continue;
|
|
125
|
+
for (const { key, labels } of INTENT_FIELDS) {
|
|
126
|
+
if (fields[key]) continue;
|
|
127
|
+
if (labels.some((item) => label.toLowerCase() === item.toLowerCase())) {
|
|
128
|
+
fields[key] = value;
|
|
129
|
+
sources[key] = `「${label}」字段`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { fields, sources };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 标题启发:md 里 `#` / `##` 标题的条数**正好等于页数**(或多一条文档大标题)时,
|
|
138
|
+
* 才把它们当逐页标题——数量对不上就宁可不用,也不硬塞。
|
|
139
|
+
*/
|
|
140
|
+
export function headingsAsTitles(text = "", pageCount = 0) {
|
|
141
|
+
const count = Number(pageCount) || 0;
|
|
142
|
+
if (!count) return [];
|
|
143
|
+
const heads = String(text ?? "")
|
|
144
|
+
.split("\n")
|
|
145
|
+
.map((line) => /^(#{1,2})\s+(.+?)\s*$/.exec(line.trim()))
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.map((match) => match[2].replace(/\*\*/g, "").replace(/^\d+[.、)]\s*/, "").trim())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
if (heads.length === count) return heads;
|
|
150
|
+
if (heads.length === count + 1) return heads.slice(1);
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 一份 md 能不能当这台 deck 的 script:逐页解析出来的页数必须和 PDF 页数一致。 */
|
|
155
|
+
export function scriptPagesFromMarkdown(text = "") {
|
|
156
|
+
const body = String(text ?? "");
|
|
157
|
+
if (!body.trim()) return [];
|
|
158
|
+
const table = parseStoryPages(body);
|
|
159
|
+
const rows = table.length ? table : parseSectionedPages(body);
|
|
160
|
+
return rows.filter((row) => row && Number(row.page) > 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 把 `createDeck()` 写出来的占位 `**标题**:待定` 换成导入时推出来的页标题。
|
|
165
|
+
* 不改分节头(`### P01 · …` 在建档时已经是目标标题),只补「标题」字段——
|
|
166
|
+
* 否则 deck 列表 / 工作台每一页都显示「待定」,等于没导入。
|
|
167
|
+
*/
|
|
168
|
+
export function applyImportedTitles(scriptText = "", titles = []) {
|
|
169
|
+
let index = 0;
|
|
170
|
+
return String(scriptText ?? "").replace(/^\*\*标题\*\*\s*[::].*$/gm, (line) => {
|
|
171
|
+
const title = String(titles[index] ?? "").trim();
|
|
172
|
+
index += 1;
|
|
173
|
+
return title ? `**标题**:${title}` : line;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 导入说明(写在 intent.md 末尾):把「哪些是抽到的、哪些是占位、哪些没做」一次交代清楚,
|
|
179
|
+
* 避免用户或 Agent 把导入的占位当成已确认的意图。
|
|
180
|
+
*/
|
|
181
|
+
export function buildImportNote({
|
|
182
|
+
pdfName = "",
|
|
183
|
+
pageCount = 0,
|
|
184
|
+
dpi = PDF_IMPORT_DEFAULT_DPI,
|
|
185
|
+
mdFiles = [],
|
|
186
|
+
fieldSources = {},
|
|
187
|
+
warnings = [],
|
|
188
|
+
} = {}) {
|
|
189
|
+
const lines = [
|
|
190
|
+
"## 导入说明(推断 · 待确认)",
|
|
191
|
+
"",
|
|
192
|
+
`- 来源:\`${pdfName || "(未知 PDF)"}\` —— ${Number(pageCount) || 0} 页 · ${Number(dpi) || PDF_IMPORT_DEFAULT_DPI} dpi,每页已落成整页图 \`pages/p01.png…\`。`,
|
|
193
|
+
`- 抽到的 md:${mdFiles.length ? mdFiles.map((name) => `\`${name}\``).join("、") : "(目录里没有 md)"}。`,
|
|
194
|
+
];
|
|
195
|
+
const sourced = Object.entries(fieldSources ?? {}).filter(([, from]) => from);
|
|
196
|
+
lines.push(
|
|
197
|
+
`- 上面字段的来源:${sourced.length ? sourced.map(([key, from]) => `${key} ← ${from}`).join(";") : "没有抽到现成字段,全部是占位"}。`,
|
|
198
|
+
);
|
|
199
|
+
lines.push("- 没做的:**逐页看图推断意图**(本轮不做视觉推理)。需要时让 Agent 读整页图后补这里。");
|
|
200
|
+
lines.push("- 提醒:导入的整页图是**参考 baseline**(`rev=source`),不是最终交付图;要重出图请走正常出图流程。");
|
|
201
|
+
for (const warning of Array.isArray(warnings) ? warnings : []) lines.push(`- ⚠️ ${warning}`);
|
|
202
|
+
lines.push("");
|
|
203
|
+
return lines.join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 列导入目录(只读一层,不递归):返回 PDF / md 的文件对象。 */
|
|
207
|
+
export function listImportDir(dir) {
|
|
208
|
+
const names = readdirSync(dir).filter((name) => !name.startsWith("."));
|
|
209
|
+
const { pdfs, markdown } = classifyImportNames(names);
|
|
210
|
+
const stat = (name) => {
|
|
211
|
+
const path = join(dir, name);
|
|
212
|
+
const info = statSync(path);
|
|
213
|
+
return { name, path, sizeBytes: info.size };
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
dir,
|
|
217
|
+
pdfs: pdfs.map(stat),
|
|
218
|
+
markdown: markdown.map(stat),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** poppler 的 pdftoppm 在不在(只用于提前给人话提示)。 */
|
|
223
|
+
export function hasPdftoppm() {
|
|
224
|
+
try {
|
|
225
|
+
execFileSync("pdftoppm", ["-v"], { stdio: "ignore", timeout: 10_000 });
|
|
226
|
+
return true;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return error?.code !== "ENOENT";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* PDF → 每页 PNG。
|
|
234
|
+
* 输出名统一成 `p01.png … pNN.png`(两位补零,超过 99 页按实际位数),
|
|
235
|
+
* 与 `source-adapter.sourceImages()` 认的 `P01.png` 约定一致(大小写都认)。
|
|
236
|
+
*/
|
|
237
|
+
export function rasterizePdfToPng({ pdfPath, outDir, dpi = PDF_IMPORT_DEFAULT_DPI } = {}) {
|
|
238
|
+
if (!pdfPath || !existsSync(pdfPath)) throw new Error(`PDF 不存在:${pdfPath ?? "(空)"}`);
|
|
239
|
+
const value = normalizeDpi(dpi);
|
|
240
|
+
mkdirSync(outDir, { recursive: true });
|
|
241
|
+
const rawPrefix = join(outDir, "raw");
|
|
242
|
+
try {
|
|
243
|
+
execFileSync("pdftoppm", ["-png", "-r", String(value), pdfPath, rawPrefix], {
|
|
244
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
245
|
+
timeout: 20 * 60 * 1000,
|
|
246
|
+
});
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error?.code === "ENOENT") {
|
|
249
|
+
const missing = new Error("PDF 导入需要 poppler 的 `pdftoppm`:macOS `brew install poppler`,Debian/Ubuntu `apt install poppler-utils`");
|
|
250
|
+
missing.code = "pdftoppm-missing";
|
|
251
|
+
throw missing;
|
|
252
|
+
}
|
|
253
|
+
const stderr = String(error?.stderr ?? "").trim();
|
|
254
|
+
throw new Error(`PDF 转图片失败:${stderr || error?.message || "pdftoppm 退出异常"}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const produced = readdirSync(outDir)
|
|
258
|
+
.filter((name) => /^raw-\d+\.png$/i.test(name))
|
|
259
|
+
.map((name) => ({ name, page: Number(/^raw-(\d+)\.png$/i.exec(name)[1]) }))
|
|
260
|
+
.sort((a, b) => a.page - b.page);
|
|
261
|
+
if (!produced.length) throw new Error("pdftoppm 没有产出任何页图(PDF 可能损坏、加密或没有页面)");
|
|
262
|
+
|
|
263
|
+
const files = produced.map((item, index) => {
|
|
264
|
+
const pageId = `p${String(index + 1).padStart(2, "0")}`;
|
|
265
|
+
const file = join(outDir, `${pageId}.png`);
|
|
266
|
+
renameSync(join(outDir, item.name), file);
|
|
267
|
+
return { pageId, file, bytes: statSync(file).size };
|
|
268
|
+
});
|
|
269
|
+
return { files, pageCount: files.length, dpi: value };
|
|
270
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declare native tool presentation, when the host still has that API.
|
|
3
|
+
*
|
|
4
|
+
* DSH `0.2.1-rc.2` exposes `ctx.tools.presentAs()` and a preset must declare its
|
|
5
|
+
* presentation there; DSH `0.2.2-rc.1` removed the method and mounts
|
|
6
|
+
* `@deepseek-ai/dsh-tool-executor-native` at the base layer instead. Calling it
|
|
7
|
+
* unconditionally on `0.2.2` throws `ctx.tools.presentAs is not a function`
|
|
8
|
+
* while the preset mounts, which surfaces as `agent-preset/invalid` and blocks
|
|
9
|
+
* every new deck session. Feature-detect instead of version-sniffing: native
|
|
10
|
+
* execution is the only shape this plugin supports, so declaring it is a no-op
|
|
11
|
+
* on hosts that already default to it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @param {{ presentAs?: (mode: "native") => void } | undefined} tools */
|
|
15
|
+
export function presentNative(tools) {
|
|
16
|
+
if (typeof tools?.presentAs === "function") tools.presentAs("native");
|
|
17
|
+
}
|
package/lib/review-agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Dedicated, preset-free, one-shot audience reviewer. No Cordis import is required. */
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { isAbsolute } from "node:path";
|
|
4
|
+
import { presentNative } from "./present-native.js";
|
|
4
5
|
|
|
5
6
|
export const REVIEW_AGENT_PROVIDER = "deck-studio-review";
|
|
6
7
|
|
|
@@ -122,7 +123,7 @@ export function registerReviewAgentProvider(ctx, dependencies = {}) {
|
|
|
122
123
|
signal: controller.signal,
|
|
123
124
|
setup(agentCtx, child) {
|
|
124
125
|
agentCtx.systemPrompt.suppressRuntimeContext();
|
|
125
|
-
agentCtx.tools
|
|
126
|
+
presentNative(agentCtx.tools);
|
|
126
127
|
agentCtx.tools.restrict({ allow: [] });
|
|
127
128
|
agentCtx.tools.guard(() => "Internal reviewers cannot execute tools");
|
|
128
129
|
agentCtx.systemPrompt.section({ name: "deck-studio:internal-review", order: 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-deck-studio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.25",
|
|
4
4
|
"description": "Deckline:DSH 的 image deck / PPT 工作台插件——原生 Chat + 整页图片 deck 工作区 + 设计风格库;用对话做 PPT、按页出图、版本可追踪、导出 PDF/PPTX。",
|
|
5
5
|
"icon": "assets/icon.svg",
|
|
6
6
|
"homepage": "https://github.com/yuna78/dsh-deck-studio#readme",
|
package/ui/workbench.css
CHANGED
|
@@ -371,6 +371,19 @@ body.resizing{user-select:none;cursor:col-resize}
|
|
|
371
371
|
.newdeck-body .nd-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
|
372
372
|
.newdeck-body .field > span{font-size:10.5px;color:#5c6464}
|
|
373
373
|
.newdeck-body input,.newdeck-body textarea,.newdeck-body select{width:100%;font:inherit;font-size:12px;padding:7px 9px;border:1px solid var(--line);border-radius:5px;background:#fffdf8;color:var(--ink);resize:vertical}
|
|
374
|
+
/* 0.2.18 / 0.2.19:表单 / 探索两种开工方式。
|
|
375
|
+
必须写成 .newdeck-body .nd-mode-opt 这种权重,并且放在上面那条基础 input 规则之后——
|
|
376
|
+
否则 radio 会被 `.newdeck-body input{width:100%;padding:7px 9px}` 接管:撑满整行、把文字挤成一字一行
|
|
377
|
+
(0.2.18 的排版事故就是同权重 + 顺序靠前导致的)。 */
|
|
378
|
+
.newdeck-body .nd-mode{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:0 0 10px}
|
|
379
|
+
.newdeck-body .nd-mode-opt{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:#fffdf8;cursor:pointer;font-size:12px;color:var(--ink);min-width:0}
|
|
380
|
+
.newdeck-body .nd-mode-opt input[type="radio"]{width:auto;height:auto;flex:0 0 auto;margin:2px 0 0;padding:0;border:0;background:none;accent-color:#0c4651}
|
|
381
|
+
.newdeck-body .nd-mode-opt > span{flex:1 1 auto;display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
382
|
+
.newdeck-body .nd-mode-opt b{font-weight:600;font-size:12px;line-height:1.35}
|
|
383
|
+
.newdeck-body .nd-mode-opt em{font-style:normal;font-size:11px;line-height:1.45;color:#728086;overflow-wrap:anywhere}
|
|
384
|
+
.newdeck-body .nd-mode-opt:has(input:checked){border-color:#0c4651;box-shadow:inset 0 0 0 1px #0c4651}
|
|
385
|
+
.newdeck-body .nd-mode-opt:has(input:checked) em{color:#48605f}
|
|
386
|
+
.newdeck-body .nd-mode-note{margin:0 0 10px}
|
|
374
387
|
/* 新建 Deck · 风格选择:样张预览卡片(F-45) */
|
|
375
388
|
.nd-styles{display:grid;grid-template-columns:repeat(auto-fill,minmax(158px,1fr));gap:8px;margin-bottom:8px}
|
|
376
389
|
.nd-style{display:flex;flex-direction:column;padding:0;border:1px solid var(--line);border-radius:8px;background:#fffdf8;cursor:pointer;font:inherit;text-align:left;overflow:hidden;transition:border-color .12s,box-shadow .12s}
|
|
@@ -677,7 +690,10 @@ body.has-chat .layout{grid-template-columns:var(--nav-w,430px) 5px minmax(0,1fr)
|
|
|
677
690
|
body.presenting{overflow:hidden}
|
|
678
691
|
.present-stage{width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:22px 22px 82px;cursor:pointer}
|
|
679
692
|
.present-slide{margin:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center}
|
|
680
|
-
.
|
|
693
|
+
/* 0.2.24:放映时整页图必须随屏幕放大——以前只写 max-width/max-height,图小于画布时永远保持
|
|
694
|
+
自然尺寸(1280×720 的页图在 2K/4K 屏上就缩在中间一小块)。改成 width/height:100% + object-fit:contain,
|
|
695
|
+
大图缩小、小图放大都按比例铺满;阴影改用 drop-shadow,跟着图片轮廓而不是整块元素。 */
|
|
696
|
+
.present-slide img{width:100%;height:100%;max-width:none;max-height:none;object-fit:contain;border-radius:0;background:transparent;filter:drop-shadow(0 22px 62px #000b);user-select:none;-webkit-user-drag:none}
|
|
681
697
|
.present-empty{width:min(900px,82vw);aspect-ratio:16/9;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;border:1px dashed #ffffff2e;border-radius:8px;color:#c9c2b4;font-size:13px;text-align:center;padding:24px}
|
|
682
698
|
.present-empty b{font-size:20px;color:#f3ede1}
|
|
683
699
|
.present-bar{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;padding:6px 10px 6px 6px;border-radius:999px;background:#0c4651d9;border:1px solid #ffffff24;box-shadow:0 12px 34px #0009;font-size:12px;color:#e9e4d8;max-width:calc(100% - 28px);backdrop-filter:blur(8px)}
|