Last active
January 7, 2023 06:08
-
-
Save colelawrence/f76d8937d3fddfa1ca53d67a31a29ff7 to your computer and use it in GitHub Desktop.
ProseMirror typed node spec (incomplete source code from Story.ai codebase)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
AttributeSpec, | |
DOMOutputSpec, | |
Fragment, | |
Mark, | |
Node as PMNode, | |
NodeSpec, | |
NodeType, | |
ParseRule, | |
} from "prosemirror-model"; | |
import { EditorView } from "prosemirror-view"; | |
import { BehaviorSubject, Observable, Subscription } from "rxjs"; | |
import { invariant, invariantEq } from "@autoplay/utils"; | |
import { IUtilLogger } from "librarylog"; | |
import { deepEqual } from "./deepEqual"; | |
import { deepDiff } from "./deepDiff"; | |
type ToDOM<Attrs> = { | |
toDOM: ( | |
toDOMFn: ( | |
node: Omit<PMNode, "attrs"> & { | |
/** TODO: Double check that we can guarantee that the attrs are not partial */ | |
attrs: Attrs; | |
} | |
) => DOMOutputSpec | |
) => NodeSpecAddParsers<Attrs>; | |
}; | |
type NodeSpecAddParsers<Attrs> = { | |
addParser: <N extends Node>( | |
options: Omit<ParseRule, "getAttrs"> & { | |
getAttrs(dom: N): Attrs; | |
} | |
) => NodeSpecAddParsers<Attrs>; | |
finish(): { | |
nodeSpec: NodeSpec; | |
/** | |
* Create a state to manage changes to the node. | |
* | |
* This is specifically designed to make it easier to create custom node-views. | |
*/ | |
createState( | |
node: PMNode, | |
log: IUtilLogger, | |
subscription: Subscription, | |
view: EditorView, | |
getPos: () => number | |
): { | |
attrs$: { | |
[P in keyof Attrs]: Observable<Attrs[P]>; | |
}; | |
// TODO: Double check usefulness of this | |
dispatchUpdateAttrs( | |
attrsToUpdate: | |
| { attrs: Partial<Attrs> } | |
| ((attrs: Attrs) => { | |
attrs: Partial<Attrs>; | |
}) | |
): void; | |
/** @return true if the update was able to be applied to the state */ | |
updateNode(node: PMNode): boolean; | |
}; | |
/** invariant that this node is of the correct type */ | |
attrs(node: PMNode): Attrs; | |
attrsUnchecked(node: Attrs): Attrs; | |
createNode( | |
nodeType: NodeType, | |
attrs: Partial<Attrs>, | |
content?: PMNode | PMNode[] | Fragment, | |
marks?: Mark[] | |
): PMNode; | |
createNodeJSON(attrs: Partial<Attrs>, content?: any[], marks?: Mark[]): any; | |
}; | |
}; | |
const INVALID_CHANGED_ATTRS = new Set(["uid", "uidCopied"]); | |
// type OmitAnyEntries<T> = { | |
// [P in keyof T as string extends P ? never : P]: T[P] | |
// } | |
type NonStringKeys<T> = keyof { | |
[P in keyof T as string extends P ? never : P]: T[P]; | |
}; | |
/** Remove `[key: string]: any;` from a type */ | |
type OmitAnyEntries<T> = Pick<T, NonStringKeys<T>>; | |
/** See https://gist.github.com/colelawrence/f76d8937d3fddfa1ca53d67a31a29ff7 for example usage */ | |
export function buildTypedNodeSpec<T extends Record<string, AttributeSpec>>( | |
keyName: string, | |
spec: Omit<OmitAnyEntries<NodeSpec>, "attrs" | "toDOM" | "parseDOM"> & { | |
attrs: T; | |
} | |
): ToDOM<{ | |
[P in keyof T]: T[P]["default"]; | |
}> { | |
type Attrs = { | |
[P in keyof T]: T[P]["default"]; | |
}; | |
return { | |
toDOM: function toDomBuilder(toDOMFn, rules: ParseRule[] = []) { | |
return { | |
addParser({ getAttrs, ...parseRule }) { | |
return toDomBuilder(toDOMFn, [ | |
...rules, | |
{ | |
...parseRule, | |
getAttrs(dom) { | |
try { | |
return getAttrs(dom as any); | |
} catch { | |
return false; | |
} | |
}, | |
}, | |
]); | |
}, | |
finish() { | |
return { | |
attrs(node) { | |
invariantEq( | |
node.type.name, | |
keyName, | |
"expected node to be of key" | |
); | |
return node.attrs as any; | |
}, | |
attrsUnchecked(attrs) { | |
return attrs; | |
}, | |
createNode(nodeType, attrs, content, marks) { | |
return nodeType.createChecked(attrs, content, marks); | |
}, | |
createNodeJSON(attrs, content, marks) { | |
return { | |
type: keyName, | |
attrs, | |
content, | |
marks, | |
}; | |
}, | |
nodeSpec: { | |
...spec, | |
toDOM: toDOMFn as (gnode: PMNode) => DOMOutputSpec, | |
parseDOM: rules, | |
}, | |
createState(node, log, sub, view, getPos) { | |
log = log.named("node state"); | |
const nodeType = view.state.schema.nodes[keyName]; | |
invariant( | |
nodeType, | |
"expected that the node type is keyed in the schema for createState", | |
{ | |
schema: view.state.schema, | |
keyName, | |
} | |
); | |
const $attrs$ = objMap( | |
spec.attrs, | |
(_val, key) => | |
// @ts-ignore - necessary since P of attrs isn't enforced as a string | |
new BehaviorSubject(node.attrs[key]) | |
); | |
/** takes behaviors into consideration */ | |
const getLatestAttrs = (partial: Partial<Attrs>) => | |
objMap($attrs$, ($beh$, key) => | |
key in partial ? partial[key] : $beh$.value | |
); | |
// defensive clean-up | |
sub.add(() => { | |
for (const key in $attrs$) { | |
$attrs$[key].complete(); | |
} | |
}); | |
return { | |
attrs$: objMap($attrs$, (val) => val.asObservable()), | |
updateNode(updatedNode) { | |
if (updatedNode.type !== nodeType) return false; | |
const updatedAttrs = updatedNode.attrs; | |
for (const attrName in updatedAttrs) { | |
const updateNodeAttrValue = updatedAttrs[attrName]; | |
const $attr$ = $attrs$[attrName]; | |
if (!deepEqual(updateNodeAttrValue, $attr$.value)) { | |
// don't handle "uid" changed automatically | |
// since that will usually result in some pubsub being wrong | |
if (INVALID_CHANGED_ATTRS.has(attrName)) { | |
log.warn( | |
"Unexpected attribute changed with updateNode", | |
{ attrName } | |
); | |
return false; | |
} | |
// doesn't feel super safe, but we're relying on ProseMirror to do a good job | |
log.trace(`updated ${attrName}`, { | |
with: updateNodeAttrValue, | |
}); | |
$attr$.next(updateNodeAttrValue); | |
} | |
} | |
const latestAttrs = getLatestAttrs({}); | |
const attrsUpdatedDiff = deepDiff(updatedAttrs, latestAttrs); | |
const attrsUpdated = Object.keys(attrsUpdatedDiff).length > 0; | |
if (attrsUpdated) { | |
log.trace( | |
"will re-render since attrs updated", | |
attrsUpdatedDiff | |
); | |
} | |
// if there are extras we couldn't apply, we want prosemirror to re-render the whole node view | |
return !attrsUpdated; | |
}, | |
dispatchUpdateAttrs(partialOrUpdateFn) { | |
const { attrs: partial /* overrideUIOperation */ } = | |
typeof partialOrUpdateFn === "function" | |
? partialOrUpdateFn( | |
objMap($attrs$, (val) => val.getValue()) | |
) | |
: partialOrUpdateFn; | |
let tr = view.state.tr.setNodeMarkup( | |
getPos(), | |
undefined, | |
getLatestAttrs(partial) | |
); | |
// TODO: Some way to make it easy to mark the operation as handled before sending over to author sync | |
// if (overrideUIOperation) { | |
// tr = uiOperationUpdateMeta.set(tr, overrideUIOperation); | |
// } | |
// dispatch create transaction | |
view.dispatch(tr); | |
}, | |
}; | |
}, | |
}; | |
}, | |
}; | |
}, | |
}; | |
} | |
function objMap<T extends Record<string, any>, U>( | |
template: T, | |
eachKey: <P extends keyof T>(value: T[P], name: P) => U | |
): { [P in keyof T]: U } { | |
// @ts-ignore | |
return Object.fromEntries( | |
Object.entries(template).map(([name, value]) => { | |
// @ts-ignore | |
return [name, eachKey(value, name)]; | |
}) | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { invariant, invariantThrow } from "~helpers" | |
import { ui } from "src/executor" | |
import { tokenAtomNodeKey } from "./atom-spec" | |
import { blockGroupKey } from "./blockGroupKey" | |
import { tokenExpressionNodeKey } from "./token-expression-spec" | |
import { buildTypedNodeSpec } from "./utils/buildNodeSpec" | |
import { deployPresenceAttrs } from "./utils/deployPresenceAttrs" | |
import { linkPropsDefault } from "./utils/linkPropsDefault" | |
import { uidAttrs } from "./utils/uidAttrs" | |
/** ui::BlockKind::BlockLink which is an independent block with one line of tokens and no children blocks */ | |
export const blockLinkNodeKey = "blockLink" as const | |
function pastedLinkAttrs() { | |
return { | |
/** the default is null, but we want other places to know that this attrs type is UID */ | |
uid: null as any as ui.UID, | |
uidCopied: null, | |
props: linkPropsDefault(), | |
deployPresence: ui.DeployPresence.Observed(), | |
} | |
} | |
export const blockLinkGSpec = buildTypedNodeSpec(blockLinkNodeKey, { | |
content: `(text|${tokenAtomNodeKey}|${tokenExpressionNodeKey})*`, | |
marks: "italics bold mono underline strikethrough mvpcolor link", | |
group: blockGroupKey, | |
draggable: false, | |
selectable: true, | |
attrs: { | |
...uidAttrs.attrs, | |
...deployPresenceAttrs.attrs, | |
link: { | |
default: ui.LinkValue({ | |
placeholder: ui.LinkValuePlaceholder.Anything(), | |
value_opt: null, | |
}), | |
}, | |
props: { | |
default: linkPropsDefault(), | |
}, | |
}, | |
}) | |
.toDOM((node) => { | |
return [ | |
"div", | |
// should align with "block-link"-node-view | |
{ | |
class: `block block-link`, | |
"data-link": JSON.stringify(node.attrs.link), | |
...linkPropsAttrs(node.attrs.props ?? linkPropsDefault()), | |
...uidAttrs.toDOMAttrs(node), | |
...deployPresenceAttrs.toDOMAttrs(node), | |
}, | |
0, | |
] | |
}) | |
.addParser<HTMLDivElement>({ | |
tag: ".block-link", | |
getAttrs(dom) { | |
return { | |
link: JSON.parse(dom.getAttribute("data-link") ?? ""), | |
props: linkPropsFromAttrs(dom), | |
...uidAttrs.parseGetAttrs(dom), | |
...deployPresenceAttrs.parseGetAttrs(dom), | |
} | |
}, | |
preserveWhitespace: true, | |
}) | |
.addParser<HTMLImageElement>({ | |
tag: "img", | |
getAttrs(dom: HTMLImageElement) { | |
const src = new URL(dom.getAttribute("src") ?? "") | |
return { | |
link: ui.LinkValue({ | |
placeholder: ui.LinkValuePlaceholder.Image(), | |
value_opt: getLinkValueInnerFromURL(src), | |
}), | |
hasCaption: false, | |
...pastedLinkAttrs(), | |
} | |
}, | |
}) | |
.finish() | |
export function getLinkValueInnerFromURL(src: URL): ui.LinkValueInner | null { | |
return ui.LinkValueInner.GenericURL( | |
ui.URLAsset.External({ url: src.toString(), origin: src.origin, scheme: getSchemeOrThrow(src) }), | |
) | |
} | |
export function linkPropsAttrs(props: ui.LinkProps) { | |
if (props.link_style != null) { | |
invariant(typeof props.link_style === "string", "expect link style is string") | |
} | |
return { | |
"data-link-style": props.link_style || undefined, | |
"data-link-preview": nullOrStringify(props.link_preview), | |
"data-media-fit": nullOrStringify(props.media_fit), | |
"data-media-preview": nullOrStringify(props.media_preview), | |
"data-show-caption": nullOrStringify(props.show_caption), | |
} | |
} | |
function linkPropsFromAttrs(dom: Element): ui.LinkProps { | |
return { | |
link_preview: nullOrParse(dom.getAttribute("data-link-preview")), | |
link_style: dom.getAttribute("data-link-style") as any, | |
media_fit: nullOrParse(dom.getAttribute("data-media-fit")), | |
media_preview: nullOrParse(dom.getAttribute("data-media-preview")), | |
show_caption: nullOrParse(dom.getAttribute("data-show-caption")) ?? true, | |
} | |
} | |
function nullOrParse(a: any): any { | |
try { | |
if (a != null) return JSON.parse(a) | |
} catch (err) { | |
console.warn("Failed to parse", a, err) | |
} | |
return null | |
} | |
function nullOrStringify(a: any): null | string { | |
if (a != null) return JSON.stringify(a) | |
return null | |
} | |
export function getSchemeOrThrow(url: URL | string): ui.URLAssetExternalScheme { | |
if (!(url instanceof URL)) { | |
url = new URL(url) | |
} | |
const schemeStr = /^([^:]+)/.exec(url.href)?.[1] ?? invariantThrow("Unknown URL scheme", { url }) | |
switch (schemeStr.toLowerCase()) { | |
case "http": | |
return ui.URLAssetExternalScheme.Http() | |
case "https": | |
return ui.URLAssetExternalScheme.Https() | |
case "tel": | |
return ui.URLAssetExternalScheme.Tel() | |
case "mailto": | |
return ui.URLAssetExternalScheme.Mailto() | |
case "magnet": | |
return ui.URLAssetExternalScheme.Magnet() | |
case "data": | |
return ui.URLAssetExternalScheme.Data() | |
default: | |
return ui.URLAssetExternalScheme.Other(schemeStr) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment