import { Node as ProsemirrorNode, Slice } from 'prosemirror-model'; import { EditorView } from 'prosemirror-view'; import { ISetHTMLOptions, SylApi } from './api'; import { createDetachedElement, Types } from './libs'; import { FORMAT_TYPE, SYL_TAG, SylPlugin } from './schema'; import { IMatcherConfig } from './schema/matchers'; /** * get & setHTML */ // check if there is an empty

in the last line of html in `setHTML` const insertEmptyPTagAtTheEndOfHtml = (div: HTMLElement): void => { const lastChild = div.lastChild; if (!lastChild) return; if (lastChild.nodeName.toLocaleLowerCase() !== 'p' || (lastChild as HTMLElement).innerHTML !== '') { const blankP = document.createElement('p'); div.appendChild(blankP); return; } }; // remove the last `break`, and operate Node directly will cause the size to be incorrect and need to be corrected manually const removeBrInEnd = (doc: ProsemirrorNode | Slice) => { const matchNodes: Array<{ parent: ProsemirrorNode; topPos: number; topNode: ProsemirrorNode; pos: number; index: number; newNode: ProsemirrorNode; }> = []; let topNode: ProsemirrorNode; let topPos = 0; doc.content.nodesBetween( 0, doc.content.size, (node: ProsemirrorNode, pos: number, _parent: ProsemirrorNode, index: number) => { // set the new top Node if (!_parent) { topNode = node; topPos = pos; } if (node.isTextblock && node.lastChild && node.lastChild.type === node.type.schema.nodes.break) { const parent = _parent || doc; const newNode = node.copy(node.content.cut(0, node.content.size - 1)); // reverse to avoid incorrect position after modification matchNodes.unshift({ parent, topNode, topPos, newNode, pos, index }); return false; } }, ); matchNodes.forEach(({ parent, topNode: _topNode, topPos: _topPos, newNode, pos, index }) => { const $pos = _topNode.resolve(pos - _topPos); // border parent.content = parent.content.replaceChild(index, newNode); parent !== doc && doc.content.size--; let depth = $pos.depth - 2; while (depth >= 0) { $pos.node(depth).content.size--; depth--; } }); return doc; }; // `getHTML` remove the
at the end const removeEmptyTagAtEndOfHtml = (div: HTMLElement) => { const MARK = '
'; let lastChild: ChildNode | null; do { lastChild = div.lastChild; if (!lastChild || (lastChild as HTMLElement).innerHTML !== MARK) break; lastChild.parentElement && lastChild.parentElement.removeChild(lastChild); } while ((lastChild as HTMLElement).innerHTML === MARK); }; const IG_TAG = 'ignoretag'; const IG_EL = 'ignoreel'; const removeTag = (el: Element) => { if (el.parentNode) { Array.from(el.childNodes).forEach(cNode => el.parentNode!.insertBefore(cNode, el)); el.parentNode.removeChild(el); } }; const removeIgnoreTag = (div: HTMLElement) => { const ignoreTags = div.querySelectorAll(`*[${IG_TAG}=true]`); Array.from(ignoreTags) .reverse() .forEach(removeTag); }; const removeIgnoreContent = (div: HTMLElement) => { const ignoreEls = div.querySelectorAll(`*[${IG_EL}=true]`); Array.from(ignoreEls) .reverse() .forEach(el => { el.parentElement!.removeChild(el); }); }; // remove and .ProseMirror-trailingBreak const handleHackNode = (div: HTMLElement) => { Array.from(div.querySelectorAll('.ProseMirror-separator')).map(n => n.parentElement?.removeChild?.(n)); Array.from(div.querySelectorAll('.ProseMirror-trailingBreak')).forEach(n => n.removeAttribute('class')); }; // when
after `inlineCard` is the last node, delete it const removeBrAfterInlineCard = (div: HTMLElement) => { Array.from(div.querySelectorAll('br:last-child')).forEach(a => { if (a.nextSibling || !a.previousSibling || a.previousSibling.nodeType !== 1) return; const prevNode = a.previousSibling as HTMLElement; if (prevNode.matches?.('syl-inline') || prevNode.querySelector?.('syl-inline')) { a.parentElement?.removeChild?.(a); } }); }; // merge continues
,


const mergeContinuesEmptyLine = (node: HTMLElement) => { const domRemoves = []; const walker = document.createTreeWalker(node, NodeFilter.SHOW_ALL, { acceptNode(cNode) { if (cNode.nodeType === 1 || cNode.nodeType === 3) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_REJECT; }, }); let prevTag = ''; const isEmptyParagraph = (p: Element | null) => p && p.nodeName === 'P' && (/^
+$/.test(p.innerHTML) || !p.innerHTML); const judgeMerge = (judgeNode: Node) => { let res = false; if (judgeNode.nodeName === 'BR' && prevTag === 'BR') { res = true; } else if ( judgeNode.nodeType === 1 && isEmptyParagraph(judgeNode as Element) && isEmptyParagraph((judgeNode as Element).previousElementSibling) ) { res = true; } prevTag = judgeNode.nodeName; return res; }; while (walker.nextNode()) { const curNode = walker.currentNode; if (judgeMerge(curNode)) { domRemoves.push(curNode); } } domRemoves.forEach(dom => dom.parentNode && dom.parentNode.removeChild(dom)); }; // use the content of `` to replace `SYL_TAG` const removeShell = (root: HTMLElement) => { const cards = root!.querySelectorAll(`[${SYL_TAG}]`); [].slice.call(cards).forEach((card: HTMLElement) => { card.childNodes.forEach((child: any) => { if (child.tagName === 'TEMPL' && card.parentElement) { card.parentElement.replaceChild(child, card); removeTag(child); } }); }); return root.innerHTML; }; const transformGetHTML = (html: string, sylPlugins: SylPlugin[]) => { let res = html; sylPlugins.forEach(({ $controller }) => { if ($controller && $controller.transformGetHTML) { res = $controller.transformGetHTML(res); } }); return res; }; interface IFormatHTMLConfig { mergeEmpty?: boolean; keepLastLine?: boolean; } const handleDOMSpec = (dom: HTMLElement) => { removeIgnoreTag(dom); removeIgnoreContent(dom); return dom; }; const formatGetHTML = (root: HTMLElement, config: IFormatHTMLConfig, sylPlugins: SylPlugin[]) => { const div = createDetachedElement('div'); const salt = String(Math.random()).substring(2, 8); div.hidden = true; div.innerHTML = root.innerHTML .replace(/(<[^>]*)(src=)/g, `$1data${salt}src=`) .replace(/(<[^>]*)(draggable=[^\s|/|>]*\s?)/g, '$1') .replace(/(<[^>]*)(contenteditable=[^\s|/|>]*\s?)/g, '$1'); handleDOMSpec(div); handleHackNode(div); removeBrAfterInlineCard(div); config && config.mergeEmpty && mergeContinuesEmptyLine(div); removeEmptyTagAtEndOfHtml(div); const pureHTML = (html: string) => html.replace(new RegExp(`data${salt}src=`, 'g'), 'src='); const resHTML = pureHTML(removeShell(div)); return transformGetHTML(resHTML, sylPlugins); }; const formatSetHTML = (html: string, config: IFormatHTMLConfig) => { const div = createDetachedElement('div'); div.innerHTML = html; // this replace break element with \n in codeblock handleDOMSpec(div); // used to avoid when `keepLastLine` is true, click after `setHTML` will cause emit confuse `text-change` event config.keepLastLine && insertEmptyPTagAtTheEndOfHtml(div); config.mergeEmpty && mergeContinuesEmptyLine(div); return div; }; type matchRules = { name: string; matchRule: IMatcherConfig; }[]; interface INodeContent { type: string; attrs?: Types.StringMap; content?: INodeContent[]; text?: string; marks?: { type: string; attrs?: Types.StringMap }; } const getSplicedNode = (node: INodeContent, start: number, end: number, newNode: INodeContent) => { let plus = 0; const originText = node.text!; const prevText = originText.substring(0, start); const nextText = originText.substring(end); const nodes = []; if (prevText) { nodes.push({ ...node, text: prevText }); plus++; } nodes.push(newNode); nextText && nodes.push({ ...node, text: nextText }); return { nodes, plus }; }; const getMatchRules = (sylPlugins: SylPlugin[]) => { const inlineMatchers: matchRules = []; const blockMatchers: matchRules = []; sylPlugins.forEach(({ $schemaMeta }) => { if ($schemaMeta && $schemaMeta.config.textMatcher) { if ($schemaMeta.formatType === FORMAT_TYPE.INLINE_CARD) { $schemaMeta.config.textMatcher .filter((r: IMatcherConfig) => r.inputOnly === false) .forEach((r: IMatcherConfig) => { inlineMatchers.push({ name: $schemaMeta.name, matchRule: r }); }); } else if ($schemaMeta.formatType === FORMAT_TYPE.BLOCK_CARD) { $schemaMeta.config.textMatcher .filter((r: IMatcherConfig) => r.inputOnly !== true) .forEach((r: IMatcherConfig) => { blockMatchers.push({ name: $schemaMeta.name, matchRule: r }); }); } } }); const matchNode = (node: INodeContent, type: 'block' | 'inline' = 'block') => { const curMatchRules = type === 'block' ? blockMatchers : inlineMatchers; for (let i = 0; i < curMatchRules.length; i++) { const { matcher, handler } = curMatchRules[i].matchRule; if (!handler) continue; const newMatchers = new RegExp(matcher.source, matcher.flags); const matched = newMatchers.exec(node.text!); if (!matched) continue; const attrs = handler(matched); if (!attrs) continue; return { node: { type: curMatchRules[i].name, marks: node.marks, attrs, }, matchInfo: { start: matched.index, end: matched.index + matched[0].length, }, }; } }; return { matchNode, blockMatchers, inlineMatchers }; }; // special HTML is transformed into `Node` through `textMatcher` config of `Schema` const transformCard = (docNode: ProsemirrorNode, view: EditorView, sylPlugins: SylPlugin[]) => { let doc = docNode.toJSON(); const { matchNode, blockMatchers, inlineMatchers } = getMatchRules(sylPlugins); doc = { type: 'doc', content: doc.content.map((node: INodeContent) => { if ( blockMatchers.length && node.type === 'paragraph' && node.content && node.content.length === 1 && node.content[0].type === 'text' ) { const res = matchNode(node.content[0]); if (res) return res.node; } if (inlineMatchers.length && node.content) { const loopContent = (_content: INodeContent[]) => { for (let i = 0; i < _content.length; i++) { const curNode = _content[i]; if (curNode && curNode.content) { loopContent(curNode.content!); } else if (curNode.text) { const res = matchNode(curNode, 'inline'); if (res) { const spliceNodeInfo = getSplicedNode(curNode, res.matchInfo.start, res.matchInfo.end, res.node); _content.splice(i, 1, ...spliceNodeInfo.nodes); i += spliceNodeInfo.plus; } } } }; loopContent(node.content); } return node; }), }; return ProsemirrorNode.fromJSON(view.state.schema, doc); }; const parseHTML = (html: string, adapter: SylApi, config: ISetHTMLOptions) => { const { view, basicConfiguration } = adapter.configurator; const htmlNode = formatSetHTML(html, { ...basicConfiguration, ...config, }); const docNode = transformCard( // TODO:use parseSlice instead adapter.domParser.parse(htmlNode, { preserveWhitespace: config.keepWhiteSpace === undefined ? basicConfiguration.keepWhiteSpace : config.keepWhiteSpace, }), view, adapter.configurator.getSylPlugins(), ); removeBrInEnd(docNode); return docNode; }; export { formatGetHTML, formatSetHTML, handleDOMSpec, IG_EL, IG_TAG, insertEmptyPTagAtTheEndOfHtml, parseHTML, removeBrInEnd, transformCard, };