77} from "./editor" ;
88import type { EditorView } from "@milkdown/prose/view" ;
99import { TextSelection } from "@milkdown/prose/state" ;
10+ import { t } from "./i18n" ;
1011import {
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// 初始化查找栏
239241const 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 时移除面板 */
257406function 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
0 commit comments