handymd GitHub

API 参考

包名:@21stware/handymd

import {
  createEditor, HandyEditor,
  createShikiHighlighter,
  // …见下方完整导出
} from '@21stware/handymd'
import '@21stware/handymd/style.css'

createEditor(options) → HandyEditor

工厂函数,等价于 new HandyEditor(options)。

HandyEditorOptions

字段类型默认说明
mountHTMLElement—挂载点(会被加上 handymd class)
contentstring''初始 markdown;与 load 同时给时 load 优先
load`() => string \Promise<string>`—异步拉取;失败 → phase=error
save`(md: string) => unknown \Promise<unknown>`—提供后启用 L4 自动保存
autosave`Omit<AutosaveOptions, 'save' \'onStatusChange'>`见下防抖/退避等
readOnlybooleanfalse初始只读
sourceModebooleanfalse以源码模式启动
highlight`CodeHighlighter \Promise<CodeHighlighter>`—代码高亮
diagram`DiagramRenderer \Promise<DiagramRenderer>`—diagram block(如 ```mermaid )渲染器;缺省时按普通代码块呈现
onOpenLink(href: string) => voidwindow.openConcealed 链接单击
onChange(md: string) => void—每次 docChanged
onPhaseChange(phase: EditorPhase) => void—L1 阶段变化
onSaveStatusChange(status: SaveStatus, error?: unknown) => void—L4 状态变化
pluginsPlugin[][]追加自定义 ProseMirror 插件
historybooleantrue是否启用撤销重做
normalizeOrderedListsbooleantrue有序列表自动重编号
uploadImage(file: File) => Promise<string>—粘贴 / 拖放 / insertImageFiles 的图片上传,返回写进 Markdown 的地址;缺省内联为 data: URL(不推荐,见 createLocalImageStore)
resolveImage`(src: string) => string \Promise<string>`—渲染前把 Markdown 里的图片地址解析成可加载的 URL(相对路径 / 本地存储 / 私有签名);源码不变

HandyEditor 实例

成员类型说明
view`EditorView \null`底层 ProseMirror 视图;loading/error/destroyed 时可能为 null
autosave`Autosave \null`未提供 save 时为 null
phaseEditorPhase`loading \ready \error \conflicted \destroyed`
saveStatusSaveStatus`clean \dirty \saving \retrying \offline`
readOnlyboolean当前只读态
sourceModeboolean当前是否源码模式
loadErrorunknown最近一次加载错误
remoteConflict`string \null`冲突中的远端文本
getMarkdown()() => string序列化(无损)
setMarkdown(md, opts?)(string, { addToHistory?: boolean }) => void编程式替换
insertTable(opts?)(InsertTableOptions) => boolean编程式插入 GFM 表格
insertImage(opts)({ src, alt? }) => boolean独立成行插入 ![alt](src)
insertImageFiles(files)`(Iterable<File> \FileList) => Promise<void>`插入图片文件(占位 → 上传 → 替换)
exportToPDF(opts?)(ExportPDFOptions) => Promise<void>以渲染态打开系统打印对话框(存储为 PDF),见「导出 PDF」
setReadOnly(v)(boolean) => void切换只读
setSourceMode(v)(boolean) => void源码模式 ⇄ 渲染模式
focus()() => void聚焦
retry()() => voiderror → loading 重试加载
notifyRemote(md)(string) => void通知远端版本变化
resolveConflict(choice)`('local' \'remote') => void`解决冲突
flush()() => Promise<void>立即保存
destroy()() => Promise<void>flush 后销毁
on(event, handler)见下事件订阅,返回取消函数

事件

editor.on('phase', (phase: EditorPhase) => {})
editor.on('change', (markdown: string) => {})
editor.on('saveStatus', (status: SaveStatus) => {})

L4:Autosave

import { Autosave, type AutosaveOptions, type SaveStatus } from '@21stware/handymd'

const as = new Autosave(() => markdown, {
  save: async (md) => { /* PUT */ },
  debounceMs: 800,
  maxRetries: 5,
  backoffBaseMs: 500,
  backoffMaxMs: 30_000,
  listenOnline: true,
  onStatusChange: (status, error) => {},
})

as.markDirty()
as.markClean()
await as.flush()
as.retryNow()
as.destroy()
as.status  // SaveStatus
as.error

状态转移:clean → dirty → saving → clean | retrying → offline;保存期间再输入会在完成后立即再存。


L3:conceal 插件

import {
  concealPlugin, concealKey, setConcealMeta,
  isRevealed, revealSignature, buildBlockDecos,
  type ConcealState, type ConcealMeta, type ConcealOptions,
} from '@21stware/handymd'

const plugin = concealPlugin({
  readOnly: false,
  // 可选:diagram block 的渲染回调(见"图表渲染"一节)
  renderDiagram: createDiagramRenderCallback(createMermaidRenderer()),
})

// 投递配置迁移
view.dispatch(setConcealMeta(view.state.tr, { readOnly: true }))
view.dispatch(setConcealMeta(view.state.tr, { composing: true }))
view.dispatch(setConcealMeta(view.state.tr, { refresh: true }))

const st = concealKey.getState(view.state)
// st.blocks / st.set / st.composing / st.readOnly

isRevealed(el, selection, readOnly):pure hitTest。 buildBlockDecos(block, revealed[], ctx?):由元素+reveal 位生成 decoration;ctx.renderDiagram 控制 diagram block 的渲染。


L2:输入管线插件

import {
  imePlugin,                 // composition 冻结
  interactionsPlugin,        // 链接打开 / checkbox
  caretGuardPlugin,          // 隐藏前缀光标 / 选区保护
  clipboardPlugin,           // 源码行级复制粘贴、HTML → Markdown、粘贴网址成链接
  normalizePlugin,           // 有序列表重编号
  markdownKeymap,            // Enter / Backspace / Mod-b…
  continueListItem,          // Enter
  splitWithoutPrefix,        // Shift-Enter
  closeFenceOnEnter,         // 未闭合围栏自动补闭合行
  toggleInline,
  setHeading,                // setHeading(1…6)
  indentListItem,
  dedentListItem,
  insertTab,
  removeTab,
  backspaceBlockFormat,
  deleteForwardStripPrefix,  // Delete
  arrowLeftSkipPrefix,
  shiftArrowLeftSkipPrefix,
  htmlToMarkdown,
  markdownToSlice,
} from '@21stware/handymd'

interactionsPlugin({ onOpenLink: (href) => location.assign(href) })
toggleInline('**')  // Command

表格(编程式)

GFM 表格无输入触发;请用 editor.insertTable() 或 insertTable command。

import {
  insertTable, buildTableMarkdown,
  goToNextTableCell, goToPrevTableCell, continueTableRow,
  parseTableRow, isTableSeparator,
  type InsertTableOptions,
} from '@21stware/handymd'

editor.insertTable({ rows: 3, cols: 3, headers: ['A', 'B', 'C'] })
buildTableMarkdown({ rows: 2, cols: 2 })
// => "|  |  |\n| --- | --- |\n|  |  |"

insertTable({ rows: 3, cols: 3 })(view.state, view.dispatch)

InsertTableOptions:rows?(含表头,默认 3)、cols?(默认 3)、withHeaderRow?(默认 true)、headers?。

表格在表头行渲染为单个网格 widget,单元格内编辑(见使用指南「表格」)。相关导出:

import {
  focusTableCell,   // (view, headerPos, { row, col }, caret?) → 让某格进入编辑
  parseTableModel,  // 整表源码 → { rows, align, colCount }
  renderCellPreview,
  parseTableAlign,
  backspaceIntoTable, deleteIntoTable, // 表格前后行的 Backspace / Delete 不并入管道源码
  tableControllerAt, // (view, headerPos) → 控制器;.pick({ kind: 'row' | 'col', index }) 选中整行 / 整列
} from '@21stware/handymd'

goToNextTableCell / goToPrevTableCell / continueTableRow 作用于 ProseMirror 选区,仅在源码模式下生效。

结构操作是作用于管道源码的纯函数(widget 的行列菜单 / 拖动排序也用它们)。行下标含表头(0 = 表头); 行操作保留未改动行的原文,列操作重写每一行与分隔行。返回 null 表示整张表被删空:

import {
  splitTableSource, joinTableSource,       // 整表源码 ⇄ { rows, sep }
  insertTableRow, deleteTableRow, moveTableRow,
  insertTableColumn, deleteTableColumn, moveTableColumn,
  setTableColumnAlign,                     // 'left' | 'center' | 'right' | 'none'
} from '@21stware/handymd'

const t = splitTableSource('| A | B |\n| --- | --- |\n| 1 | 2 |')
joinTableSource(moveTableColumn(t, 0, 1)).join('\n') // '| B | A |\n| --- | --- |\n| 2 | 1 |'

图片

import {
  insertImage, insertImageFiles, imageMarkdown, imagePlugin, selectedImage,
  createLocalImageStore,
} from '@21stware/handymd'

insertImage({ src: 'a.png', alt: 'A' })(view.state, view.dispatch)
await insertImageFiles(view, files, async (file) => uploadAndGetUrl(file))
imagePlugin({ upload })   // 粘贴 / 拖放图片文件 + 图片的点击选中 / 键盘删除
imageMarkdown({ src: 'a b.png', alt: 'x' }) // => "![x](a%20b.png)"
selectedImage(state)      // 选区恰好选中一张图片时返回该元素,否则 null

图片是原子元素:渲染态下永远不回到 ![alt](src) 源码。单击 = 选中(选区覆盖整段源码,画 .hm-image-selected),Backspace / Delete 先选中再删除,方向键把它当作一个字符跨过, 选中时 Enter 在图片后开新行。改 alt / 地址请用源码模式。

createLocalImageStore(opts?) → { upload, resolve, get }:没有后端时代替 data: 内联。 文件存进 IndexedDB(不可用时退化为内存),Markdown 里只写短路径 assets/<name>-<hash>.<ext> (内容哈希去重),渲染时由 resolve 换回 blob: URL。

选项默认说明
prefix'assets/'写进 Markdown 的路径前缀;resolve 只处理这个前缀下的地址
dbName'handymd-images'IndexedDB 库名;null = 只存内存

导出 PDF

import { exportToPDF, buildPrintDocument, printableClone } from '@21stware/handymd'

await editor.exportToPDF({ title: '周报' })   // 或 exportToPDF(view, opts)

克隆编辑器的渲染 DOM 写进隐藏 iframe 后调用 print(),用户在打印对话框里选「存储为 PDF」。 导出前临时切到只读渲染态(光标下的元素也收起源码;源码模式同样按渲染态导出),完成后还原; 会等待仍在渲染的图表与图片。页面样式表与 --hm-* 主题变量一并带入。

选项默认说明
title第一个标题打印文档标题(多数浏览器用作默认 PDF 文件名)
css—追加的打印 CSS(如 @page { size: A4 landscape })
timeout8000等待图表 / 图片 / 样式的上限(ms)
printwin => win.print()替换触发打印的方式(桌面壳的原生打印 / 测试拦截)

buildPrintDocument(view, { title?, css? }) 返回完整 HTML 字符串,可自行交给服务端渲染 PDF; printableClone(dom) 只做 DOM 清理(去掉表格把手、编辑中的格子、选中态)。


代码高亮

import {
  highlightPlugin, highlightKey, createShikiHighlighter,
  type CodeHighlighter, type HighlightSpan, type ShikiHighlighterOptions,
} from '@21stware/handymd'

type HighlightSpan = { text: string; color?: string }
type CodeHighlighter = (code: string, lang: string) => HighlightSpan[][] | Promise<HighlightSpan[][]>

const hl = await createShikiHighlighter({
  theme: 'github-light',
  langs: ['javascript', 'typescript', 'python', 'bash', 'json', 'html', 'css', 'markdown'],
})
highlightPlugin(hl)
// 或 highlightPlugin(createShikiHighlighter()) — 接受 Promise

图表渲染(diagram block)

```mermaid 围栏在结构化解析层就与普通代码块分开(diagramOpen / diagramLine / diagramClose),并遵循块级 Live Render 语义:光标离开围栏区域 → 源码整块隐藏、渲染为图表;光标进入(或点击图表)→ 回到围栏源码编辑,视觉与普通代码块一致。

import {
  createMermaidRenderer, createDiagramRenderCallback,
  type DiagramRenderer, type DiagramRenderCallback, type MermaidRendererOptions,
} from '@21stware/handymd'

// 渲染器契约:源码 → SVG/HTML 字符串(可异步;抛错 = 图表语法错误)
type DiagramRenderer = (code: string, lang: string) => string | Promise<string>

// 用 HandyEditor:直接传 diagram 选项(mermaid 为可选依赖,动态 import)
createEditor({ mount, diagram: createMermaidRenderer({ theme: 'neutral' }) })

// 自建 EditorView:包一层缓存回调再交给 concealPlugin
concealPlugin({ renderDiagram: createDiagramRenderCallback(createMermaidRenderer()) })

行为细节:

  • 渲染只发生在 Concealed 态(光标离开之后),编辑期间永远是源码 —— 不存在"边打字边重渲染"的抖动;
  • 结果按 (lang, code) 缓存,光标反复进出同一图表命中缓存、无闪烁;
  • 渲染失败显示错误信息(.hm-diagram-error),点击仍可进入源码修复;
  • 空围栏显示占位(.hm-diagram-empty),不会让块"消失";
  • 未配置渲染器时 diagram block 退化为普通代码块呈现(解析层仍然分类为 diagram)。

文档模型与解析

import {
  schema,
  markdownToDoc, docToMarkdown, toCommonMark,
  parseInline, parseInlineCached,
  classifyLines, parseDoc,
  type LineInfo, type LineType, type BlockMeta,
  type ElementRange, type ElementKind, type ElementAttrs,
  type InlineKind, type BlockKind, type RelElement, type Span,
} from '@21stware/handymd'

markdownToDoc('# hi')           // Node
docToMarkdown(doc)              // string,无损
toCommonMark(md, { lineBreak: 'hard' })  // 导出为语义等价的 CommonMark(见 guide)
parseInline('**a** ==b==')      // RelElement[](相对坐标)
classifyLines(['# a', '```', 'x', '```'])
parseDoc(doc)                   // BlockMeta[](绝对坐标 + 元素表)

ElementRange 要点

字段说明
kind`strong \em \code \strike \mark \link \image \tag \heading \quote \todo \bullet \ordered \hr \fenceOpen \fenceClose \codeLine \diagramOpen \diagramClose \diagramLine \tableHeader \tableSep \tableRow \tableCell`
scopeinline(扩一格命中)/ block(块命中)
from / to元素整体范围
hitFrom / hitTocursorEnter/Leave 判定区间
markers需隐藏的标记符子范围
content语义内容范围
static永不参与 reveal(tag / codeLine / ordered 序号样式)
permanent永久 Concealed(quote / bullet / todo / hr)
attrslevel / checked / checkPos / href / alt / indent / num / info / lang / code / colCount / col / tableEdge / tableSrc

标题不设 permanent:源码 # 在 decoration 层永远隐藏,但聚焦时要展示层级图标,因此参与 reveal 判定。


CSS 入口

import '@21stware/handymd/style.css'
// 或
import '@21stware/handymd/style.css' // package exports: "./style.css"

挂载点 class:handymd。关键类名:

class用途
.hm-concealedfont-size:0 隐藏标记
.hm-caret-pad透明空格,保证行首光标可见
.hm-marker可见(弱化)标记
.hm-strong / .hm-em / .hm-code / .hm-strike / .hm-mark / .hm-link / .hm-tag行内语义
.hm-heading / .hm-h1… / .hm-heading-badge标题与层级图标
.hm-quote / .hm-todo / .hm-bullet / .hm-ordered块级
.hm-checkbox / .hm-bullet-dot / .hm-hr / .hm-imagewidgets
.hm-code-line / .hm-fence-line / .hm-code-lang代码块
.hm-diagram / .hm-diagram-host / .hm-diagram-hidden / .hm-diagram-loading / .hm-diagram-empty / .hm-diagram-errordiagram block
.hm-table-wrap / table.hm-table-grid / .hm-table-cell / .hm-table-cell-editing表格网格 widget / 单元格 / 编辑中的单元格
.hm-table-scroll / .hm-table-cell-picked表格横向滚动容器 / 被选中整行整列的单元格
.hm-table-handle-row / .hm-table-handle-col / .hm-table-add-row / .hm-table-add-col / .hm-table-menu行列把手 / 添加条 / 行列菜单(只读态隐藏)
img.hm-image / .hm-image-selected图片预览 / 选中态
.hm-table-host / .hm-table-hidden表头源码行(承载网格) / 折叠的分隔行与表体源码行