Skip to content

Instantly share code, notes, and snippets.

@colelawrence
Last active January 7, 2023 06:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save colelawrence/f76d8937d3fddfa1ca53d67a31a29ff7 to your computer and use it in GitHub Desktop.
Save colelawrence/f76d8937d3fddfa1ca53d67a31a29ff7 to your computer and use it in GitHub Desktop.
ProseMirror typed node spec (incomplete source code from Story.ai codebase)
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)];
})
);
}
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