Sneditor 是一套自研的轻量富文本编辑器:不依赖任何框架与第三方库,
页面里只引一个 sneditor.css + 一个 sneditor.js。
采用 contenteditable 内核,所有功能以插件挂载,工具栏由命令注册表驱动 ——
关掉插件,按钮自动消失。
<link rel="stylesheet" href="./sneditor/sneditor.css">
<script src="./sneditor/sneditor.js"></script>
<!-- 一个容器就够了 -->
<div id="editor"></div>
下面这个编辑器直接引入了本仓库的 sneditor.css 与 sneditor.js,没有任何构建步骤。
换主题、开 AI 分栏、插代码块、导出 Markdown —— 都是真实调用实例 API。
Sneditor.registerAiProvider('demo', { keyField: '' })
注册的一个「离线演示」服务商,配合 editor.aiRequest 打桩输出模拟流式文本 —— 顺便演示了自定义服务商这个扩展点。
真实项目里把 ai.provider 填成内置的八家之一并配上 Key 即为真实请求;改成 'proxy' 则走后端代理。
内核只管 DOM、历史、选区、模式、工具栏与事件,其余一切 —— Markdown、上传、表格、AI、地图、主题 ——
都是平级插件。每个插件可单独关闭,互不依赖;连轻提示 editor.notify() 都是内核方法。
加粗 / 斜体 / 下划线 / 删除线 / 上标下标、字体颜色与背景色、字号、清除格式;标题 1-6、引用、有序无序列表、对齐、分割线、行内代码。
图片 / 视频 / 音频 / 附件,点击、拖拽、Ctrl+V 粘贴三条路都通,带上传进度与失败提示;可开启外链图片自动转存。
富文本 ⇄ Markdown 双向转换。左右分栏,左侧源码可输入、右侧预览也可输入,两侧实时同步,中间的分隔条可拖拽调宽(双击恢复 1:1)。
编辑器内左右分栏,左侧写作、右侧流式生成;润色 / 续写 / 扩写 / 精简 / 翻译 / 总结 / 排版 / 自定义指令,结果可直接替换、插入或追加。
内置 OpenAI / 智谱 / 讯飞星火 / DeepSeek / Kimi / 通义 / 硅基流动 / 百度千帆八家,都是 OpenAI 兼容协议,一份代码通吃,浏览器直连。
插入代码块时自动识别语言,也可显式指定,共 24 种。语言写进 data-lang 与 class="language-*",导出 Markdown 自动带围栏语言。
文本 / 文件 / 网址三路导入,支持覆盖、追加、插入三种落地方式,自动解析 front matter,并默认做一次安全清洗。
高德 AMap + 百度 BMapGL 选点(POI 搜索 / 地图点选 / 手填坐标),地址与坐标双向解析。插入时可选卡片或完整地图,随时互切、数据不丢。
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 |
✓ |
工具栏由「命令注册表」驱动,按钮随插件启停自动显隐。地图默认只占一个入口(弹窗里切高德 / 百度),
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' 时出现) |
| Markdown | markdown mdsource copymd mdimport | 分栏、纯源码、复制 MD、导入 Markdown |
| AI / 主题 | ai theme | AI 助手侧栏、主题面板 |
| 视图 | preview readonly source fullscreen | 预览、只读预览、HTML 源码、全屏 |
| 清理 | clear | 清除格式 |
本质是 DOM + 事件,任何框架都能包一层。仓库里已经备好了 Vue2 / Vue3 组件,原生 HTML 只需要两行资源引入。
引入两个文件,给一个容器,然后 new Sneditor(el, options)。所有功能都能在初始化时开 / 关、配参数。
<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>
Sneditor.Markdown.markdownToHtml(md) 转回 HTML。
直接用仓库里的 vue3/Sneditor.vue(script setup + defineExpose),
组件内部已 import 那两个静态文件。
<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>
| prop | 类型 | 说明 |
|---|---|---|
modelValue(Vue3) | String | v-model 内容,格式由 model-type 决定 |
modelType | html | markdown | v-model 的格式,默认 html |
theme | String | 主题名,变化时轻量切换(不重建实例) |
height | Number | String | 编辑区高度 |
placeholder | String | 占位提示 |
readonly | Boolean | 只读 |
options | Object | 内核 + 插件配置,深度变化时重建实例 |
ref 调用)options 整体变化才重建实例,theme / readonly / height 变化走轻量更新。
vue2/Sneditor.vue 用 Options API 写成,Vue 2.6 / 2.7 通用。props 与 Vue3 版一致,
区别是 v-model 走 value 属性 + input 事件,生命周期用 beforeDestroy。
<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>
setEditorContent(value, format),
因为 setContent 在 Options API 的 methods 里会和编辑器实例方法同名,容易误用。
仓库里没带现成的 React 组件,但它本来就不需要 —— 编辑器是纯 DOM + 事件,
用 useRef 拿容器、useEffect 建销实例即可,二十行搞定。
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();
Sneditor.apiBase = '/api/editor';
new Sneditor('#editor', {
ai: {
provider: 'proxy', // 密钥留在服务端,最安全
endpoint: '', // 留空 = Sneditor.apiBase + '/ai'
headers: { 'X-CSRF-Token': getToken() }, // 自定义请求头
stream: true
}
});
| provider | 名称 | 默认地址 | 默认模型 | Key 从哪拿 |
|---|---|---|---|---|
openai | OpenAI | https://api.openai.com/v1 | gpt-4o-mini | platform.openai.com |
zhipu | 智谱 GLM | https://open.bigmodel.cn/api/paas/v4 | glm-4-flash(免费) | open.bigmodel.cn |
spark | 讯飞星火 | https://spark-api-open.xf-yun.com/v1 | lite(免费) | 控制台的 APIPassword |
deepseek | DeepSeek | https://api.deepseek.com/v1 | deepseek-chat | platform.deepseek.com |
moonshot | 月之暗面 Kimi | https://api.moonshot.cn/v1 | moonshot-v1-8k | platform.moonshot.cn |
qwen | 通义千问 | https://dashscope.aliyuncs.com/compatible-mode/v1 | qwen-turbo | 阿里云百炼控制台 |
siliconflow | 硅基流动 | https://api.siliconflow.cn/v1 | Qwen/Qwen2.5-7B-Instruct | cloud.siliconflow.cn |
baidu | 百度千帆 | https://qianfan.baidubce.com/v2 | ernie-speed-8k | 千帆控制台 |
proxy | 自建后端代理 | Sneditor.apiBase + '/ai' | 由后端决定 | 不需要前端 Key |
provider: 'proxy';勾选「记住」还会写进 localStorage,公共电脑上不要勾。
只有一个接口是必选的 —— 上传。AI 只有选「自建代理」时才需要后端。统一前缀由
Sneditor.apiBase 指定(默认 /api/editor)。
# 请求
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。
provider: 'proxy' 时需要)# 请求
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]
backend/thinkphp8/:
Editor.php(上传)、EditorAi.php(AI 流式代理)、route.php、config.php。
其它语言按上面两个协议实现即可,一共不到两个接口。
public/storage 软链没建 ——
执行 php think storage:link,或手动在 public/ 下建软链指向 storage/app/public。
源码在 sneditor/src/ 下按「内核 + 各插件」分文件维护,改完用
python tools/build.py 拼成一个 css + 一个 js。所有扩展点都是静态方法,注册即生效。
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/ 里任何一个文件都必须重新打包,否则页面用的还是旧产物。
拼接顺序只影响「谁的默认值先注册」,不影响功能是否可用。
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) { /* … */ } });
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());
| 需求 | 做法 |
|---|---|
| 注册插件 | 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 集中定义,改这一处即可 |
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' } });
// 主题:只给 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'
统一用 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 |
| AI | aistart(含 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); });
实例方法与静态方法一览。输入关键词可以即时过滤,例如 markdown、ai、set。
| 方法 | 返回 | 说明 |
|---|---|---|
getHtml() | string | 富文本 HTML |
getMarkdown() | string | Markdown 源码 |
getText() | string | 纯文本 |
getContent('json') | object | { html, text, markdown };参数传 'markdown' / 'html' 则只回对应字符串 |
getWordCount() | object | { chars, words } |
getDraft() | object | { html, markdown, time }(自动草稿) |
| 方法 | 说明 |
|---|---|
setHtml(html) | 整体写入 HTML(会走一次净化) |
setMarkdown(md) | 覆盖全文(Markdown) |
setContent(value, 'markdown') | 按格式写入,第二参 'html' | 'markdown' |
insertHtml('<p>x</p>') | 在光标处插入 HTML |
insertMarkdown('## 标题') | Markdown 片段 → 富文本插入 |
insertText('文本') | 插入纯文本 |
clear() | 清空内容 |
| 方法 | 说明 |
|---|---|
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() | 打开可视化导入面板(文件 / 粘贴 / 网址三路) |
| 方法 / 属性 | 说明 |
|---|---|
insertCodeBlock({ lang: 'auto' }) | 按当前选区自动识别语言;也可显式写 'python',或连 code 一起给 |
getCodeLang(el?) | 读光标所在代码块的语言(也可传 pre 元素) |
setCodeLang('rust') | 换语言;'plaintext' = 去掉语言 |
Sneditor.detectLang(code) | 纯函数:识别一段代码的语言 |
Sneditor.CODE_LANGS | 语言表(24 种,含别名与识别正则) |
Sneditor.registerLang(name, def) | 注册自定义语言 |
| 方法 | 说明 |
|---|---|
placeCaretIn(el) | 把光标放到该节点内部的开头(表格重建用的就是它) |
placeCaretIn(el, true) | 放到末尾(插入块级节点后用这个方向) |
placeCaretAtEnd() | 放到正文最后 |
saveSelection() / restoreSelection() | 手动存取选区(AI 生成前保位就是这套) |
focus() / blur() | 聚焦 / 失焦 |
| 方法 | 说明 |
|---|---|
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) |
| 方法 | 说明 |
|---|---|
setTheme('starry') | 按名字切换(10 套内置 + 自定义) |
getTheme() | 当前主题名 |
nextTheme() / cycleTheme() | 按注册表顺序切换 |
Sneditor.listThemes() | 列出全部主题(含 label / dark / swatch) |
Sneditor.registerTheme(name, def) | 运行时注册自定义主题 |
| 方法 | 说明 |
|---|---|
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() | 中断当前生成 |
| 方法 | 说明 |
|---|---|
openMapDialog({ provider }) | 打开选点弹窗 |
insertMapLocation({ provider, lng, lat, name, address, show }) | 插入地点,show 取 'card' | 'map' |
getMapLocations() | 读出内容里所有地点 |
updateMapCard(cardEl, newData) | 编辑已有卡片 |
setMapShow(el, 'map') | 卡片 ⇄ 完整地图互切(只改一个 data-show) |
| 方法 | 说明 |
|---|---|
insertMediaFromFile(file, 'image') | 上传并插入,第二参 image|video|audio|attachment |
uploadFile(file, 'attachment') | 仅上传,返回 Promise(不插入正文) |
| 方法 / 属性 | 说明 |
|---|---|
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 |
全部由 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');
themeCfg —— 因为 options.theme 已被内核占为「当前主题名」。
集成、二次开发与运维阶段最常撞到的那些坑,按现象列出来。
多半是浏览器跨域(CORS)。依次排查:
connect-src)拦。两种解法:把 ai.provider 换成 'proxy' 走后端,
或把 ai.baseUrl 指向你自己的转发地址(如 Nginx 反代 /llm/ → 服务商)。
排查前先用 editor.testAiConnection() 确认 Key 本身有效。
会。浏览器直连意味着 Key 存在于前端,任何能打开 DevTools 的人都能看到,只适合内部工具 / 自用。
面向公网的产品请用 provider: 'proxy',把 Key 放在服务端。
勾选「记住」还会写进 localStorage,公共电脑上不要勾。
响应被缓冲了。依次检查:
proxy_buffering off / fastcgi_buffering off;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',否则不会跟着插件一起隐藏。
内核已内置净化(剥 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.backSync 是否为 true,以及 backSyncDelay 防抖是否设得过大
(默认 420ms)。另外 previewEditable 必须为 true,否则右侧预览是只读的。
默认是 1:1。拖动两栏中间的分隔条即可调宽,双击分隔条恢复 1:1;
也可以用 editor.setMdSplitRatio(0.35)(源码列占 35%)由代码控制,
比例会被自动夹在 0.15 ~ 0.85,保证两栏都还能用。
窄屏(≤760px)会自动改为上下堆叠。
高德需在控制台把 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 断言,改完先跑它比手动点界面快得多。