Minditor 是一个既注重最终用户编辑体验,又注重开发者体验的块级富文本编辑器。它有以下特点:
- 已经提供了例如图片上传、markdown 命令等重要的组件和 Plugin,并将由官方持续完善,目标默认就提供达到专业编辑工具的用户体验。
- 支持 InlineBlock,可在行内插入。
- 可以用任何框架的组件来实现编辑器的 Block/InlineBlock。
- 除了常见的事件和 API 外, Plugin 系统还为开发者默认提供一系列重要的 reactive state,让开发 Plugin 变得无比简单。
我们的官网首页 就是用 Minditor 编写和渲染的,你可以在首页上体验所有的功能,快去看看吧。
默认支持右键菜单和快捷键进行复制、粘贴、剪切、撤回、重做。
Minditor 默认提供了以下命令来实现快速编辑:
- 输入
# + 空格
来插入 Heading。# 的数量决定了 Heading 的层级 - 输入
1. + 空格
来在 Heading 中插入自动序号。 - 在段首输入
- + 空格
插入无序列表。 - 在段首落输入
1. + 空格
插入有序列表。 - 输入
+ 加任意文字 +
插入 Inline Code。 - 在段首输入
``` + 语言 + 空格
插入代码编辑器。
鼠标划词会出现悬浮工具,可以修改字体样式或者创建链接。
输入 /
会出现下拉框,提供可插入的组件。如果在空行头部输入 /
,会同时出现可插入的 Block 和 InlineBlock。
鼠标移动到任意 Block,左侧会出现悬浮工具,默认提供删除、复制、黏贴和可插入 Block 等工具。
支持在文档外部显示由 Heading 构成的大纲。如本文档左侧所示。
Minditor 的图片组件允许用户将图片上传到指定接口或者 aws3 bucket,也可以将图片使用 URL.createObjectURL 编码后直接保存到文档中。Minditor 中已经默认提供了将图片编码保存到文档中的组件 InlineImageBlock。
你也可以自己使用 createImageBlock 传入相应参数来创建将图片上传到指定地址的组件:import {createImageBlock} from 'minditor'
// @params
// - type: 'xhr'|'aws'
// - config: Uppy.XHRConfig|Uppy.AWS3Config
const ImageBlock = createImageBlock(
'xhr',
{endpoint: 'https://your-domain.com/upload'}
)
Image Block 中使用了 Uppy 作为上传的工具。更多地配置可以参考 https://uppy.io/docs/xhr-upload/ 。
Grid 组件的单元格中支持所有的文档能力,相当于一个内嵌的小文档。可以通过左侧的 Block Tool 在空段落处插入 Grid。也可以在段首输入 /
后 Insert Suggestion Plugin 中获得。Grid 支持的功能:
我们已经在开发一款更强大的 spreadsheet 工具,包含更强大的布局和数据处理能力,未来也会作为组件嵌入到 Minditor 中,所以当前的 Grid 没有太多的增强计划。
Minditor 使用了 CodeMirror 6 作为 Code 组件。默认支持以下语言:
• javascript (alias js/jsx) • typescript (alias ts/tsx) • python • php • cpp • java • sql • json • rust • css • html • sass • less • xml • yaml
你可以通过在段首输入 ``` + 语言 + 空格
来插入指定语言的 Code Block。也可以通过 Block Tool 或者 Suggession Tool 插入。
npm i minditor
Minditor 本身的可以渲染在任意的 dom 节点中。对外没有任何的布局、样式等要求. 如果你只是用来渲染最简单的文字。可以直接使用下面的方式来渲染:
import {
Code,
Grid,
Heading,
InlineImageBlock,
InlineCode,
Link,
OLItem,
Paragraph,
Text,
ULItem,
InlineImageBlock
} from "minditor";
const rootElement = document.getElementById('root')!
const types = {
Paragraph,
Text,
Heading,
OLItem,
ULItem,
InlineCode,
Code,
Link,
Grid,
Image: InlineImageBlock
}
const data = {
name: 'MyDoc',
children: [{
type: 'Paragraph',
content: [{type:'Text', value: 'This is my first document.'}]
}]
}
const doc = new Document(rootElement, data, types)
doc.render()
对于需要完整 Plugin,希望视图匡高自动适应外部 container 的场景,我们推荐使用 scaffold 来创建视图:
import {
Code,
createBlockTool,
createRangeTool,
createSuggestionTool,
defaultBlockWidgets,
defaultFormatWidgets,
defaultMarkdownPlugins,
defaultSuggestionWidgets,
Grid,
Heading,
InlineImageBlock,
InlineCode,
Link,
OLItem,
Paragraph,
scaffold,
Text,
ULItem,
createTOCTool
} from "minditor";
const root= document.getElementById('root')!
const types = {
Paragraph,
Text,
Heading,
OLItem,
ULItem,
InlineCode,
Code,
Link,
Grid,
Image: InlineImageBlock
}
const plugins = [
...defaultMarkdownPlugins,
createBlockTool(defaultBlockWidgets),
createRangeTool( defaultFormatWidgets ),
createSuggestionTool(defaultSuggestionWidgets),
createTOCTool()
]
const result = scaffold(root, {data: jsonData, types, plugins}, { debug: true })
result.render()
Inline可以嵌入到行内,开发一个 InlineBlock 非常简单,我们以 InlineCode 为例:
export class InlineCode extends InlineComponent {
static displayName = 'InlineCode'
constructor(public data: InlineCodeData) {
super();
}
render() {
return <span style={{display:'inline-block', background:'#eee', padding:'4px 8px'}}>{this.data.value}</span>
}
toText() {
return this.data.value
}
}
通常有了 InlineBlock 之后,我们还需要为用户提供使用 InlineBlock 的方法。有两种方式:
- 在 Minditor 的 SuggestionTool 中出一个 Widget,当用户输入 / 时将展开下拉框看见这个 Block,并能点击使用。
- 实现一个 Plugin,监听用户的特殊输入,当输入满足条件时插入 InlineBlock。类似于 markdown。
我们在这里以添加字体颜色和背景色的 Widget 为例来看如何创建一个 Range Tool 的 Widget:
class ColorWidget extends RangeWidget {
static displayName = `ColorRangeWidget`
useColor = (color: string) => {
this.document.view.formatCurrentRange({color})
}
useBackgroundColor = (backgroundColor: string) => {
this.document.view.formatCurrentRange({backgroundColor})
}
render() {
const hover = atom(false)
const pickerStyle = () => {
return ({
display: hover() ? 'block' : 'none',
position: 'absolute',
top: '100%',
left: 0,
transform: 'translateX(-50%)',
})
}
const picker = (
<div style={pickerStyle}>
<ColorPicker onColorClick={this.useColor} onBackgroundColorClick={this.useBackgroundColor}/>
</div>
)
return (
<div style={{display:'flex', position:'relative', width:24, height:24, alignItems: 'center',justifyContent: 'center'}}
onmouseenter={() => hover(true)}
onmouseleave={() => hover(false)}
>
<span style={{cursor: 'pointer',marginLeft:8, fontSize:18}}>A</span>
{picker}
</div>
)
}
}
有了这个 widget 之后,只要在 createRangeTool 的地方传入它即可使用:
const plugins = [
//... other plugins
createRangeTool( [...defaultFormatWidgets, ColorWidget] ),
]
关于 Plugin 的开发下面有更详细的例子。
开发一个 Block Compnent 的方式与 Inline Component 基本相同。只是最后在提供给用户使用时,我们是将其注册到 BlockTool 或 Insert Suggession Tool 中供用户使用。注意 BlockTool 中的 Widget 和 Insert Suggession Tool 的 Widget 是通用的。为了进一步降低 Widget 的开发难度,minditor 还提供了 createSuggestionWidget 函数来帮助快速开发 widget,我们这里以插入 Code 的 Widget 为例:
type CommonInsertHandleProps = {
insert: (initialData: InlineData|BlockData) => void
}
function CodeInsertHandle({insert}: CommonInsertHandleProps) {
const onGridChange = (lang: string) => {
insert({type: 'Code', language:lang, value: '', content: []})
activated(false)
}
const activated = atom(false)
const languages = Object.keys(CodeBlock.langToPlugin)
return (
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
cursor: 'pointer',
width: '100%'
}}
onmouseenter={() => activated(true)}
onmouseleave={() => activated(false)}
>
<Code size={18}/>
<span style={{marginLeft: 8, fontSize: 14, whiteSpace:'nowrap'}}>+ Code</span>
<Right size={18}/>
<div style={() => ({
display: activated() ? 'block' : 'none',
position: 'absolute',
left: 'calc(100% - 8px)',
top: 0,
transform: 'translateY(-50%)',
paddingLeft: 18,
background: 'transparent',
})}
>
<div style={{
border: '1px solid #eee',
background: '#fff',
boxShadow: '2px 2px 5px #dedede',
maxHeight: 400,
overflowY: 'auto'
}}
>
<CodeLanguagePicker onChange={onGridChange} languages={languages}/>
</div>
</div>
</div>
)
}
const CodeBlockWidget = createSuggestionWidget(CodeInsertHandle, 'Code', true)
我们以一个 Calendar 组件来演示如何使用其他框架来开发 Block.
import { Component } from 'minditor'
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
class Calendar extends Component {
static displayName = 'Calendar'
render() {
const container = document.createElement('div')
container.contentEditable = 'false'
const root = createRoot(container);
root.render(createElement(DayPicker, {mode: 'single'}));
return container
}
}
Minditor 中的 Plugin 拥有对整个 document 进行控制的能力,能实现几乎任何需求。它能实现根据用户的输入执行一次性的命令,也可以有自己的独立界面来支持更复杂的操作。
我们以 markdown 中添加 Heading 的能力为例来看如何开发 Plugin。
class Plugin extends Plugin{
public static displayName = `createHeading`
public static activateEvents = {
inputChar: onInputKey(' ')
}
run({} : PluginRunArgv) : boolean | undefined{
const initialCharacters = '#'
const { view, history } = this.document
const startRange = view.state.selectionRange()
const { startText, startBlock, isEndFull,isCollapsed, endText } = startRange!
// 1. Headings can only be generated within the content of a Para.
if (!(startBlock instanceof Paragraph || startBlock instanceof Heading)) return false
// 2. Can only at Para head.
if (startBlock.firstChild !== startText) return false
// 3. Text in startText should match #
if (startText.data.value.slice(0, initialCharacters.length) !== initialCharacters) return false
// 4. delete last space
const textToMatch = startText.data.value.slice(0, startText.data.value.length - 1)
const matchedText = reversMatchStr(textToMatch, initialCharacters)
if (matchedText === false) return false
// Start replacing
history.openPacket(startRange)
// 1. Delete initialCharacters and space in startText
const newTextAfterCursor = startText.data.value.slice(initialCharacters.length + 1)
view.updateText(newTextAfterCursor, startText)
// 2. Get all text in the middle
const titleTextFrag = view.deleteBetween(startText, null, startBlock)
// 3. Replace with new Heading block
const newBlock = createBlock.call(this, titleTextFrag)
view.replace(newBlock, startBlock, this.document.content)
view.setCursor(newBlock, 0)
const endRange = new DocRange(newBlock, newBlock.firstChild!, 0, newBlock, newBlock.firstChild!, 0, )
history.closePacket(endRange)
return true
}
}
Plugin 使用了一个轻量级、响应式的前端框架 Axii 来渲染 UI。开发者只需要简单记住以下基础用法即可:
- 需要相应的数据使用
atom()
包裹起来,用函数调用的形式取值赋值。 - 使用
computed
来包装计算数据。 - 需要保持 reactive 的 dom 结构/属性 使用函数来表示,无需
computed
包装。
我们以 Range Tool 为例,来看如何使用 Axii 和系统提供的 reactive state 构建响应式界面。
class OneRangeTool extends RangeTool {
public rangeWidgets: RangeWidget[]
constructor(public document: Document) {
super(document);
this.rangeWidgets = RangeWidgets.map(RangeWidgetClass => {
return new RangeWidgetClass(this.document)
})
}
calculatePosition(outsideDocBoundary: boolean) {
const {visibleRangeRect, lastMouseUpPositionAfterRangeChange, selectionRange, hasRange} = this.document.view.state
const positionAttrs = {
position: 'fixed'
} as any
positionAttrs.position = 'fixed'
// Decide modal position based on last mouse position
if (lastMouseUpPositionAfterRangeChange()!.top > (visibleRangeRect.raw!.top + visibleRangeRect.raw!.height / 2)) {
positionAttrs.top = visibleRangeRect.raw!.top + visibleRangeRect.raw!.height
positionAttrs.bottom = undefined
} else {
positionAttrs.top = undefined
positionAttrs.bottom = -(visibleRangeRect.raw!.top)
}
positionAttrs.left = lastMouseUpPositionAfterRangeChange()!.left
// If the plugin UI is in same container with content, use 'absolute' position.
if(!outsideDocBoundary) {
const boundaryRect = this.document.view.getContainerBoundingRect()!
positionAttrs.position = 'absolute'
if (lastMouseUpPositionAfterRangeChange()!.top > (visibleRangeRect.raw!.top + visibleRangeRect.raw!.height / 2)) {
positionAttrs.top -= boundaryRect.top
} else {
positionAttrs.bottom += boundaryRect.top
}
positionAttrs.left -= boundaryRect.left
}
return positionAttrs
}
render(outsideDocBoundary: boolean) {
const style = () => {
const { lastMouseUpPositionAfterRangeChange, hasRange} = this.document.view.state
if (!lastMouseUpPositionAfterRangeChange() || !hasRange()){
return {display: 'none'}
}
const positionAttrs = this.calculatePosition(outsideDocBoundary)
return {
display: 'block',
...positionAttrs,
}
}
return <div style={style} data-testid="rangeTool-container">
<div style={{display:'flex', whiteSpace: 'nowrap'}}>
{() => this.rangeWidgets.map((widget: RangeWidget) => {
return widget.render()
})}
</div>
</div>
}
}
系统默认提供的 Reactive State 如下: system provides the following built-in Reactive State:
- lastActiveDeviceType: 用户最后操作的设备,鼠标或键盘。
- lastMouseUpPositionAfterRangeChange: mouseup 事件最后出现的位置。
- mousePosition:当前鼠标位置。
- selectionRange:表示成 Minditor DocRange 类型的选区对象。
- hasRange:是否有选区。
- rangeBeforeComposition:输入法出现之前的选区。
- lastMouseEnteredBlock:最后触发 mouseenter 的 Block。
- visibleRangeRect:选区的 boundRect 对象。
- bodyViewPortSize:body 可视范围的尺寸。
根据用户的上下文来支持自动补全,为接入 AI Copilot 提供基础。
更多的文件上传和预览类型。
支持可完整排序、筛选等功能的 Data Table。
块级的协同编辑,支持块级锁和单独授权。
我们欢迎用户提出更多 feature request。
Minditor 目前由 Zhenyu Hou 独立开发和维护。你的支持将用于雇佣开发者持续维护本项目和开发新的功能。Minditor 承诺所有支持支出均用于开发开源、免费、供社区所有人使用的功能。 通过 Patreon 支持本项目:https://patreon.com/sskyy 。