@atlaskit/editor-core
Version:
A package contains Atlassian editor core functionality
288 lines (263 loc) • 10.9 kB
text/typescript
import { Fragment, MarkType, Node, NodeType, Schema, Slice, MediaAttributes } from '../';
import { NodeSpec, MarkSpec } from '../prosemirror';
import matches from './matches';
import sampleSchema from './schema';
/**
* Represents a ProseMirror "position" in a document.
*/
export type position = number;
/**
* A useful feature of the builder is being able to declaratively mark positions
* in content using the curly braces e.g. `{<>}`.
*
* These positions are called "refs" (inspired by React), and are tracked on
* every node in the tree that has a ref on any of its descendants.
*/
export type Refs = { [name: string]: position };
/**
* Content that contains refs information.
*/
export type RefsContentItem = RefsNode | RefsTracker;
/**
* Content node or mark builders can consume, e.g.
*
* const builder = nodeFactory('p');
* builder('string');
* builder(aNode);
* builder(aRefsNode);
* builder(aRefsTracker);
* builder([aNode, aRefsNode, aRefsTracker]);
*/
export type BuilderContent = string | Node | RefsContentItem | (Node | RefsContentItem)[];
/**
* ProseMirror doesn't support empty text nodes, which can be quite
* inconvenient when you want to capture a position ref without introducing
* text.
*
* Take a couple of examples:
*
* p('{<>}')
* p('Hello ', '{<>}', 'world!')
*
* After the ref syntax is stripped you're left with:
*
* p('')
* p('Hello ', '', 'world!')
*
* This violates the rule of text nodes being non-empty. This class solves the
* problem by providing an alternative data structure that *only* stores refs,
* and can be used in scenarios where an empty text would be forbidden.
*
* This is done under the hood when using `text()` factory, and instead of
* always returning a text node, it'll instead return one of two things:
*
* - a text node -- when given a non-empty string
* - a refs tracker -- when given a string that *only* contains refs.
*/
export class RefsTracker {
refs: Refs;
}
/**
* A standard ProseMirror Node that also tracks refs.
*/
export interface RefsNode extends Node {
refs: Refs;
}
/**
* Create a text node.
*
* Special markers called "refs" can be put in the text. Refs provide a way to
* declaratively describe a position within some text, and then access the
* position in the resulting node.
*/
export function text(value: string, schema: Schema<NodeSpec, MarkSpec>): RefsContentItem {
let stripped = '';
let textIndex = 0;
const refs: Refs = {};
// Helpers
const isEven = n => n % 2 === 0;
for (const match of matches(value, /([\\]+)?{(\w+|<|>|<>)}/g)) {
const [refToken, skipChars, refName] = match;
let { index } = match;
const skipLen = skipChars && skipChars.length;
if (skipLen) {
if (isEven(skipLen)) {
index += (skipLen / 2);
}
else {
stripped += value.slice(textIndex, index + ((skipLen - 1) / 2));
stripped += value.slice(index + skipLen, index + refToken.length);
textIndex = index + refToken.length;
continue;
}
}
stripped += value.slice(textIndex, index);
refs[refName] = stripped.length;
textIndex = match.index + refToken.length;
}
stripped += value.slice(textIndex);
const node = stripped === ''
? new RefsTracker()
: schema.text(stripped) as RefsNode;
node.refs = refs;
return node;
}
/**
* Offset ref position values by some amount.
*/
export function offsetRefs(refs: Refs, offset: number): Refs {
const result = {} as Refs;
for (const name in refs) {
result[name] = refs[name] + offset;
}
return result;
}
/**
* Given a collection of nodes, sequence them in an array and return the result
* along with the updated refs.
*/
export function sequence(...content: RefsContentItem[]) {
let position = 0;
let refs = {} as Refs;
const nodes = [] as RefsNode[];
// It's bizarre that this is necessary. An if/else in the for...of should have
// sufficient but it did not work at the time of writing.
const isRefsTracker = (n: any): n is RefsTracker => n instanceof RefsTracker;
const isRefsNode = (n: any): n is RefsNode => !isRefsTracker(n);
for (const node of content) {
if (isRefsTracker(node)) {
refs = { ...refs, ...offsetRefs(node.refs, position) };
}
if (isRefsNode(node)) {
const thickness = node.isText ? 0 : 1;
refs = { ...refs, ...offsetRefs(node.refs, position + thickness) };
position += node.nodeSize;
nodes.push(node as RefsNode);
}
}
return { nodes, refs };
}
/**
* Given a jagged array, flatten it down to a single level.
*/
export function flatten<T>(deep: (T | T[])[]): T[] {
const flat = [] as T[];
for (const item of deep) {
if (Array.isArray(item)) {
flat.splice(flat.length, 0, ...item);
} else {
flat.push(item);
}
}
return flat;
}
/**
* Coerce builder content into ref nodes.
*/
export function coerce(content: BuilderContent[], schema: Schema<NodeSpec, MarkSpec>) {
const refsContent = content
.map(item => typeof item === 'string'
? text(item, schema)
: item) as (RefsContentItem | RefsContentItem[])[];
return sequence(...flatten<RefsContentItem>(refsContent));
}
/**
* Create a factory for nodes.
*/
export function nodeFactory(type: NodeType, attrs = {}) {
return function (...content: BuilderContent[]): RefsNode {
const { nodes, refs } = coerce(content, type.schema);
const node = type.create(attrs, nodes) as RefsNode;
node.refs = refs;
return node;
};
}
/**
* Create a factory for marks.
*/
export function markFactory(type: MarkType, attrs = {}) {
const mark = type.create(attrs);
return (...content: BuilderContent[]): RefsNode[] => {
const { nodes } = coerce(content, type.schema);
return nodes
.map(node => {
if (mark.type.isInSet(node.marks)) {
return node;
} else {
const refNode = node.mark(mark.addToSet(node.marks)) as RefsNode;
refNode.refs = node.refs;
return refNode;
}
});
};
}
export const createCell = (colspan, rowspan) => td({colspan, rowspan})(p('x'));
export const createHeaderCell = (colspan, rowspan) => th({colspan, rowspan})(p('x'));
export const doc = nodeFactory(sampleSchema.nodes.doc, {});
export const p = nodeFactory(sampleSchema.nodes.paragraph, {});
export const blockquote = nodeFactory(sampleSchema.nodes.blockquote, {});
export const h1 = nodeFactory(sampleSchema.nodes.heading, { level: 1 });
export const h2 = nodeFactory(sampleSchema.nodes.heading, { level: 2 });
export const h3 = nodeFactory(sampleSchema.nodes.heading, { level: 3 });
export const h4 = nodeFactory(sampleSchema.nodes.heading, { level: 4 });
export const h5 = nodeFactory(sampleSchema.nodes.heading, { level: 5 });
export const h6 = nodeFactory(sampleSchema.nodes.heading, { level: 6 });
export const li = nodeFactory(sampleSchema.nodes.listItem, {});
export const ul = nodeFactory(sampleSchema.nodes.bulletList, {});
export const ol = nodeFactory(sampleSchema.nodes.orderedList, {});
export const br = sampleSchema.nodes.hardBreak.createChecked();
export const panel = nodeFactory(sampleSchema.nodes.panel, {});
export const panelNote = nodeFactory(sampleSchema.nodes.panel, { panelType: 'note' });
export const plain = nodeFactory(sampleSchema.nodes.plain, {});
export const hardBreak = nodeFactory(sampleSchema.nodes.hardBreak, {});
// tslint:disable-next-line:variable-name
export const code_block = (attrs: {} = {}) => nodeFactory(sampleSchema.nodes.codeBlock, attrs);
export const img = (attrs: { src: string, alt?: string, title?: string }) => sampleSchema.nodes.image.createChecked(attrs);
export const emoji = (attrs: { shortName: string, id?: string, fallback?: string }) => {
const emojiNodeAttrs = {
shortName: attrs.shortName,
id: attrs.id,
text: attrs.fallback || attrs.shortName,
};
return sampleSchema.nodes.emoji.createChecked(emojiNodeAttrs);
};
export const mention = (attrs: { id: string, text?: string }) => sampleSchema.nodes.mention.createChecked(attrs);
export const hr = sampleSchema.nodes.rule.createChecked();
export const em = markFactory(sampleSchema.marks.em, {});
export const subsup = (attrs: { type: string }) => markFactory(sampleSchema.marks.subsup, attrs);
export const underline = markFactory(sampleSchema.marks.underline, {});
export const strong = markFactory(sampleSchema.marks.strong, {});
export const code = markFactory(sampleSchema.marks.code, {});
export const strike = markFactory(sampleSchema.marks.strike, {});
export const mentionQuery = (attrs = { active: true }) => markFactory(sampleSchema.marks.mentionQuery, attrs ? attrs : {} );
export const a = (attrs: { href: string, title?: string }) => markFactory(sampleSchema.marks.link, attrs);
export const fragment = (...content: BuilderContent[]) => flatten<BuilderContent>(content);
export const slice = (...content: BuilderContent[]) => new Slice(Fragment.from(coerce(content, sampleSchema).nodes), 0, 0);
export const emojiQuery = markFactory(sampleSchema.marks.emojiQuery, {});
export const singleImage = (attrs = {}) => nodeFactory(sampleSchema.nodes.singleImage, attrs);
export const mediaGroup = nodeFactory(sampleSchema.nodes.mediaGroup);
export const media = (attrs: MediaAttributes) => sampleSchema.nodes.media.create(attrs);
export const textColor = (attrs: { color: string }) => markFactory(sampleSchema.marks.textColor, attrs);
export const table = nodeFactory(sampleSchema.nodes.table, {});
export const tr = nodeFactory(sampleSchema.nodes.tableRow, {});
export const td = (attrs: { colspan?: number, rowspan?: number }) => nodeFactory(sampleSchema.nodes.tableCell, attrs);
export const th = (attrs: { colspan?: number, rowspan?: number }) => nodeFactory(sampleSchema.nodes.tableHeader, attrs);
export const tdEmpty = td({})(p(''));
export const thEmpty = th({})(p(''));
export const tdCursor = td({})(p('{<>}'));
export const thCursor = th({})(p('{<>}'));
export const td11 = createCell(1, 1);
export const th11 = createHeaderCell(1, 1);
export const decisionList = nodeFactory(sampleSchema.nodes.decisionList, {});
export const decisionItem = nodeFactory(sampleSchema.nodes.decisionItem, {});
export const taskList = nodeFactory(sampleSchema.nodes.taskList, {});
export const taskItem = nodeFactory(sampleSchema.nodes.taskItem, {});
export const confluenceUnsupportedBlock = (cxhtml: string) => nodeFactory(sampleSchema.nodes.confluenceUnsupportedBlock, { cxhtml })();
export const confluenceUnsupportedInline = (cxhtml: string) => nodeFactory(sampleSchema.nodes.confluenceUnsupportedInline, { cxhtml })();
export const confluenceJiraIssue = (attrs: {
issueKey?: string;
macroId?: string;
schemaVersion?: string;
server?: string;
serverId?: string;
}) => sampleSchema.nodes.confluenceJiraIssue.create(attrs);