Skip to content

Commit 93002c6

Browse files
committed
feat: Frontmatter 面板可编辑化
- 将只读 frontmatter 表格改为 contenteditable 可编辑表格 - 点击单元格直接输入,Enter 提交、Escape 还原、Tab 跳转 - 支持新增字段(底部 + 按钮)和删除字段(行 hover 显示 × 按钮) - 编辑后实时序列化为 YAML 并通过 frontmatterUpdate 消息同步到 Extension 侧写盘 - 新增 frontmatterUpdate 消息类型(shared/messages.ts) - Extension 侧处理 frontmatterUpdate:更新 _frontmatterMap 并触发文档保存 - 占位符提示:空单元格显示 key/value 半透明文字 - 新增 Add field 翻译(i18n)
1 parent adaa29c commit 93002c6

6 files changed

Lines changed: 293 additions & 19 deletions

File tree

shared/messages.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export type ToExtensionMessage =
3535
| { type: "getProjectImages"; id: string }
3636
| { type: "renameImage"; id: string; webviewUri: string; newBasename: string }
3737
| { type: "getPathSuggestions"; id: string; query: string }
38-
| { type: "resolveImagePath"; id: string; relPath: string };
38+
| { type: "resolveImagePath"; id: string; relPath: string }
39+
| { type: "frontmatterUpdate"; frontmatter: string };
3940

4041
/**
4142
* Extension → WebView 方向的消息。

src/MarkdownEditorProvider.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,25 @@ export class MarkdownEditorProvider
288288
this._scheduleAutoSaveOrMarkDirty(document);
289289
}
290290
break;
291+
case "frontmatterUpdate": {
292+
// WebView 侧编辑了 frontmatter 面板,同步到 Extension 并触发保存
293+
const oldFm = this._frontmatterMap.get(uriKey) ?? "";
294+
const newFm = message.frontmatter;
295+
if (oldFm === newFm) { break; }
296+
this._frontmatterMap.set(uriKey, newFm);
297+
// 从当前文档内容中提取 body(去掉旧 frontmatter),拼接新 frontmatter
298+
const currentText = document.getText();
299+
const { body } = extractFrontmatter(currentText);
300+
const fullContent = newFm + body;
301+
if (fullContent === currentText) { break; }
302+
document.update(fullContent);
303+
if (!this._pinnedDocuments.has(uriKey)) {
304+
this._pinnedDocuments.add(uriKey);
305+
vscode.commands.executeCommand('workbench.action.keepEditor');
306+
}
307+
this._scheduleAutoSaveOrMarkDirty(document);
308+
break;
309+
}
291310
case "openUrl":
292311
if (message.url) {
293312
vscode.env.openExternal(vscode.Uri.parse(message.url));

src/i18n/webviewTranslations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const ZH_CN_WEBVIEW: Record<string, string> = {
4747
'Enable Word Wrap': '开启自动换行',
4848
'Disable Word Wrap': '关闭自动换行',
4949
'Drag to resize': '拖拽调整高度',
50+
'Add field': '添加字段',
5051
'Remove Link': '移除链接',
5152
'Settings': '设置',
5253
'Upload Image': '上传图片',

webview/index.ts

Lines changed: 185 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "./editor";
88
import type { EditorView } from "@milkdown/prose/view";
99
import { TextSelection } from "@milkdown/prose/state";
10+
import { t } from "./i18n";
1011
import {
1112
notifyReady,
1213
notifyUpdate,
@@ -16,6 +17,7 @@ import {
1617
notifyUploadImage,
1718
notifyGetProjectImages,
1819
notifyRenameImage,
20+
notifyFrontmatterUpdate,
1921
getWebviewState,
2022
setWebviewState,
2123
} from "./messaging";
@@ -238,8 +240,14 @@ document.body.appendChild(toc.panel);
238240
// 初始化查找栏
239241
const findBar = initFindBar(() => document.getElementById("editor"));
240242

243+
// ─── Frontmatter 可编辑面板 ─────────────────────────────────
244+
245+
import { IconPlus, IconX } from "./ui/icons";
246+
247+
type FmEntry = { key: string; value: string };
248+
241249
/** 解析 YAML frontmatter 字符串为 key-value 数组 */
242-
function parseFrontmatter(raw: string): { key: string; value: string }[] {
250+
function parseFrontmatter(raw: string): FmEntry[] {
243251
return raw
244252
.split('\n')
245253
.filter(line => !line.match(/^---/) && line.includes(':'))
@@ -253,35 +261,197 @@ function parseFrontmatter(raw: string): { key: string; value: string }[] {
253261
.filter(({ key }) => key.length > 0);
254262
}
255263

264+
/** 将 key-value 数组序列化为 YAML frontmatter 字符串 */
265+
function serializeFrontmatter(entries: FmEntry[]): string {
266+
if (entries.length === 0) { return ""; }
267+
const lines = entries
268+
.filter(e => e.key.length > 0)
269+
.map(e => `${e.key}: ${e.value}`);
270+
if (lines.length === 0) { return ""; }
271+
return `---\n${lines.join("\n")}\n---\n`;
272+
}
273+
274+
/** 当前面板数据(模块级状态) */
275+
let currentFmEntries: FmEntry[] = [];
276+
277+
/** 将编辑结果同步到 Extension */
278+
function commitFrontmatterChange(): void {
279+
const raw = serializeFrontmatter(currentFmEntries);
280+
notifyFrontmatterUpdate(raw);
281+
// 若全部删除,移除面板
282+
if (currentFmEntries.length === 0) {
283+
const existing = document.getElementById('frontmatter-panel');
284+
existing?.remove();
285+
const editorEl = document.getElementById('editor');
286+
if (editorEl) { editorEl.style.paddingTop = ''; }
287+
}
288+
}
289+
290+
/** 为 contenteditable td 绑定编辑行为 */
291+
function bindFmCell(
292+
td: HTMLElement,
293+
entry: FmEntry,
294+
field: 'key' | 'value',
295+
tbody: HTMLElement,
296+
panel: HTMLElement,
297+
): void {
298+
td.contentEditable = 'true';
299+
td.textContent = entry[field];
300+
td.dataset['orig'] = entry[field];
301+
td.dataset['placeholder'] = field === 'key' ? 'key' : 'value';
302+
303+
// Enter 提交(Shift+Enter 允许换行)
304+
td.addEventListener('keydown', (e) => {
305+
if (e.isComposing) { return; }
306+
e.stopPropagation();
307+
if (e.key === 'Enter' && !e.shiftKey) {
308+
e.preventDefault();
309+
td.blur();
310+
} else if (e.key === 'Escape') {
311+
e.preventDefault();
312+
td.textContent = td.dataset['orig'] ?? '';
313+
td.blur();
314+
} else if (e.key === 'Tab') {
315+
e.preventDefault();
316+
td.blur();
317+
const idx = currentFmEntries.indexOf(entry);
318+
if (field === 'key') {
319+
// 切换到同行 value
320+
const valTd = td.nextElementSibling as HTMLElement | null;
321+
if (valTd?.contentEditable === 'true') { valTd.focus(); }
322+
} else {
323+
// 切换到下一行 key 或新增行
324+
const nextRow = tbody.children[idx + 1] as HTMLElement | undefined;
325+
if (nextRow) {
326+
const nextKeyTd = nextRow.querySelector('.fm-key') as HTMLElement | null;
327+
nextKeyTd?.focus();
328+
} else {
329+
addNewRow(tbody, panel);
330+
}
331+
}
332+
}
333+
});
334+
335+
td.addEventListener('blur', () => {
336+
const newVal = (td.textContent ?? '').trim();
337+
if (field === 'key' && newVal.length === 0) {
338+
// key 不能为空,恢复原值
339+
td.textContent = td.dataset['orig'] ?? '';
340+
return;
341+
}
342+
if (newVal !== entry[field]) {
343+
entry[field] = newVal;
344+
commitFrontmatterChange();
345+
}
346+
td.dataset['orig'] = entry[field];
347+
});
348+
}
349+
350+
/** 创建单行可编辑表格行(contenteditable td,直接输入) */
351+
function createFmRow(entry: FmEntry, index: number, tbody: HTMLElement, panel: HTMLElement): HTMLTableRowElement {
352+
const tr = document.createElement('tr');
353+
354+
// key 单元格
355+
const tdKey = document.createElement('td');
356+
tdKey.className = 'fm-key';
357+
bindFmCell(tdKey, entry, 'key', tbody, panel);
358+
359+
// value 单元格
360+
const tdVal = document.createElement('td');
361+
tdVal.className = 'fm-val';
362+
bindFmCell(tdVal, entry, 'value', tbody, panel);
363+
364+
// 删除按钮
365+
const tdDel = document.createElement('td');
366+
tdDel.className = 'fm-action';
367+
const delBtn = document.createElement('button');
368+
delBtn.className = 'fm-delete-btn';
369+
delBtn.innerHTML = IconX;
370+
delBtn.title = t('Delete');
371+
delBtn.addEventListener('mousedown', (e) => {
372+
e.preventDefault();
373+
e.stopPropagation();
374+
currentFmEntries.splice(index, 1);
375+
commitFrontmatterChange();
376+
rebuildFmTable(tbody, panel);
377+
});
378+
tdDel.appendChild(delBtn);
379+
380+
tr.appendChild(tdKey);
381+
tr.appendChild(tdVal);
382+
tr.appendChild(tdDel);
383+
return tr;
384+
}
385+
386+
/** 重建表格 tbody 内容 */
387+
function rebuildFmTable(tbody: HTMLElement, panel: HTMLElement): void {
388+
tbody.innerHTML = '';
389+
currentFmEntries.forEach((entry, i) => {
390+
tbody.appendChild(createFmRow(entry, i, tbody, panel));
391+
});
392+
}
393+
394+
/** 新增一行 */
395+
function addNewRow(tbody: HTMLElement, panel: HTMLElement): void {
396+
const newEntry: FmEntry = { key: '', value: '' };
397+
currentFmEntries.push(newEntry);
398+
const tr = createFmRow(newEntry, currentFmEntries.length - 1, tbody, panel);
399+
tbody.appendChild(tr);
400+
// 自动聚焦 key 单元格
401+
const keyTd = tr.querySelector('.fm-key') as HTMLElement | null;
402+
keyTd?.focus();
403+
}
404+
256405
/** 在 #editor 前渲染 frontmatter 表格面板;无 frontmatter 时移除面板 */
257406
function renderFrontmatterPanel(frontmatter: string | undefined): void {
258407
const existing = document.getElementById('frontmatter-panel');
259408
const editorEl = document.getElementById('editor');
409+
410+
// 无 frontmatter → 清空状态、移除面板
260411
if (!frontmatter) {
412+
currentFmEntries = [];
261413
existing?.remove();
262414
if (editorEl) { editorEl.style.paddingTop = ''; }
263415
return;
264416
}
417+
265418
const entries = parseFrontmatter(frontmatter);
266-
if (entries.length === 0) {
267-
existing?.remove();
268-
if (editorEl) { editorEl.style.paddingTop = ''; }
269-
return;
270-
}
419+
// 即使 entries 为空也保留面板(允许用户后续添加行)
420+
currentFmEntries = entries;
421+
271422
const panel = existing ?? document.createElement('div');
272423
panel.id = 'frontmatter-panel';
273424
panel.className = 'frontmatter-panel';
274-
panel.innerHTML = `<table class="frontmatter-table"><tbody>${
275-
entries.map(({ key, value }) =>
276-
`<tr><td class="fm-key">${escapeHtml(key)}</td><td class="fm-val">${escapeHtml(value)}</td></tr>`
277-
).join('')
278-
}</tbody></table>`;
279-
const editor = document.getElementById('editor');
425+
426+
// 构建表格
427+
const table = document.createElement('table');
428+
table.className = 'frontmatter-table';
429+
const tbody = document.createElement('tbody');
430+
entries.forEach((entry, i) => {
431+
tbody.appendChild(createFmRow(entry, i, tbody, panel));
432+
});
433+
table.appendChild(tbody);
434+
panel.innerHTML = '';
435+
panel.appendChild(table);
436+
437+
// 底部添加按钮
438+
const addRow = document.createElement('div');
439+
addRow.className = 'fm-add-row';
440+
const addBtn = document.createElement('button');
441+
addBtn.className = 'fm-add-btn';
442+
addBtn.innerHTML = `${IconPlus} <span>${t('Add field')}</span>`;
443+
addBtn.addEventListener('mousedown', (e) => {
444+
e.preventDefault();
445+
e.stopPropagation();
446+
addNewRow(tbody, panel);
447+
});
448+
addRow.appendChild(addBtn);
449+
panel.appendChild(addRow);
450+
280451
if (!existing) {
281-
editor?.parentNode?.insertBefore(panel, editor);
452+
editorEl?.parentNode?.insertBefore(panel, editorEl);
282453
}
283-
// 有 frontmatter 面板时,editor 的顶部 padding 由面板承担,只保留间距
284-
if (editor) { editor.style.paddingTop = '16px'; }
454+
if (editorEl) { editorEl.style.paddingTop = '16px'; }
285455
}
286456

287457

webview/messaging.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ export function notifyRenameImage(
7575
vscode.postMessage({ type: "renameImage", id, webviewUri, newBasename });
7676
}
7777

78+
export function notifyFrontmatterUpdate(frontmatter: string): void {
79+
vscode.postMessage({ type: "frontmatterUpdate", frontmatter });
80+
}
81+
7882
export function onMessage(handler: (msg: IncomingMessage) => void): void {
7983
window.addEventListener("message", (event: MessageEvent) => {
8084
handler(event.data as IncomingMessage);

0 commit comments

Comments
 (0)