split helpers and utilities

This commit is contained in:
Philipp Kühn
2020-11-30 09:42:53 +01:00
parent 8d38459289
commit f486ddf80a
56 changed files with 57 additions and 60 deletions

View File

@@ -0,0 +1,11 @@
import { Node } from 'prosemirror-model'
import getSchema from './getSchema'
import getHTMLFromFragment from './getHTMLFromFragment'
import { Extensions } from '../types'
export default function generateHTML(doc: object, extensions: Extensions): string {
const schema = getSchema(extensions)
const contentNode = Node.fromJSON(schema, doc)
return getHTMLFromFragment(contentNode, schema)
}

View File

@@ -0,0 +1,72 @@
import splitExtensions from './splitExtensions'
import {
Extensions,
GlobalAttributes,
Attributes,
Attribute,
ExtensionAttribute,
} from '../types'
/**
* Get a list of all extension attributes defined in `addAttribute` and `addGlobalAttribute`.
* @param extensions List of extensions
*/
export default function getAttributesFromExtensions(extensions: Extensions) {
const extensionAttributes: ExtensionAttribute[] = []
const { nodeExtensions, markExtensions } = splitExtensions(extensions)
const nodeAndMarkExtensions = [...nodeExtensions, ...markExtensions]
const defaultAttribute: Required<Attribute> = {
default: null,
rendered: true,
renderHTML: null,
parseHTML: null,
}
extensions.forEach(extension => {
const context = {
options: extension.options,
}
const globalAttributes = extension.config.addGlobalAttributes.bind(context)() as GlobalAttributes
globalAttributes.forEach(globalAttribute => {
globalAttribute.types.forEach(type => {
Object
.entries(globalAttribute.attributes)
.forEach(([name, attribute]) => {
extensionAttributes.push({
type,
name,
attribute: {
...defaultAttribute,
...attribute,
},
})
})
})
})
})
nodeAndMarkExtensions.forEach(extension => {
const context = {
options: extension.options,
}
const attributes = extension.config.addAttributes.bind(context)() as Attributes
Object
.entries(attributes)
.forEach(([name, attribute]) => {
extensionAttributes.push({
type: extension.config.name,
name,
attribute: {
...defaultAttribute,
...attribute,
},
})
})
})
return extensionAttributes
}

View File

@@ -0,0 +1,13 @@
import { Node, DOMSerializer, Schema } from 'prosemirror-model'
export default function getHTMLFromFragment(doc: Node, schema: Schema): string {
const fragment = DOMSerializer
.fromSchema(schema)
.serializeFragment(doc.content)
const temporaryDocument = document.implementation.createHTMLDocument()
const container = temporaryDocument.createElement('div')
container.appendChild(fragment)
return container.innerHTML
}

View File

@@ -0,0 +1,25 @@
import { EditorState } from 'prosemirror-state'
import { Mark, MarkType } from 'prosemirror-model'
import getMarkType from './getMarkType'
export default function getMarkAttributes(state: EditorState, typeOrName: string | MarkType) {
const type = getMarkType(typeOrName, state.schema)
const { from, to, empty } = state.selection
let marks: Mark[] = []
if (empty) {
marks = state.selection.$head.marks()
} else {
state.doc.nodesBetween(from, to, node => {
marks = [...marks, ...node.marks]
})
}
const mark = marks.find(markItem => markItem.type.name === type.name)
if (mark) {
return { ...mark.attrs }
}
return {}
}

View File

@@ -0,0 +1,44 @@
import { MarkType, ResolvedPos } from 'prosemirror-model'
interface Range {
from: number,
to: number,
}
export default function getMarkRange($pos: ResolvedPos, type: MarkType): Range | void {
if (!$pos || !type) {
return
}
const start = $pos.parent.childAfter($pos.parentOffset)
if (!start.node) {
return
}
const link = start.node.marks.find(mark => mark.type === type)
if (!link) {
return
}
let startIndex = $pos.index()
let startPos = $pos.start() + start.offset
let endIndex = startIndex + 1
let endPos = startPos + start.node.nodeSize
while (startIndex > 0 && link.isInSet($pos.parent.child(startIndex - 1).marks)) {
startIndex -= 1
startPos -= $pos.parent.child(startIndex).nodeSize
}
while (endIndex < $pos.parent.childCount && link.isInSet($pos.parent.child(endIndex).marks)) {
endPos += $pos.parent.child(endIndex).nodeSize
endIndex += 1
}
return {
from: startPos,
to: endPos,
}
}

View File

@@ -0,0 +1,9 @@
import { MarkType, Schema } from 'prosemirror-model'
export default function getMarkType(nameOrType: string | MarkType, schema: Schema): MarkType {
if (typeof nameOrType === 'string') {
return schema.marks[nameOrType]
}
return nameOrType
}

View File

@@ -0,0 +1,23 @@
import { EditorState } from 'prosemirror-state'
import { Node, NodeType } from 'prosemirror-model'
import getNodeType from './getNodeType'
export default function getNodeAttributes(state: EditorState, typeOrName: string | NodeType) {
const type = getNodeType(typeOrName, state.schema)
const { from, to } = state.selection
let nodes: Node[] = []
state.doc.nodesBetween(from, to, node => {
nodes = [...nodes, node]
})
const node = nodes
.reverse()
.find(nodeItem => nodeItem.type.name === type.name)
if (node) {
return { ...node.attrs }
}
return {}
}

View File

@@ -0,0 +1,9 @@
import { NodeType, Schema } from 'prosemirror-model'
export default function getNodeType(nameOrType: string | NodeType, schema: Schema): NodeType {
if (typeof nameOrType === 'string') {
return schema.nodes[nameOrType]
}
return nameOrType
}

View File

@@ -0,0 +1,20 @@
import { Node, Mark } from 'prosemirror-model'
import { ExtensionAttribute, AnyObject } from '../types'
import mergeAttributes from '../utilities/mergeAttributes'
export default function getRenderedAttributes(nodeOrMark: Node | Mark, extensionAttributes: ExtensionAttribute[]): AnyObject {
return extensionAttributes
.filter(item => item.attribute.rendered)
.map(item => {
if (!item.attribute.renderHTML) {
return {
[item.name]: nodeOrMark.attrs[item.name],
}
}
return item.attribute.renderHTML(nodeOrMark.attrs) || {}
})
.reduce((attributes, attribute) => {
return mergeAttributes(attributes, attribute)
}, {})
}

View File

@@ -0,0 +1,94 @@
import { NodeSpec, MarkSpec, Schema } from 'prosemirror-model'
import { Extensions } from '../types'
import splitExtensions from './splitExtensions'
import getAttributesFromExtensions from './getAttributesFromExtensions'
import getRenderedAttributes from './getRenderedAttributes'
import isEmptyObject from '../utilities/isEmptyObject'
import injectExtensionAttributesToParseRule from './injectExtensionAttributesToParseRule'
import callOrReturn from '../utilities/callOrReturn'
function cleanUpSchemaItem<T>(data: T) {
return Object.fromEntries(Object.entries(data).filter(([key, value]) => {
if (key === 'attrs' && isEmptyObject(value)) {
return false
}
return value !== null && value !== undefined
})) as T
}
export default function getSchema(extensions: Extensions): Schema {
const allAttributes = getAttributesFromExtensions(extensions)
const { nodeExtensions, markExtensions } = splitExtensions(extensions)
const topNode = nodeExtensions.find(extension => extension.config.topNode)?.config.name
const nodes = Object.fromEntries(nodeExtensions.map(extension => {
const extensionAttributes = allAttributes.filter(attribute => attribute.type === extension.config.name)
const context = { options: extension.options }
const schema: NodeSpec = cleanUpSchemaItem({
content: callOrReturn(extension.config.content, context),
marks: callOrReturn(extension.config.marks, context),
group: callOrReturn(extension.config.group, context),
inline: callOrReturn(extension.config.inline, context),
atom: callOrReturn(extension.config.atom, context),
selectable: callOrReturn(extension.config.selectable, context),
draggable: callOrReturn(extension.config.draggable, context),
code: callOrReturn(extension.config.code, context),
defining: callOrReturn(extension.config.defining, context),
isolating: callOrReturn(extension.config.isolating, context),
attrs: Object.fromEntries(extensionAttributes.map(extensionAttribute => {
return [extensionAttribute.name, { default: extensionAttribute?.attribute?.default }]
})),
})
if (extension.config.parseHTML) {
schema.parseDOM = extension.config.parseHTML
.bind(context)()
?.map(parseRule => injectExtensionAttributesToParseRule(parseRule, extensionAttributes))
}
if (extension.config.renderHTML) {
schema.toDOM = node => (extension.config.renderHTML as Function)?.bind(context)({
node,
HTMLAttributes: getRenderedAttributes(node, extensionAttributes),
})
}
return [extension.config.name, schema]
}))
const marks = Object.fromEntries(markExtensions.map(extension => {
const extensionAttributes = allAttributes.filter(attribute => attribute.type === extension.config.name)
const context = { options: extension.options }
const schema: MarkSpec = cleanUpSchemaItem({
inclusive: callOrReturn(extension.config.inclusive, context),
excludes: callOrReturn(extension.config.excludes, context),
group: callOrReturn(extension.config.group, context),
spanning: callOrReturn(extension.config.spanning, context),
attrs: Object.fromEntries(extensionAttributes.map(extensionAttribute => {
return [extensionAttribute.name, { default: extensionAttribute?.attribute?.default }]
})),
})
if (extension.config.parseHTML) {
schema.parseDOM = extension.config.parseHTML
.bind(context)()
?.map(parseRule => injectExtensionAttributesToParseRule(parseRule, extensionAttributes))
}
if (extension.config.renderHTML) {
schema.toDOM = mark => (extension.config.renderHTML as Function)?.bind(context)({
mark,
HTMLAttributes: getRenderedAttributes(mark, extensionAttributes),
})
}
return [extension.config.name, schema]
}))
return new Schema({
topNode,
nodes,
marks,
})
}

View File

@@ -0,0 +1,13 @@
import { Schema } from 'prosemirror-model'
export default function getSchemaTypeByName(name: string, schema: Schema) {
if (schema.nodes[name]) {
return schema.nodes[name]
}
if (schema.marks[name]) {
return schema.marks[name]
}
return null
}

View File

@@ -0,0 +1,13 @@
import { Schema } from 'prosemirror-model'
export default function getSchemaTypeNameByName(name: string, schema: Schema) {
if (schema.nodes[name]) {
return 'node'
}
if (schema.marks[name]) {
return 'mark'
}
return null
}

View File

@@ -0,0 +1,48 @@
import { ParseRule } from 'prosemirror-model'
import { ExtensionAttribute } from '../types'
import fromString from '../utilities/fromString'
/**
* This function merges extension attributes into parserule attributes (`attrs` or `getAttrs`).
* Cancels when `getAttrs` returned `false`.
* @param parseRule ProseMirror ParseRule
* @param extensionAttributes List of attributes to inject
*/
export default function injectExtensionAttributesToParseRule(parseRule: ParseRule, extensionAttributes: ExtensionAttribute[]): ParseRule {
if (parseRule.style) {
return parseRule
}
return {
...parseRule,
getAttrs: node => {
const oldAttributes = parseRule.getAttrs
? parseRule.getAttrs(node)
: parseRule.attrs
if (oldAttributes === false) {
return false
}
const newAttributes = extensionAttributes
.filter(item => item.attribute.rendered)
.reduce((items, item) => {
const attributes = item.attribute.parseHTML
? item.attribute.parseHTML(node as HTMLElement) || {}
: {
[item.name]: fromString((node as HTMLElement).getAttribute(item.name)),
}
const filteredAttributes = Object.fromEntries(Object.entries(attributes)
.filter(([, value]) => value !== undefined && value !== null))
return {
...items,
...filteredAttributes,
}
}, {})
return { ...oldAttributes, ...newAttributes }
},
}
}

View File

@@ -0,0 +1,45 @@
import { EditorState } from 'prosemirror-state'
import { Node, Mark } from 'prosemirror-model'
import nodeIsActive from './nodeIsActive'
import markIsActive from './markIsActive'
import objectIncludes from '../utilities/objectIncludes'
import getSchemaTypeNameByName from './getSchemaTypeNameByName'
export default function isActive(state: EditorState, name: string | null, attributes: { [key: string ]: any } = {}): boolean {
if (name) {
const schemaType = getSchemaTypeNameByName(name, state.schema)
if (schemaType === 'node') {
return nodeIsActive(state, state.schema.nodes[name], attributes)
} if (schemaType === 'mark') {
return markIsActive(state, state.schema.marks[name], attributes)
}
}
if (!name) {
const { from, to, empty } = state.selection
let nodes: Node[] = []
let marks: Mark[] = []
if (empty) {
marks = state.selection.$head.marks()
}
state.doc.nodesBetween(from, to, node => {
nodes = [...nodes, node]
if (!empty) {
marks = [...marks, ...node.marks]
}
})
const anyNodeWithAttributes = nodes.find(node => objectIncludes(node.attrs, attributes))
const anyMarkWithAttributes = marks.find(mark => objectIncludes(mark.attrs, attributes))
if (anyNodeWithAttributes || anyMarkWithAttributes) {
return true
}
}
return false
}

View File

@@ -0,0 +1,20 @@
import { Extensions } from '../types'
import splitExtensions from './splitExtensions'
import callOrReturn from '../utilities/callOrReturn'
export default function isList(name: string, extensions: Extensions) {
const { nodeExtensions } = splitExtensions(extensions)
const extension = nodeExtensions.find(item => item.config.name === name)
if (!extension) {
return false
}
const groups = callOrReturn(extension.config.group, { options: extension.options })
if (typeof groups !== 'string') {
return false
}
return groups.split(' ').includes('list')
}

View File

@@ -0,0 +1,15 @@
import { EditorState } from 'prosemirror-state'
import { MarkType } from 'prosemirror-model'
import getMarkAttributes from './getMarkAttributes'
import isEmptyObject from '../utilities/isEmptyObject'
import objectIncludes from '../utilities/objectIncludes'
export default function markHasAttributes(state: EditorState, type: MarkType, attributes: {}) {
if (isEmptyObject(attributes)) {
return true
}
const originalAttributes = getMarkAttributes(state, type)
return objectIncludes(originalAttributes, attributes)
}

View File

@@ -0,0 +1,20 @@
import { EditorState } from 'prosemirror-state'
import { MarkType } from 'prosemirror-model'
import markHasAttributes from './markHasAttributes'
export default function markIsActive(state: EditorState, type: MarkType, attributes = {}) {
const {
from,
$from,
to,
empty,
} = state.selection
const hasMark = empty
? !!(type.isInSet(state.storedMarks || $from.marks()))
: state.doc.rangeHasMark(from, to, type)
const hasAttributes = markHasAttributes(state, type, attributes)
return hasMark && hasAttributes
}

View File

@@ -0,0 +1,18 @@
import { findParentNode, findSelectedNodeOfType } from 'prosemirror-utils'
import { EditorState } from 'prosemirror-state'
import { Node, NodeType } from 'prosemirror-model'
export default function nodeIsActive(state: EditorState, type: NodeType, attributes = {}) {
const predicate = (node: Node) => node.type === type
const node = findSelectedNodeOfType(type)(state.selection)
|| findParentNode(predicate)(state.selection)
if (!Object.keys(attributes).length || !node) {
return !!node
}
return node.node.hasMarkup(type, {
...node.node.attrs,
...attributes,
})
}

View File

@@ -0,0 +1,16 @@
import { Extensions } from '../types'
import { Extension } from '../Extension'
import { Node } from '../Node'
import { Mark } from '../Mark'
export default function splitExtensions(extensions: Extensions) {
const baseExtensions = extensions.filter(extension => extension.type === 'extension') as Extension[]
const nodeExtensions = extensions.filter(extension => extension.type === 'node') as Node[]
const markExtensions = extensions.filter(extension => extension.type === 'mark') as Mark[]
return {
baseExtensions,
nodeExtensions,
markExtensions,
}
}