Merge branch 'feature/node-views' into main

This commit is contained in:
Philipp Kühn
2020-10-30 16:58:13 +01:00
26 changed files with 504 additions and 122 deletions

View File

@@ -0,0 +1,53 @@
<template>
<div v-if="editor">
<button @click="editor.chain().focus().taskList().run()" :class="{ 'is-active': editor.isActive('task_list') }">
task list
</button>
<editor-content :editor="editor" />
</div>
</template>
<script>
import { Editor } from '@tiptap/core'
import { EditorContent } from '@tiptap/vue'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [
Document(),
Paragraph(),
Text(),
TaskList(),
TaskItem(),
],
content: `
<ul data-type="task_list">
<li data-type="task_item" data-checked="true">A list item</li>
<li data-type="task_item" data-checked="false">And another one</li>
</ul>
`,
})
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>

View File

@@ -9,8 +9,13 @@ npm install @tiptap/extension-image
yarn add @tiptap/extension-image
```
## Settings
| Option | Type | Default | Description |
| ------ | ------- | ------- | ------------------------ |
| inline | boolean | false | Renders the node inline. |
## Source code
[packages/extension-image/](https://github.com/ueberdosis/tiptap-next/blob/main/packages/extension-image/)
## Usage
<demo name="Extensions/Image" highlight="12,30" />
<demo name="Extensions/Image" />

View File

@@ -0,0 +1,31 @@
# TaskList
This extension enables you to use task lists in the editor. They are rendered as `<ul>` HTML tags.
## Installation
::: warning Use with TaskItem
The `TaskList` extension is intended to be used with the [`TaskItem`](/api/extensions/task-item) extension. Make sure to import that one too, otherwise youll get a SyntaxError.
:::
```bash
# With npm
npm install @tiptap/extension-task-list @tiptap/extension-task-item
# Or: With Yarn
yarn add @tiptap/extension-task-list @tiptap/extension-task-item
```
## Settings
| Option | Type | Default | Description |
| ------ | ------ | ------- | -------------------------------------------- |
| class | string | | Add a custom class to the rendered HTML tag. |
## Commands
| Command | Options | Description |
| ----------- | ------- | --------------------- |
| task_list | — | Toggle a task list. |
## Source code
[packages/extension-task-list/](https://github.com/ueberdosis/tiptap-next/blob/main/packages/extension-task-list/)
## Usage
<demo name="Extensions/TaskList" />

View File

@@ -158,6 +158,9 @@
# draft: true
- title: Strike
link: /api/extensions/strike
- title: TaskList
link: /api/extensions/task-list
draft: true
# - title: TableCell
# link: /api/extensions/table-cell
# draft: true

View File

@@ -75,8 +75,6 @@ declare module './Editor' {
@magicMethods
export class Editor extends EventEmitter {
public renderer!: any
private proxy!: Editor
private commandManager!: CommandManager
@@ -265,10 +263,16 @@ export class Editor extends EventEmitter {
],
}),
dispatchTransaction: this.dispatchTransaction.bind(this),
})
// `editor.view` is not yet available at this time.
// Therefore we will add all node views directly afterwards.
this.view.setProps({
nodeViews: this.extensionManager.nodeViews,
})
// store editor in dom element for better testing
// Lets store the editor instance in the DOM element.
// So well have access to it for tests.
const dom = this.view.dom as HTMLElement
dom.editor = this.proxy
}

View File

@@ -1,14 +1,15 @@
import { Plugin } from 'prosemirror-state'
import { keymap } from 'prosemirror-keymap'
// import { Schema, Node as ProsemirrorNode } from 'prosemirror-model'
import { Schema, Node as ProsemirrorNode } from 'prosemirror-model'
import { inputRules } from 'prosemirror-inputrules'
// import { EditorView, Decoration } from 'prosemirror-view'
import { Schema } from 'prosemirror-model'
import { EditorView, Decoration } from 'prosemirror-view'
import { Editor } from './Editor'
// import capitalize from './utils/capitalize'
import { Extensions } from './types'
import { Extensions, NodeViewRenderer } from './types'
import getSchema from './utils/getSchema'
import getSchemaTypeByName from './utils/getSchemaTypeByName'
import splitExtensions from './utils/splitExtensions'
import getAttributesFromExtensions from './utils/getAttributesFromExtensions'
import getRenderedAttributes from './utils/getRenderedAttributes'
export default class ExtensionManager {
@@ -98,36 +99,41 @@ export default class ExtensionManager {
}
get nodeViews() {
// const { renderer: Renderer } = this.editor
const { editor } = this
const { nodeExtensions } = splitExtensions(this.extensions)
const allAttributes = getAttributesFromExtensions(this.extensions)
// if (!Renderer || !Renderer.type) {
// return {}
// }
return Object.fromEntries(nodeExtensions
.filter(extension => !!extension.addNodeView)
.map(extension => {
const extensionAttributes = allAttributes.filter(attribute => attribute.type === extension.name)
const context = {
options: extension.options,
editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
// const prop = `to${capitalize(Renderer.type)}`
const renderer = extension.addNodeView?.bind(context)?.() as NodeViewRenderer
// return collect(this.extensions)
// .where('extensionType', 'node')
// .filter((extension: any) => extension.schema()[prop])
// .map((extension: any) => {
// return (
// node: ProsemirrorNode,
// view: EditorView,
// getPos: (() => number) | boolean,
// decorations: Decoration[],
// ) => {
// return new Renderer(extension.schema()[prop], {
// extension,
// editor: this.editor,
// node,
// getPos,
// decorations,
// })
// }
// })
// .all()
const nodeview = (
node: ProsemirrorNode,
view: EditorView,
getPos: (() => number) | boolean,
decorations: Decoration[],
) => {
const attributes = getRenderedAttributes(node, extensionAttributes)
return {}
return renderer({
editor,
node,
getPos,
decorations,
attributes,
})
}
return [extension.name, nodeview]
}))
}
}

View File

@@ -10,22 +10,22 @@ export interface MarkExtensionSpec<Options = {}, Commands = {}> extends Overwrit
/**
* Inclusive
*/
inclusive?: MarkSpec['inclusive'],
inclusive?: MarkSpec['inclusive'] | ((this: { options: Options }) => MarkSpec['inclusive']),
/**
* Excludes
*/
excludes?: MarkSpec['excludes'],
excludes?: MarkSpec['excludes'] | ((this: { options: Options }) => MarkSpec['excludes']),
/**
* Group
*/
group?: MarkSpec['group'],
group?: MarkSpec['group'] | ((this: { options: Options }) => MarkSpec['group']),
/**
* Spanning
*/
spanning?: MarkSpec['spanning'],
spanning?: MarkSpec['spanning'] | ((this: { options: Options }) => MarkSpec['spanning']),
/**
* Parse HTML

View File

@@ -3,7 +3,7 @@ import {
} from 'prosemirror-model'
import { Plugin } from 'prosemirror-state'
import { ExtensionSpec, defaultExtension } from './Extension'
import { Attributes, Overwrite } from './types'
import { Attributes, NodeViewRenderer, Overwrite } from './types'
import { Editor } from './Editor'
export interface NodeExtensionSpec<Options = {}, Commands = {}> extends Overwrite<ExtensionSpec<Options, Commands>, {
@@ -12,55 +12,60 @@ export interface NodeExtensionSpec<Options = {}, Commands = {}> extends Overwrit
*/
topNode?: boolean,
/**
* List
*/
list?: boolean,
/**
* Content
*/
content?: NodeSpec['content'],
content?: NodeSpec['content'] | ((this: { options: Options }) => NodeSpec['content']),
/**
* Marks
*/
marks?: NodeSpec['marks'],
marks?: NodeSpec['marks'] | ((this: { options: Options }) => NodeSpec['marks']),
/**
* Group
*/
group?: NodeSpec['group'],
group?: NodeSpec['group'] | ((this: { options: Options }) => NodeSpec['group']),
/**
* Inline
*/
inline?: NodeSpec['inline'],
inline?: NodeSpec['inline'] | ((this: { options: Options }) => NodeSpec['inline']),
/**
* Atom
*/
atom?: NodeSpec['atom'],
atom?: NodeSpec['atom'] | ((this: { options: Options }) => NodeSpec['atom']),
/**
* Selectable
*/
selectable?: NodeSpec['selectable'],
selectable?: NodeSpec['selectable'] | ((this: { options: Options }) => NodeSpec['selectable']),
/**
* Draggable
*/
draggable?: NodeSpec['draggable'],
draggable?: NodeSpec['draggable'] | ((this: { options: Options }) => NodeSpec['draggable']),
/**
* Code
*/
code?: NodeSpec['code'],
code?: NodeSpec['code'] | ((this: { options: Options }) => NodeSpec['code']),
/**
* Defining
*/
defining?: NodeSpec['defining'],
defining?: NodeSpec['defining'] | ((this: { options: Options }) => NodeSpec['defining']),
/**
* Isolating
*/
isolating?: NodeSpec['isolating'],
isolating?: NodeSpec['isolating'] | ((this: { options: Options }) => NodeSpec['isolating']),
/**
* Parse HTML
@@ -139,6 +144,11 @@ export interface NodeExtensionSpec<Options = {}, Commands = {}> extends Overwrit
editor: Editor,
type: NodeType,
}) => Plugin[],
/**
* Node View
*/
addNodeView?: (() => NodeViewRenderer) | null,
}> {}
export type NodeExtension = Required<Omit<NodeExtensionSpec, 'defaultOptions'> & {
@@ -153,6 +163,7 @@ const defaultNode: NodeExtension = {
type: 'node',
name: 'node',
topNode: false,
list: false,
content: null,
marks: null,
group: null,
@@ -166,6 +177,7 @@ const defaultNode: NodeExtension = {
parseHTML: () => null,
renderHTML: null,
addAttributes: () => ({}),
addNodeView: null,
}
export function createNode<Options extends {}, Commands extends {}>(config: NodeExtensionSpec<Options, Commands>) {

View File

@@ -1,23 +1,19 @@
import { wrapInList, liftListItem } from 'prosemirror-schema-list'
import { findParentNode } from 'prosemirror-utils'
import { Node, NodeType, Schema } from 'prosemirror-model'
import { NodeType } from 'prosemirror-model'
import { Command } from '../Editor'
import { createExtension } from '../Extension'
import getNodeType from '../utils/getNodeType'
function isList(node: Node, schema: Schema) {
return (node.type === schema.nodes.bullet_list
|| node.type === schema.nodes.ordered_list
|| node.type === schema.nodes.todo_list)
}
import isList from '../utils/isList'
export const ToggleList = createExtension({
addCommands() {
return {
toggleList: (listTypeOrName: string | NodeType, itemTypeOrName: string | NodeType): Command => ({ tr, state, dispatch }) => {
const { extensions } = this.editor.options
const listType = getNodeType(listTypeOrName, state.schema)
const itemType = getNodeType(itemTypeOrName, state.schema)
const { schema, selection } = state
const { selection } = state
const { $from, $to } = selection
const range = $from.blockRange($to)
@@ -25,14 +21,14 @@ export const ToggleList = createExtension({
return false
}
const parentList = findParentNode(node => isList(node, schema))(selection)
const parentList = findParentNode(node => isList(node.type.name, extensions))(selection)
if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) {
if (parentList.node.type === listType) {
return liftListItem(itemType)(state, dispatch)
}
if (isList(parentList.node, schema) && listType.validContent(parentList.node.content)) {
if (isList(parentList.node.type.name, extensions) && listType.validContent(parentList.node.content)) {
tr.setNodeMarkup(parentList.pos, listType)
return false

View File

@@ -9,6 +9,14 @@ const style = `.ProseMirror {
font-variant-ligatures: none;
}
.ProseMirror [contenteditable="false"] {
white-space: normal;
}
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
white-space: pre-wrap;
}
.ProseMirror pre {
white-space: pre-wrap;
}

View File

@@ -1,6 +1,9 @@
import { Node } from 'prosemirror-model'
import { Decoration, NodeView } from 'prosemirror-view'
import { Extension } from './Extension'
import { NodeExtension } from './NodeExtension'
import { MarkExtension } from './MarkExtension'
import Editor from '..'
export type Extensions = (Extension | NodeExtension | MarkExtension)[]
@@ -40,3 +43,13 @@ export type Overwrite<T, U> = Pick<T, Diff<keyof T, keyof U>> & U;
export type AnyObject = {
[key: string]: any
}
export type NodeViewRendererProps = {
editor: Editor,
node: Node,
getPos: (() => number) | boolean,
decorations: Decoration[],
attributes: AnyObject,
}
export type NodeViewRenderer = (props: NodeViewRendererProps) => NodeView

View File

@@ -0,0 +1,17 @@
/**
* Optionally calls `value` as a function.
* Otherwise it is returned directly.
* @param value Function or any value.
* @param context Optional context to bind to function.
*/
export default function callOrReturn(value: any, context?: any) {
if (typeof value === 'function') {
if (context) {
return value.bind(context)()
}
return value()
}
return value
}

View File

@@ -1,8 +1,8 @@
import { Node, Mark } from 'prosemirror-model'
import { ExtensionAttribute } from '../types'
import { ExtensionAttribute, AnyObject } from '../types'
import mergeAttributes from './mergeAttributes'
export default function getRenderedAttributes(nodeOrMark: Node | Mark, extensionAttributes: ExtensionAttribute[]): { [key: string]: any } {
export default function getRenderedAttributes(nodeOrMark: Node | Mark, extensionAttributes: ExtensionAttribute[]): AnyObject {
return extensionAttributes
.filter(item => item.attribute.rendered)
.map(item => {

View File

@@ -5,6 +5,7 @@ import getAttributesFromExtensions from './getAttributesFromExtensions'
import getRenderedAttributes from './getRenderedAttributes'
import isEmptyObject from './isEmptyObject'
import injectExtensionAttributesToParseRule from './injectExtensionAttributesToParseRule'
import callOrReturn from './callOrReturn'
function cleanUpSchemaItem<T>(data: T) {
return Object.fromEntries(Object.entries(data).filter(([key, value]) => {
@@ -25,16 +26,16 @@ export default function getSchema(extensions: Extensions): Schema {
const extensionAttributes = allAttributes.filter(attribute => attribute.type === extension.name)
const context = { options: extension.options }
const schema: NodeSpec = cleanUpSchemaItem({
content: extension.content,
marks: extension.marks,
group: extension.group,
inline: extension.inline,
atom: extension.atom,
selectable: extension.selectable,
draggable: extension.draggable,
code: extension.code,
defining: extension.defining,
isolating: extension.isolating,
content: callOrReturn(extension.content, context),
marks: callOrReturn(extension.marks, context),
group: callOrReturn(extension.group, context),
inline: callOrReturn(extension.inline, context),
atom: callOrReturn(extension.atom, context),
selectable: callOrReturn(extension.selectable, context),
draggable: callOrReturn(extension.draggable, context),
code: callOrReturn(extension.code, context),
defining: callOrReturn(extension.defining, context),
isolating: callOrReturn(extension.isolating, context),
attrs: Object.fromEntries(extensionAttributes.map(extensionAttribute => {
return [extensionAttribute.name, { default: extensionAttribute?.attribute?.default }]
})),
@@ -60,10 +61,10 @@ export default function getSchema(extensions: Extensions): Schema {
const extensionAttributes = allAttributes.filter(attribute => attribute.type === extension.name)
const context = { options: extension.options }
const schema: MarkSpec = cleanUpSchemaItem({
inclusive: extension.inclusive,
excludes: extension.excludes,
group: extension.group,
spanning: extension.spanning,
inclusive: callOrReturn(extension.inclusive, context),
excludes: callOrReturn(extension.excludes, context),
group: callOrReturn(extension.group, context),
spanning: callOrReturn(extension.spanning, context),
attrs: Object.fromEntries(extensionAttributes.map(extensionAttribute => {
return [extensionAttribute.name, { default: extensionAttribute?.attribute?.default }]
})),

View File

@@ -0,0 +1,13 @@
import { Extensions } from '../types'
import splitExtensions from './splitExtensions'
export default function isList(name: string, extensions: Extensions) {
const { nodeExtensions } = splitExtensions(extensions)
const extension = nodeExtensions.find(item => item.name === name)
if (!extension) {
return false
}
return extension.list
}

View File

@@ -1,13 +1,17 @@
import { Command, createNode } from '@tiptap/core'
import { wrappingInputRule } from 'prosemirror-inputrules'
export const inputRegex = /^\s*([-+*])\s$/
const BulletList = createNode({
name: 'bullet_list',
content: 'list_item+',
list: true,
group: 'block',
content: 'list_item+',
parseHTML() {
return [
{ tag: 'ul' },
@@ -34,7 +38,7 @@ const BulletList = createNode({
addInputRules() {
return [
wrappingInputRule(/^\s*([-+*])\s$/, this.type),
wrappingInputRule(inputRegex, this.type),
]
},
})

View File

@@ -1,13 +1,25 @@
import { Command, createNode, nodeInputRule } from '@tiptap/core'
export interface ImageOptions {
inline: boolean,
}
export const inputRegex = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/
const Image = createNode({
name: 'image',
inline: true,
defaultOptions: <ImageOptions>{
inline: false,
},
group: 'inline',
inline() {
return this.options.inline
},
group() {
return this.options.inline ? 'inline' : 'block'
},
draggable: true,

View File

@@ -9,7 +9,9 @@ const ListItem = createNode({
parseHTML() {
return [
{ tag: 'li' },
{
tag: 'li',
},
]
},

View File

@@ -1,13 +1,17 @@
import { Command, createNode } from '@tiptap/core'
import { wrappingInputRule } from 'prosemirror-inputrules'
export const inputRegex = /^(\d+)\.\s$/
const OrderedList = createNode({
name: 'ordered_list',
content: 'list_item+',
list: true,
group: 'block',
content: 'list_item+',
addAttributes() {
return {
start: {
@@ -54,7 +58,7 @@ const OrderedList = createNode({
addInputRules() {
return [
wrappingInputRule(
/^(\d+)\.\s$/,
inputRegex,
this.type,
match => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1],

View File

@@ -0,0 +1,120 @@
import { createNode, mergeAttributes } from '@tiptap/core'
import { wrappingInputRule } from 'prosemirror-inputrules'
export const inputRegex = /^\s*(\[([ |x])\])\s$/
export interface TaskItemOptions {
nested: boolean,
}
const TaskItem = createNode({
name: 'task_item',
content() {
return this.options.nested ? '(paragraph|task_list)+' : 'paragraph+'
},
defining: true,
defaultOptions: <TaskItemOptions>{
nested: false,
},
addAttributes() {
return {
checked: {
default: false,
parseHTML: element => ({
checked: element.getAttribute('data-checked') === 'true',
}),
renderHTML: attributes => ({
'data-checked': attributes.checked,
}),
},
}
},
parseHTML() {
return [
{
tag: 'li[data-type="task_item"]',
priority: 51,
},
]
},
renderHTML({ attributes }) {
return ['li', mergeAttributes(attributes, { 'data-type': 'task_item' }), 0]
},
addKeyboardShortcuts() {
const shortcuts = {
Enter: () => this.editor.splitListItem('task_item'),
}
if (!this.options.nested) {
return shortcuts
}
return {
...shortcuts,
Tab: () => this.editor.sinkListItem('task_item'),
'Shift-Tab': () => this.editor.liftListItem('task_item'),
}
},
addNodeView() {
return ({ attributes, getPos, editor }) => {
const { view } = editor
const listItem = document.createElement('li')
const checkbox = document.createElement('input')
const content = document.createElement('div')
checkbox.type = 'checkbox'
checkbox.addEventListener('change', event => {
const { checked } = event.target as any
if (typeof getPos === 'function') {
view.dispatch(view.state.tr.setNodeMarkup(getPos(), undefined, {
checked,
}))
}
})
if (attributes['data-checked'] === true) {
checkbox.setAttribute('checked', 'checked')
}
listItem.append(checkbox, content)
Object.entries(attributes).forEach(([key, value]) => {
listItem.setAttribute(key, value)
})
return {
dom: listItem,
contentDOM: content,
}
}
},
addInputRules() {
return [
wrappingInputRule(
inputRegex,
this.type,
match => ({
checked: match[match.length - 1] === 'x',
}),
),
]
},
})
export default TaskItem
declare module '@tiptap/core/src/Editor' {
interface AllExtensions {
TaskItem: typeof TaskItem,
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "@tiptap/extension-task-item",
"version": "1.0.0",
"source": "index.ts",
"main": "dist/tiptap-extension-task-item.js",
"umd:main": "dist/tiptap-extension-task-item.umd.js",
"module": "dist/tiptap-extension-task-item.mjs",
"unpkg": "dist/tiptap-extension-task-item.js",
"jsdelivr": "dist/tiptap-extension-task-item.js",
"files": [
"src",
"dist"
],
"peerDependencies": {
"@tiptap/core": "2.x"
}
}

View File

@@ -0,0 +1,40 @@
import { Command, createNode, mergeAttributes } from '@tiptap/core'
const TaskList = createNode({
name: 'task_list',
list: true,
group: 'block',
content: 'task_item+',
parseHTML() {
return [
{
tag: 'ul[data-type="task_list"]',
priority: 51,
},
]
},
renderHTML({ attributes }) {
return ['ul', mergeAttributes(attributes, { 'data-type': 'task_list' }), 0]
},
addCommands() {
return {
taskList: (): Command => ({ commands }) => {
return commands.toggleList('task_list', 'task_item')
},
}
},
})
export default TaskList
declare module '@tiptap/core/src/Editor' {
interface AllExtensions {
TaskList: typeof TaskList,
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "@tiptap/extension-task-list",
"version": "1.0.0",
"source": "index.ts",
"main": "dist/tiptap-extension-task-list.js",
"umd:main": "dist/tiptap-extension-task-list.umd.js",
"module": "dist/tiptap-extension-task-list.mjs",
"unpkg": "dist/tiptap-extension-task-list.js",
"jsdelivr": "dist/tiptap-extension-task-list.js",
"files": [
"src",
"dist"
],
"peerDependencies": {
"@tiptap/core": "2.x"
}
}

View File

@@ -1,9 +1,3 @@
import { Editor as CoreEditor } from '@tiptap/core'
import Renderer from './src/Renderer'
export * from '@tiptap/core'
export { default as VueRenderer } from './src/VueRenderer'
export { default as EditorContent } from './src/components/EditorContent'
export class Editor extends CoreEditor {
renderer = Renderer
}

View File

@@ -1,32 +0,0 @@
import Vue from 'vue'
import { ComponentRenderer } from '@tiptap/core'
export default class Renderer extends ComponentRenderer {
static type = 'vue'
vm!: Vue
constructor(component: Vue) {
super()
this.mount(component)
}
mount(component: Vue) {
const Component = Vue.extend(component)
this.vm = new Component({
// parent: this.parent,
// propsData: props,
}).$mount()
}
get dom() {
return this.vm.$el
}
get contentDOM() {
return this.vm.$refs.content
}
}

View File

@@ -0,0 +1,42 @@
import { NodeViewRendererProps } from '@tiptap/core'
import { NodeView } from 'prosemirror-view'
import Vue from 'vue'
import { VueConstructor } from 'vue/types/umd'
class VueNodeView implements NodeView {
vm!: Vue
constructor(component: Vue | VueConstructor, props: NodeViewRendererProps) {
// const { node, editor, getPos } = props
// const { view } = editor
this.mount(component)
}
mount(component: Vue | VueConstructor) {
const Component = Vue.extend(component)
this.vm = new Component({
// parent: this.parent,
// propsData: props,
}).$mount()
}
get dom() {
return this.vm.$el
}
get contentDOM() {
return this.vm.$refs.content as Element
}
stopEvent() {
return true
}
}
export default function VueRenderer(component: Vue | VueConstructor) {
return (props: NodeViewRendererProps) => new VueNodeView(component, props) as NodeView
}