MIT License · 零依赖 · 插件化内核

一套内核,装得下所有编辑需求

Sneditor 是一套自研的轻量富文本编辑器:不依赖任何框架与第三方库, 页面里只引一个 sneditor.css + 一个 sneditor.js。 采用 contenteditable 内核,所有功能以插件挂载,工具栏由命令注册表驱动 —— 关掉插件,按钮自动消失。

index.html · 全部引入就这两行
<link rel="stylesheet" href="./sneditor/sneditor.css">
<script src="./sneditor/sneditor.js"></script>

<!-- 一个容器就够了 -->
<div id="editor"></div>
1+1CSS + JS 文件
0第三方依赖
41已注册命令
6可插拔插件
10内置主题
24代码块语言
01 / LIVE DEMO

在线演示:这就是同一个 js 跑出来的

下面这个编辑器直接引入了本仓库的 sneditor.css 与 sneditor.js,没有任何构建步骤。 换主题、开 AI 分栏、插代码块、导出 Markdown —— 都是真实调用实例 API。

主题

      
关于 AI 演示:官网不联网,AI 用的是本页用 Sneditor.registerAiProvider('demo', { keyField: '' }) 注册的一个「离线演示」服务商,配合 editor.aiRequest 打桩输出模拟流式文本 —— 顺便演示了自定义服务商这个扩展点。 真实项目里把 ai.provider 填成内置的八家之一并配上 Key 即为真实请求;改成 'proxy' 则走后端代理。
02 / INTRODUCTION

介绍:插件化内核,能力全在明面上

内核只管 DOM、历史、选区、模式、工具栏与事件,其余一切 —— Markdown、上传、表格、AI、地图、主题 —— 都是平级插件。每个插件可单独关闭,互不依赖;连轻提示 editor.notify() 都是内核方法。

Aa文本与段落

加粗 / 斜体 / 下划线 / 删除线 / 上标下标、字体颜色与背景色、字号、清除格式;标题 1-6、引用、有序无序列表、对齐、分割线、行内代码。

↥四类上传

图片 / 视频 / 音频 / 附件,点击、拖拽、Ctrl+V 粘贴三条路都通,带上传进度与失败提示;可开启外链图片自动转存。

M↓Markdown 双向

富文本 ⇄ Markdown 双向转换。左右分栏,左侧源码可输入、右侧预览也可输入,两侧实时同步,中间的分隔条可拖拽调宽(双击恢复 1:1)。

✦AI 助手

编辑器内左右分栏,左侧写作、右侧流式生成;润色 / 续写 / 扩写 / 精简 / 翻译 / 总结 / 排版 / 自定义指令,结果可直接替换、插入或追加。

⚡AI 免后端直连

内置 OpenAI / 智谱 / 讯飞星火 / DeepSeek / Kimi / 通义 / 硅基流动 / 百度千帆八家,都是 OpenAI 兼容协议,一份代码通吃,浏览器直连。

{ }代码块语言

插入代码块时自动识别语言,也可显式指定,共 24 种。语言写进 data-lang 与 class="language-*",导出 Markdown 自动带围栏语言。

⇤导入 Markdown

文本 / 文件 / 网址三路导入,支持覆盖、追加、插入三种落地方式,自动解析 front matter,并默认做一次安全清洗。

◎地图双通道

高德 AMap + 百度 BMapGL 选点(POI 搜索 / 地图点选 / 手填坐标),地址与坐标双向解析。插入时可选卡片或完整地图,随时互切、数据不丢。

◐10 套主题

6 浅色 + 4 深色,全部由 CSS 变量驱动;支持运行时用几行变量注册自定义主题,浮层会跟着换,不会半深半浅。

▤表格与预览

插入表格、行列增删、悬停操作条;另有 HTML 源码模式、整页预览、全屏、只读切换与字数统计。

⧉粘贴净化与草稿

内置粘贴净化,自动剥掉 Office 的 mso-*、<o:p> 等垃圾标签;支持自动草稿、撤销重做与历史深度配置。

◨样式隔离

内核用 .sne-* 前缀 + .sne-content 作用域做 reset 隔离,宿主页面的全局样式不会污染编辑区。

六个插件,各管一摊

在 plugins: {} 里把某一项设为 false,它的 init 不会执行, 工具栏上所有归属该插件的命令一并隐藏,但 Sneditor.getPluginDef(name) 仍可拿到定义。

插件提供的能力命令可关闭
markdown 富文本 ⇄ Markdown 双向转换、分栏实时预览、纯源码模式、复制 MD、导入 Markdown markdown mdsource copymd mdimport ✓
upload 图片 / 视频 / 音频 / 附件四类上传,拖拽与粘贴上传、外链图片转存 image video audio attachment ✓
table 插入表格、行列增删、悬停操作条、Tab 键跳格 table ✓
ai AI 助手侧栏 / 浮层、多服务商直连、SSE 流式输出、8 项内置功能 ai ✓
map 高德 / 百度双通道选点、POI 搜索、正逆地理编码、卡片与完整地图互切 map mapamap mapbaidu ✓
theme 10 套内置主题切换、运行时新建自定义主题 theme ✓

41 个已注册命令(默认铺开 39 个)

工具栏由「命令注册表」驱动,按钮随插件启停自动显隐。地图默认只占一个入口(弹窗里切高德 / 百度), editor.setToolbar('all') 会铺满全部 41 个,多出「高德 / 百度」两个直达按钮。

分组命令名说明
历史undo redo撤销 / 重做(默认栈深 100)
段落formatBlock fontsize forecolor backcolor段落格式(标题 1-6 / 正文 / 引用 / 代码块)、字号、字色、底色
行内bold italic underline strike superscript subscript加粗、斜体、下划线、删除线、上标、下标
块级align listul listol quote code codeblock codelang对齐、无序 / 有序列表、引用、行内代码、代码块(自动识别语言)、代码块语言
链接link unlink插入超链接 / 取消链接
媒体image video audio attachment四类上传入口
其它块table hr表格、分割线
地图map mapamap mapbaidu地图选点入口(后两个是高德 / 百度直达按钮,仅 'all' 时出现)
Markdownmarkdown mdsource copymd mdimport分栏、纯源码、复制 MD、导入 Markdown
AI / 主题ai themeAI 助手侧栏、主题面板
视图preview readonly source fullscreen预览、只读预览、HTML 源码、全屏
清理clear清除格式
03 / INTEGRATION

集成:两个静态文件,五个场景

本质是 DOM + 事件,任何框架都能包一层。仓库里已经备好了 Vue2 / Vue3 组件,原生 HTML 只需要两行资源引入。

引入两个文件,给一个容器,然后 new Sneditor(el, options)。所有功能都能在初始化时开 / 关、配参数。

index.html
<link rel="stylesheet" href="./sneditor/sneditor.css">
<script src="./sneditor/sneditor.js"></script>

<div id="editor"></div>

<script>
  // 统一后端前缀:upload / ai(proxy 模式) 默认在它下面拼 /upload、/ai
  Sneditor.apiBase = '/api/editor';

  var editor = new Sneditor('#editor', {
    height: 480,
    theme: 'light',                 // 10 套内置主题
    placeholder: '在这里开始写作…',
    content: '<p>Hello Sneditor</p>',

    // 插件开关:不想用的直接关,工具栏按钮会一起消失
    plugins: { /* markdown: false, upload: false, map: false, theme: false, ai: false */ },

    markdown: { split: true, previewEditable: true, backSync: true },
    upload:   { serverUrl: '/api/editor/upload', catchRemoteImage: true },
    ai:       { provider: 'zhipu', apiKey: '', model: 'glm-4-flash' },
    map:      { defaultProvider: 'amap', providers: { amap: { key: '' }, baidu: { ak: '' } } },

    onChange: function (payload) { /* payload = { html, text, markdown } */ }
  });

  // 读内容
  editor.getHtml(); editor.getMarkdown(); editor.getContent('json');
</script>
提示 内容 落库建议存 Markdown:体积小、可 diff、可检索,展示时再用 Sneditor.Markdown.markdownToHtml(md) 转回 HTML。

直接用仓库里的 vue3/Sneditor.vue(script setup + defineExpose), 组件内部已 import 那两个静态文件。

App.vue
<template>
  <Sneditor ref="ed" v-model="content" model-type="markdown" theme="cyber" :height="480"
            :options="editorOptions" @save="onSave" />
</template>

<script setup>
import { ref } from 'vue'
import Sneditor from './components/Sneditor.vue'

const content = ref('')
const ed = ref(null)
const editorOptions = {
  ai: { provider: 'zhipu', apiKey: 'xxx.yyy', remember: true },
  map: { defaultProvider: 'baidu' }
}

function onSave(payload) {
  api.post('/article', { content: payload.markdown })
}
// 也可以用 ref 调实例方法
// ed.value.getMarkdown(); ed.value.insertMapLocation({ lng: 116.397, lat: 39.908, name: '天安门' })
</script>

组件 props

prop类型说明
modelValue(Vue3)Stringv-model 内容,格式由 model-type 决定
modelTypehtml | markdownv-model 的格式,默认 html
themeString主题名,变化时轻量切换(不重建实例)
heightNumber | String编辑区高度
placeholderString占位提示
readonlyBoolean只读
optionsObject内核 + 插件配置,深度变化时重建实例

组件暴露的方法(ref 调用)

getHtmlgetMarkdowngetText getContentsetContentinsertHtml insertMarkdowninsertMapLocationopenMapDialog setModesetThemelistThemes setReadonlytoggleAiPanetoggleFullscreen focusclearsave getEditorrebuild
设计要点 v-model 做了回环保护:程序性写入不会再 emit 一次,避免输入抖动与光标跳走; options 整体变化才重建实例,theme / readonly / height 变化走轻量更新。

vue2/Sneditor.vue 用 Options API 写成,Vue 2.6 / 2.7 通用。props 与 Vue3 版一致, 区别是 v-model 走 value 属性 + input 事件,生命周期用 beforeDestroy。

ArticleEdit.vue
<template>
  <sneditor v-model="content" model-type="markdown" theme="mint" :height="480"
            :options="editorOptions" ref="ed" />
</template>

<script>
import Sneditor from '@/components/Sneditor.vue'

export default {
  components: { Sneditor },
  data () {
    return {
      content: '',
      editorOptions: { ai: { provider: 'deepseek', apiKey: 'sk-xxx' } }
    }
  },
  async mounted () {
    const md = this.$refs.ed.getMarkdown()
    // 也有 this.$refs.ed.insertMarkdown('## 标题')
  }
}
</script>
注意 Vue2 版里「设置内容」的方法叫 setEditorContent(value, format), 因为 setContent 在 Options API 的 methods 里会和编辑器实例方法同名,容易误用。

仓库里没带现成的 React 组件,但它本来就不需要 —— 编辑器是纯 DOM + 事件, 用 useRef 拿容器、useEffect 建销实例即可,二十行搞定。

Sneditor.jsx
import { useRef, useEffect, forwardRef, useImperativeHandle } from 'react'
import '../sneditor/sneditor.css'
import '../sneditor/sneditor.js'          // 执行后挂到 window.Sneditor

const SneditorEditor = forwardRef(function SneditorEditor(
  { value = '', onChange, options = {} }, ref
) {
  const host = useRef(null)
  const ed = useRef(null)

  useEffect(() => {
    const Sneditor = window.Sneditor
    ed.current = new Sneditor(host.current, {
      ...options,
      content: value,
      onChange: (p) => onChange && onChange(p)
    })
    return () => ed.current.destroy()      // 卸载时必须销毁,否则事件会泄漏
  }, [])                                   // 依赖留空:只在挂载时建一次

  useImperativeHandle(ref, () => ({
    getHtml: () => ed.current.getHtml(),
    getMarkdown: () => ed.current.getMarkdown(),
    setContent: (v, f) => ed.current.setContent(v, f),
    getEditor: () => ed.current
  }))

  return <div ref={host} />
})

export default SneditorEditor
要点 受控更新要自己加一层「回环保护」(对比新旧值再写),否则每次 onChange 都会把内容重设一遍, 光标会跳回开头。Vue 版组件已经内置了这层保护,React 侧需要照抄思路。

AI 有两条通道。通道一(默认)浏览器直连服务商,不需要任何后端; 通道二走自建代理,密钥留在服务端,适合面向公网的产品。

通道一 · 直连(免后端)
// 全部设置就在这一个对象里:AI 面板上没有任何输入框
var editor = new Sneditor('#editor', {
  ai: {
    provider: 'zhipu',        // openai | zhipu | spark | deepseek | moonshot | qwen | siliconflow | baidu
    apiKey: 'xxxx.yyyy',      // 当前服务商的 Key
    model: 'glm-4-flash',     // 留空用服务商默认模型

    // 多家一起配(按服务商名分别给)
    apiKeys: { openai: 'sk-xxx', zhipu: 'xxxx.yyyy' },
    models:  { openai: 'gpt-4o-mini', zhipu: 'glm-4-flash' },
    baseUrls:{ openai: 'https://my-proxy/v1' },   // 走自建转发时填

    panel: 'side',            // side = 编辑器内左右分栏(默认)| popover = 浮层面板
    position: 'right',
    width: 380,
    showEngine: true,         // 面板顶部只读的「服务商 · 模型」标识
    stream: true,
    temperature: 0.7,
    markdownOutput: true      // 结果按 Markdown 渲染后插入
  }
});

// 运行时换引擎(不经过界面)
editor.setAiEngine('openai', { apiKey: 'sk-x', model: 'gpt-4o-mini' });
// 排查连通性
editor.resolveAiEngine();      // { name, label, url, key, model, ready, error, … }
editor.testAiConnection();
通道二 · 自建后端代理(provider: 'proxy')
Sneditor.apiBase = '/api/editor';

new Sneditor('#editor', {
  ai: {
    provider: 'proxy',                       // 密钥留在服务端,最安全
    endpoint: '',                            // 留空 = Sneditor.apiBase + '/ai'
    headers: { 'X-CSRF-Token': getToken() }, // 自定义请求头
    stream: true
  }
});

内置八家服务商(都是 OpenAI 兼容协议 · SSE 流式)

provider名称默认地址默认模型Key 从哪拿
openaiOpenAIhttps://api.openai.com/v1gpt-4o-miniplatform.openai.com
zhipu智谱 GLMhttps://open.bigmodel.cn/api/paas/v4glm-4-flash(免费)open.bigmodel.cn
spark讯飞星火https://spark-api-open.xf-yun.com/v1lite(免费)控制台的 APIPassword
deepseekDeepSeekhttps://api.deepseek.com/v1deepseek-chatplatform.deepseek.com
moonshot月之暗面 Kimihttps://api.moonshot.cn/v1moonshot-v1-8kplatform.moonshot.cn
qwen通义千问https://dashscope.aliyuncs.com/compatible-mode/v1qwen-turbo阿里云百炼控制台
siliconflow硅基流动https://api.siliconflow.cn/v1Qwen/Qwen2.5-7B-Instructcloud.siliconflow.cn
baidu百度千帆https://qianfan.baidubce.com/v2ernie-speed-8k千帆控制台
proxy自建后端代理Sneditor.apiBase + '/ai'由后端决定不需要前端 Key
安全 浏览器直连意味着 Key 存在于前端,任何能打开 DevTools 的人都能看到,只适合内部工具 / 自用。 面向公网的产品请用 provider: 'proxy';勾选「记住」还会写进 localStorage,公共电脑上不要勾。

只有一个接口是必选的 —— 上传。AI 只有选「自建代理」时才需要后端。统一前缀由 Sneditor.apiBase 指定(默认 /api/editor)。

1. 上传(必选)

POST /api/editor/upload · multipart/form-data
# 请求
POST /api/editor/upload        Content-Type: multipart/form-data
     file=<二进制>              # 字段名可用 uploadField 配置
     type=image|video|audio|attachment

# 响应
{
  "code": 0,
  "msg": "ok",
  "data": { "url": "https://cdn.x/a.png", "name": "a.png", "size": 102400, "ext": "png" }
}

前端侧的对应配置:upload.serverUrl、uploadField、uploadHeaders、 uploadMaxSize、uploadExtra、uploadAccept。 返回结构如果和上面不一致,用 upload.responsePrepare 之类的钩子归一化即可 —— 内核只认 data.url。

2. AI(只在 provider: 'proxy' 时需要)

POST /api/editor/ai · text/event-stream
# 请求
POST /api/editor/ai            Content-Type: application/json
     { "messages": [{ "role": "user", "content": "…" }], "model": "", "stream": true }

# 响应(SSE,任选一种格式,前端都认)
data: {"content": "增量文本"}          # 推荐:自定义精简格式
data: {"choices":[{"delta":{"content":"增量文本"}}]}   # OpenAI 原生格式
data: 纯文本分块
data: [DONE]
参考实现 仓库自带 PHP(ThinkPHP 8)参考后端,位置在 backend/thinkphp8/: Editor.php(上传)、EditorAi.php(AI 流式代理)、route.php、config.php。 其它语言按上面两个协议实现即可,一共不到两个接口。
上传排错 ThinkPHP 上传成功后图片 404,多半是 public/storage 软链没建 —— 执行 php think storage:link,或手动在 public/ 下建软链指向 storage/app/public。
04 / EXTENDING

二次开发:插件、命令、装饰与主题

源码在 sneditor/src/ 下按「内核 + 各插件」分文件维护,改完用 python tools/build.py 拼成一个 css + 一个 js。所有扩展点都是静态方法,注册即生效。

4.1 源码结构与构建

项目结构
sneditor/
├── sneditor.js                 # ★ 打包产物:内核 + 6 个插件(页面只引这一个 js)
├── sneditor.css                # ★ 打包产物:全部样式(页面只引这一个 css)
└── src/                        # 可维护源码
    ├── sneditor-core.js                # 内核:命令注册表 / 历史 / 模式 / 侧栏 / 主题基座 / toast
    ├── sneditor-plugin-markdown.js     # Markdown 双向转换 + 分栏
    ├── sneditor-plugin-upload.js       # 图片 / 视频 / 音频 / 附件上传
    ├── sneditor-plugin-table.js        # 表格
    ├── sneditor-plugin-ai.js           # AI 助手(多服务商直连 / 流式 / 左右分栏)
    ├── sneditor-plugin-map.js          # 高德 / 百度地图选点
    ├── sneditor-plugin-theme.js        # 主题(10 套内置 + 自定义注册)
    └── sneditor.css                    # 样式源码

tools/
├── build.py                    # 打包:src/ → sneditor.js + sneditor.css
├── verify.js                   # 自测:367 项 jsdom DOM 断言
└── smoke-page.js               # 整页冒烟:真实加载 index.html,抓页面级 JS 错误
开发命令
# 打包:纯文本拼接,内核在前、插件按固定顺序在后
python tools/build.py

# 只校验不写文件(CI 可用)
python tools/build.py --check

# 自测:367 项 jsdom DOM 断言
node tools/verify.js                 # 测打包产物
node tools/verify.js sneditor/src    # 测未打包源码

# 整页冒烟:真实加载 index.html,页面级 JS 错误会直接失败
node tools/smoke-page.js
重要 改完 sneditor/src/ 里任何一个文件都必须重新打包,否则页面用的还是旧产物。 拼接顺序只影响「谁的默认值先注册」,不影响功能是否可用。

4.2 注册一个插件

Sneditor.registerPlugin(name, def)
Sneditor.registerPlugin('watermark', {
  title: '水印',
  optionKey: 'watermarkCfg',                        // 配置键(默认与插件同名)
  defaults: { enabled: true, text: '内部资料' },     // 自动合并进 options[配置键]
  init: function (editor, cfg) {
    // 初始化逻辑:绑事件、建 DOM、加命令……
    // 用 Sneditor.onReady(fn) 注册的钩子会在每个实例构造完成后依次调用,
    // 这是插件绑定 DOM 事件的正确入口(不要用 Sneditor.prototype.on())
    return true;                                    // 返回 false = 视为未启用
  },
  api: { /* 供他人复用的纯函数 */ }
});

// 实例化时按插件名配置
new Sneditor('#editor', { watermarkCfg: { text: '机密' } });

// 或者用「安装函数」的形式
Sneditor.use(function (S) { /* … */ });
Sneditor.use({ install: function (S) { /* … */ } });

4.3 加一个工具栏按钮

Sneditor.addCommand(name, def)
Sneditor.addCommand('mycmd', {
  title: '我的按钮',
  icon: 'star',                 // Sneditor.icons 里的键名,或直接传 SVG 字符串
  plugin: 'watermark',          // 归属插件:插件被关掉时按钮自动隐藏
  toggle: true,                 // 按钮类型:可切换,配合 opened(editor) 显示激活态
  opened: function (editor) { return editor.isPaneOpen('mycmd'); },
  action: function (editor) {
    editor.editPane('mycmd', { side: 'right', width: 320, title: '我的面板' });
  }
});

// 加进工具栏的两种方式
// 1) 写进初始化配置
new Sneditor('#editor', { toolbar: [['undo','redo'], ['bold','mycmd'], ['ai']] });

// 2) 让运行时自动编排:它会读注册表,第三方命令自动补到末尾
editor.setToolbar(Sneditor.allToolbar());

4.4 全部扩展点

需求做法
注册插件Sneditor.registerPlugin(name, def) / Sneditor.use(fn)
新增工具栏按钮Sneditor.addCommand(name, def)、Sneditor.addCommands(map)
插件就绪钩子Sneditor.onReady(fn)(实例构造完成时调用,绑定 DOM 事件的正路)
实例销毁钩子Sneditor.onDestroy(fn)(释放定时器、解绑 window 事件)
换图标改写 Sneditor.icons 里同名 SVG(内置 map/pin/palette/split/sidebar/swap/lock/gear/key/bolt/power 等)
换 / 加主题Sneditor.registerTheme(name, { label, dark, vars, css })
接新的 AI 服务商Sneditor.registerAiProvider(name, { label, baseUrl, models, defaultModel, keyField, auth?, body? })
接第三方地图Sneditor.registerMapProvider(name, { label, keyField, script, preload, isReady, mount, link, staticUrl })
加代码块语言Sneditor.registerLang(name, { label, alias, tests });识别用 Sneditor.detectLang(code)
自定义 AI 功能ai.functions: [{ key, label, system }],null 表示用内置 8 项
改全局默认值Sneditor.extendDefaults({ … })
复用 Markdown 转换Sneditor.Markdown.htmlToMarkdown(html) / .markdownToHtml(md)
让装饰不进导出Sneditor.registerChrome('.my-overlay') 登记瞬态装饰;Sneditor.registerDecorator(fn) 参与重绘
拿实例 / 枚举命令Sneditor.get(el)、Sneditor.instances、Sneditor.getAllCommands()
复用底层工具Sneditor.HtmlUtil、Sneditor.History、Sneditor.TableUtil、Sneditor.UploadUtil
本地化各文件顶部的命令 title / 面板 label 集中定义,改这一处即可

4.5 自定义 AI 服务商

Sneditor.registerAiProvider
Sneditor.registerAiProvider('corp', {
  label: '公司内部模型',
  hint: '内网 vLLM',
  baseUrl: 'https://llm.corp.internal/v1',
  models: ['qwen2.5-14b', 'glm-4-9b'],
  defaultModel: 'qwen2.5-14b',
  keyField: 'API Key',            // '' = 不需要 Key(本页演示就用了这招)
  keyHint: '找平台组申请',
  console: '',                    // 留空则不显示「获取 Key」链接
  auth: function (key) {          // 可选:非 Bearer 鉴权时自己拼头
    return { Authorization: 'Bearer ' + key, 'X-Tenant': 'sne' };
  },
  body: function (body) {         // 可选:改写请求体
    body.enable_thinking = false;
    return body;
  }
});

// 之后 ai.provider 写 'corp' 即可
new Sneditor('#editor', { ai: { provider: 'corp', apiKey: 'xxx' } });

4.6 自定义主题与代码语言

registerTheme / registerLang
// 主题:只给 CSS 变量,内核自动注入 <style>
Sneditor.registerTheme('deepblue', {
  label: '深海蓝',
  dark: true,                          // 影响浮层配色
  vars: {
    '--sne-primary': '#2563eb',
    '--sne-radius': '10px',
    '--sne-bg': '#0b1220',
    '--sne-text': '#e6edf7'
  },
  css: '.sne--deepblue .sne-content h1 { color: #9dc0ff; }'
});
editor.setTheme('deepblue');

// 代码语言:加一门自己的语言
Sneditor.registerLang('mine', {
  label: '自研',
  alias: ['ml'],
  tests: [/^#!mine/]                   // 自动识别的正则
});
Sneditor.detectLang('#!/mine\nrun()');  // → 'mine'

4.7 事件系统

统一用 editor.on(name, fn) / off / emit。下面的表按模块列全了。

模块事件
生命周期ready pluginsready pluginerror destroy
内容change save maxlength autosaverestore
交互focus blur selectionchange readonlychange notify
视图modechange fullscreenchange preview historychange sourcechange
侧栏 / 主题themechange paneopen paneresize popoveropen mdsync mdsplit
上传uploadstart uploadprogress uploadsuccess uploaderror
AIaistart(含 provider) aiend aierror
Markdown 导入mdimport(p.frontMatter 取 YAML 头)
代码块codeblockinsert codelangdetect codelangchange
地图mapopen mapinsert mapshow maprebuild mapinline mappick mapreverse
典型用法
editor.on('ready', function (ed) {
  console.log('已挂载插件:', ed.pluginList().join(', '));
});

// 内容变化:生产环境记得对 markdown 做防抖再提交
var submit = debounce(function (md) { api.post('/draft', { md: md }); }, 800);
editor.on('change', function (p) { submit(p.markdown); });

// 插件出错不会拖垮整个编辑器,能在这里兜住
editor.on('pluginerror', function (p) { report(p.name, p.error); });

// 导入 Markdown 时取走 front matter
editor.on('mdimport', function (p) { console.log(p.frontMatter, p.mode, p.length); });

// 上传完成后回填业务字段
editor.on('uploadsuccess', function (p) { console.log(p.data.url, p.data.name); });
05 / API REFERENCE

API 参考

实例方法与静态方法一览。输入关键词可以即时过滤,例如 markdown、ai、set。

没有匹配的 API,换个关键词试试。

读取内容 read

方法返回说明
getHtml()string富文本 HTML
getMarkdown()stringMarkdown 源码
getText()string纯文本
getContent('json')object{ html, text, markdown };参数传 'markdown' / 'html' 则只回对应字符串
getWordCount()object{ chars, words }
getDraft()object{ html, markdown, time }(自动草稿)

写入内容 write

方法说明
setHtml(html)整体写入 HTML(会走一次净化)
setMarkdown(md)覆盖全文(Markdown)
setContent(value, 'markdown')按格式写入,第二参 'html' | 'markdown'
insertHtml('<p>x</p>')在光标处插入 HTML
insertMarkdown('## 标题')Markdown 片段 → 富文本插入
insertText('文本')插入纯文本
clear()清空内容

导入 Markdown markdown

方法说明
importMarkdown(md)默认覆盖全文,返回 this
importMarkdown(md, { mode })'append' 追加到文末 / 'insert' 插到光标处
importMarkdown(md, { raw: true })内容可信时跳过安全清洗
importMarkdown(md, { frontMatter: false })不解析开头的 YAML 头
importMarkdownFile(file)File / Blob(拖拽、input[type=file])→ Promise
importMarkdownUrl(url)远程拉取 → Promise
openMarkdownImport()打开可视化导入面板(文件 / 粘贴 / 网址三路)

代码块与语言 code

方法 / 属性说明
insertCodeBlock({ lang: 'auto' })按当前选区自动识别语言;也可显式写 'python',或连 code 一起给
getCodeLang(el?)读光标所在代码块的语言(也可传 pre 元素)
setCodeLang('rust')换语言;'plaintext' = 去掉语言
Sneditor.detectLang(code)纯函数:识别一段代码的语言
Sneditor.CODE_LANGS语言表(24 种,含别名与识别正则)
Sneditor.registerLang(name, def)注册自定义语言

光标与选区 caret

方法说明
placeCaretIn(el)把光标放到该节点内部的开头(表格重建用的就是它)
placeCaretIn(el, true)放到末尾(插入块级节点后用这个方向)
placeCaretAtEnd()放到正文最后
saveSelection() / restoreSelection()手动存取选区(AI 生成前保位就是这套)
focus() / blur()聚焦 / 失焦

模式、视图与工具栏 view

方法说明
setMode('wysiwyg' | 'html' | 'markdown')切换模式
setReadonly(true)只读切换
toggleFullscreen()全屏
openPreview()打开整页预览浮层
setToolbar('all')铺满全部已注册命令(41 个)
setToolbar(Sneditor.allToolbar())用运行时自动编排的工具栏
setToolbarShows({ image: false })动态显隐按钮(可由后端下发权限)
exec('bold')执行任意已注册命令
notify({ type, message, duration })轻提示 toast
setMdSplitRatio(0.68)Markdown 分栏比例(夹在 0.15 ~ 0.85),传 null 恢复 1:1
getMdSplitRatio()当前比例(没拖过就是 0.5)

主题 theme

方法说明
setTheme('starry')按名字切换(10 套内置 + 自定义)
getTheme()当前主题名
nextTheme() / cycleTheme()按注册表顺序切换
Sneditor.listThemes()列出全部主题(含 label / dark / swatch)
Sneditor.registerTheme(name, def)运行时注册自定义主题

AI ai

方法说明
toggleAiPane(true)打开 / 关闭 AI 侧栏
setAiEngine('zhipu', { apiKey, model, remember })运行时换引擎(不经过界面)
resolveAiEngine()返回 { name, label, url, key, model, ready, error, … },排查用
testAiConnection()发一条最短请求验证 Key 与连通性
ai('polish')按内置功能 key 直接生成 → Promise<string>
aiRequest({ messages, onChunk })底层请求(流式 / 非流式)
aiApply(text, 'replace' | 'insert' | 'append')把结果落进正文
aiStop()中断当前生成

地图 map

方法说明
openMapDialog({ provider })打开选点弹窗
insertMapLocation({ provider, lng, lat, name, address, show })插入地点,show 取 'card' | 'map'
getMapLocations()读出内容里所有地点
updateMapCard(cardEl, newData)编辑已有卡片
setMapShow(el, 'map')卡片 ⇄ 完整地图互切(只改一个 data-show)

上传 upload

方法说明
insertMediaFromFile(file, 'image')上传并插入,第二参 image|video|audio|attachment
uploadFile(file, 'attachment')仅上传,返回 Promise(不插入正文)

生命周期与实例 lifecycle

方法 / 属性说明
save()触发 save 事件 + 写本地草稿,返回 { html, markdown, time }
undo() / redo()撤销 / 重做
pluginList()已挂载插件名数组
isPluginEnabled('map')某插件是否启用
destroy()销毁实例(释放事件与浮层)
Sneditor.get(el)按容器 / 选择器找回实例
Sneditor.instances全部实例表(id → instance)
Sneditor.version当前版本号(1.0.0)
Sneditor.apiBase统一后端前缀,默认 /api/editor
06 / THEMES

10 套内置主题,点一下就能换

全部由 CSS 变量驱动,浮层会跟着换,不会半深半浅。下面的按钮走的是 editor.setTheme(),会同步切换最上面那个演示编辑器。

运行时注册一套自己的主题
Sneditor.registerTheme('deepblue', {
  label: '深海蓝',
  dark: true,                                    // 影响浮层配色
  vars: {
    '--sne-primary': '#2563eb',
    '--sne-radius': '10px',
    '--sne-bg': '#0b1220',
    '--sne-text': '#e6edf7'
  },
  css: '.sne--deepblue .sne-content h1 { color: #9dc0ff; }'   // 可选:额外规则
});
editor.setTheme('deepblue');
备注 工具栏上的「主题」按钮可以当场新建自定义主题(列表超过 6 屏高会滚动), 配置项在 themeCfg —— 因为 options.theme 已被内核占为「当前主题名」。
07 / FAQ

常见问题

集成、二次开发与运维阶段最常撞到的那些坑,按现象列出来。

AI 直连报「Failed to fetch」/ 连不上?

多半是浏览器跨域(CORS)。依次排查:

  • Key 是否填对、是否过期;
  • 该服务商是否允许浏览器直连(部分厂商只允许服务端调用);
  • 有没有被 CSP(connect-src)拦。

两种解法:把 ai.provider 换成 'proxy' 走后端, 或把 ai.baseUrl 指向你自己的转发地址(如 Nginx 反代 /llm/ → 服务商)。 排查前先用 editor.testAiConnection() 确认 Key 本身有效。

AI Key 会被别人看到吗?

会。浏览器直连意味着 Key 存在于前端,任何能打开 DevTools 的人都能看到,只适合内部工具 / 自用。 面向公网的产品请用 provider: 'proxy',把 Key 放在服务端。 勾选「记住」还会写进 localStorage,公共电脑上不要勾。

AI 结果是「啪」一下全出来,不是逐字输出?

响应被缓冲了。依次检查:

  • Nginx 是否 proxy_buffering off / fastcgi_buffering off;
  • PHP 是否 output_buffering = Off;
  • 代码里有没有中途调用缓冲函数;
  • 跨域中间件是否接管了响应体。

直连服务商时不经过这些环节,一般不会出现。

改了 sneditor/src/ 里的代码,页面却没变化?

因为页面引的是打包产物。打包是纯文本拼接(内核在前、插件按固定顺序在后), 改完源码必须跑一次 python tools/build.py 重新生成 sneditor.js / sneditor.css。CI 里可以用 python tools/build.py --check 卡住不一致。

只想显示部分工具栏按钮?

给 toolbar 传自定义分组数组即可,例如 toolbar: [['undo','redo'],['bold','italic'],['ai']]; 也可以在实例化后用 editor.setToolbar([...]) 动态换。 反过来 editor.setToolbar('all') 会把全部命令铺满。 如果只想临时藏一两个,用 editor.setToolbarShows({ image: false }) 更轻。

关掉某个插件后,工具栏按钮还在?

大概率是按钮的 def.plugin 没写对。命令的显隐靠 plugin 字段与插件注册表关联, 自己用 Sneditor.addCommand() 加的命令记得写上 plugin: 'yourPlugin',否则不会跟着插件一起隐藏。

粘贴 Word 内容带一堆脏标签?

内核已内置净化(剥 mso-*、<o:p>、class="Mso*"、on* 事件)。 要极致干净可把 pastePlain 设为 true 强制纯文本粘贴。

编辑器样式被宿主页面的全局样式影响了?

内核已用 .sne-* 前缀 + .sne-content 作用域做 reset 隔离。 若仍异常,检查宿主是否有 p { margin: 0 !important } 这类强制规则 —— !important 是唯一能穿透隔离的写法。

白主题下地图弹窗以前是透明的?

已修复。原因是「经典白」的 CSS 变量原先只声明在 .sne 上,而弹窗是 position:fixed 挂在 document.body 的浮层(带 .sne-layer),取不到变量 → var(--sne-bg) 解析失败 → 整片透明。 现在令牌同时挂在 .sne-layer / .sne--light / [data-theme="light"] 上,浮层也能继承。 写自定义主题时同理:变量要给到层级根上,别只给编辑区。

Markdown 右侧预览改了,为什么左侧没变?

检查 markdown.backSync 是否为 true,以及 backSyncDelay 防抖是否设得过大 (默认 420ms)。另外 previewEditable 必须为 true,否则右侧预览是只读的。

Markdown 分栏里预览太窄 / 源码太窄?

默认是 1:1。拖动两栏中间的分隔条即可调宽,双击分隔条恢复 1:1; 也可以用 editor.setMdSplitRatio(0.35)(源码列占 35%)由代码控制, 比例会被自动夹在 0.15 ~ 0.85,保证两栏都还能用。 窄屏(≤760px)会自动改为上下堆叠。

地图弹窗打开是空白 / 报 key 错误?

高德需在控制台把 Key 配成 Web端(JS API) 类型,并填写安全密钥 securityJsCode; 百度需用浏览器端 AK 且配置 Referer 白名单。 都不配也能用 —— 会自动降级为「手填坐标 + 官方地图链接」模式,功能不报错。

上传成功但图片不显示?

多半是 public/storage 软链没建。执行 php think storage:link, 或在 public/ 下手动建软链指向 storage/app/public。

自定义的装饰节点(角标、按钮)会跟着存库、导出吗?

默认不会 —— 但前提是你登记过。用 Sneditor.registerChrome('.my-overlay') 把选择器登记为「瞬态装饰」,内核在导出 HTML / 计算文本与选区时会跳过它; 如果装饰需要在内容变化后重绘,再挂一个 Sneditor.registerDecorator(fn)。 代码块的语言角标、行号、复制按钮和地图的画布 / 浮层走的都是这套机制。

一个页面能挂多个编辑器实例吗?

可以,每个实例独立持有 options、历史栈与事件总线;实例按 id 登记在 Sneditor.instances 里,用 Sneditor.get(el) 按容器反查。 注意浮层(预览、地图弹窗)默认挂到 document.body,zIndex 基准可用 options.zIndex 调。

某个插件初始化抛错,整个编辑器崩了?

不会。插件是逐个 try/catch 初始化的,失败的那个会被跳过并通过 editor.on('pluginerror', p => …) 派发 { name, error }。 收到后把插件名与错误上报即可,其余功能照常可用。

还有别的 没有找到答案?把现象(报错文本 + editor.resolveAiEngine() 或 editor.pluginList() 的输出)一起提出来,基本上一眼能定位。仓库里的 tools/verify.js 有 367 项 DOM 断言,改完先跑它比手动点界面快得多。