@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
379 lines (349 loc) • 14.1 kB
text/typescript
import { getChangedRanges } from "@tiptap/core";
import { Plugin, PluginKey, type Transaction } from "prosemirror-state";
import {
createExtension,
createStore,
type ExtensionOptions,
} from "../../editor/BlockNoteExtension.js";
import {
colorsForUserIds,
userColorVarNames,
normalizeToUserStore,
type UserStoreOrResolver,
} from "../../user/index.js";
import {
resolveAttributionMarkClassName,
YAttributionMarksExtension,
type GetAttributionMarkClassName,
} from "./YAttributionMarks.js";
/** The attribution marks, mapped to their `modificationType`. */
const ATTRIBUTION_MARK_TYPES = {
"y-attributed-insert": "insert",
"y-attributed-delete": "delete",
"y-attributed-format": "format",
} as const;
const ATTRIBUTION_LOAD_PLUGIN_KEY = new PluginKey("attributionLoadUsers");
/** Wrapper of an attribution mark; carries its author(s) in `data-user-ids`. */
const ATTRIBUTION_MARK_SELECTOR = "[data-user-ids]";
/** Parse the JSON-encoded `data-user-ids` attribute; `[]` if missing/malformed. */
const parseUserIds = (userIdsJSON: string | undefined): string[] => {
if (!userIdsJSON) {
return [];
}
let userIds: unknown;
try {
userIds = JSON.parse(userIdsJSON);
} catch {
return [];
}
return Array.isArray(userIds) ? userIds.map(String) : [];
};
/**
* The changed format keys from a modification mark's `data-format` (e.g.
* `["bold", "italic"]`); `[]` if missing/malformed or an empty change. This is
* the raw change context — turning it into a localized label (e.g.
* `"Bold, Italic"`) is a view concern owned by the React layer's
* `formatChangeLabel`, so core stays i18n-agnostic here.
*/
const parseFormatKeys = (formatJSON: string | undefined): string[] => {
if (!formatJSON) {
return [];
}
let format: unknown;
try {
format = JSON.parse(formatJSON);
} catch {
return [];
}
if (typeof format !== "object" || format === null) {
return [];
}
return Object.keys(format);
};
/**
* The element with a real box to anchor the tooltip to. The wrapper is
* `display: contents` (no box of its own), so use its content span child,
* falling back further for block marks.
*/
const getReferenceElement = (wrapper: Element): Element => {
const content = wrapper.firstElementChild ?? wrapper;
const rect = content.getBoundingClientRect();
if (rect.width || rect.height) {
return content;
}
return content.firstElementChild ?? content;
};
/**
* The box the tooltip anchors to. The wrapper is `display: contents` (no box of
* its own), so use its content span child, falling back further for block marks.
* Exported for the React controller's floating-ui `getBoundingClientRect`.
*/
export const getReferenceRect = (wrapper: Element): DOMRect =>
getReferenceElement(wrapper).getBoundingClientRect();
/**
* The per-line client rects of the reference element, for floating-ui's
* `inline()` middleware — it needs one rect per line to position off a
* multi-line mark, and virtual elements don't get a default `getClientRects`.
*/
export const getReferenceClientRects = (wrapper: Element): DOMRectList =>
getReferenceElement(wrapper).getClientRects();
/**
* State for the currently-hovered suggestion mark's tooltip (`undefined` when
* none). The extension computes it; a React controller renders + positions it
* (see `AttributionTooltipController`).
*/
export type AttributionTooltipState = {
/** The wrapper element the tooltip anchors to (floating-ui reference). */
anchor: HTMLElement;
/** Per-user background color, resolved from the user store (default path). */
color: string;
/** The kind of change — `format` is the modification mark. */
modificationType: "insert" | "delete" | "format";
/** Whether the mark wraps inline content or a whole block. */
contentType: "inline-content" | "block";
/** Resolved usernames (falls back to raw ids), for custom renderers. */
users: string[];
/**
* The changed format keys (e.g. `["bold", "italic"]`), present only for
* `format` marks. This is the raw change context — the view layer turns it
* into a localized label via its `formatChangeLabel`.
*/
format?: string[];
/**
* Class name from the `getAttributionMarkClassName` callback (override path).
* When present, the tooltip applies this and skips the inline `color`.
*/
className?: string;
};
/**
* Resolves the attribution tooltip state for suggestion marks on hover (exposed
* via a store for React), and loads each mark's author so its color/username
* resolves. Marks nest, so a single delegated `mouseover` listener picks the
* `closest` wrapper to the pointer — the innermost mark wins.
*/
export const AttributionExtension = createExtension(
({
options,
}: ExtensionOptions<
| {
/** Resolves authors to usernames. Optional; unresolved ids show raw. */
resolveUsers?: UserStoreOrResolver;
/** See {@link GetAttributionMarkClassName}. */
getAttributionMarkClassName?: GetAttributionMarkClassName;
}
| undefined
>) => {
const userStore = normalizeToUserStore(options?.resolveUsers);
const getAttributionMarkClassName = options?.getAttributionMarkClassName;
const store = createStore<AttributionTooltipState | undefined>(undefined);
// Load the authors of the attribution marks in `tr`'s changed ranges, so
// their colors/usernames resolve (colors then flow to marks via `syncRootVars`).
// `getChangedRanges` covers mark-only steps too — which suggestion mode adds
// over existing text and `tr.changedRange()` would miss.
const loadChangedUsers = (tr: Transaction) => {
const ranges = getChangedRanges(tr);
if (ranges.length === 0) {
return;
}
// Most changes are local (often several steps in one small span), so scan a
// single range spanning all of them rather than each range individually.
let from = Infinity;
let to = -Infinity;
for (const { newRange } of ranges) {
from = Math.min(from, newRange.from);
to = Math.max(to, newRange.to);
}
const ids = new Set<string>();
tr.doc.nodesBetween(from, to, (node) => {
for (const mark of node.marks) {
if (
ATTRIBUTION_MARK_TYPES[
mark.type.name as keyof typeof ATTRIBUTION_MARK_TYPES
]
) {
const userIds = mark.attrs["userIds"] as string[] | null;
userIds?.forEach((id) => ids.add(id));
}
}
return true;
});
if (ids.size > 0) {
void userStore.loadUsers(Array.from(ids));
}
};
return {
key: "attribution",
userStore,
store,
prosemirrorPlugins: [
// Marks arrive in a transaction (suggestion mode, viewing suggestions,
// version preview), so resolve their authors as the doc changes.
new Plugin({
key: ATTRIBUTION_LOAD_PLUGIN_KEY,
state: {
init: () => null,
apply: (tr) => {
if (tr.docChanged) {
loadChangedUsers(tr);
}
return null;
},
},
}),
],
mount({ dom, root, signal }) {
// Write each resolved author's color to the editor root as per-user CSS
// variables (`--user-color-<id>-{light,dark}`) that the mark wrappers read
// via `var(..., <fallback>)`, so the cascade recolors marks once a color
// resolves. Color-less users have theirs removed so the fallback applies.
const syncRootVars = () => {
for (const [id, user] of userStore.store.state) {
const { light, dark } = userColorVarNames(id);
if (user.color && user.colorLight) {
dom.style.setProperty(light, user.colorLight);
dom.style.setProperty(dark, user.color);
} else {
dom.style.removeProperty(light);
dom.style.removeProperty(dark);
}
}
};
// The wrapper currently showing a tooltip, so we don't re-emit on every
// `mouseover` over the same mark.
let activeAnchor: HTMLElement | undefined;
// The mark's authors as usernames, falling back to the raw id when not
// cached (`getUser` is cache-only; ids load on hover, see `onPointerOver`).
const usersLabelArray = (userIdsJSON: string | undefined): string[] =>
parseUserIds(userIdsJSON).map(
(id) => userStore.getUser(id)?.username ?? id,
);
// A stable identity string for a wrapper (empty if unattributed), used to
// (a) test whether a mark is attributed and (b) group adjacent marks with
// the *same* attribution under one tooltip. It's an internal grouping key,
// not the displayed text — that's composed in the view from `users` and
// the format label — so it's built from raw `data-*` (ids + format keys)
// and stays free of i18n/username resolution.
const attributionIdentity = (wrapper: HTMLElement) => {
const ids = parseUserIds(wrapper.dataset["userIds"]);
if (ids.length === 0) {
return "";
}
const format = parseFormatKeys(wrapper.dataset["format"]);
return `${format.join(",")}:${ids.join(",")}`;
};
// Build the tooltip state from a wrapper's `data-*` attributes.
const buildState = (anchor: HTMLElement): AttributionTooltipState => {
const isModification = anchor.dataset["format"] !== undefined;
const modificationType: AttributionTooltipState["modificationType"] =
isModification
? "format"
: anchor.tagName === "INS"
? "insert"
: "delete";
const contentType: AttributionTooltipState["contentType"] =
anchor.dataset["inline"] === "false" ? "block" : "inline-content";
return {
anchor,
// The tooltip is portaled outside the editor root, so it can't read
// the cascaded per-user vars — resolve a concrete color from the store.
color: colorsForUserIds(
userStore,
parseUserIds(anchor.dataset["userIds"]),
).dark,
modificationType,
contentType,
users: usersLabelArray(anchor.dataset["userIds"]),
format: isModification
? parseFormatKeys(anchor.dataset["format"])
: undefined,
className: resolveAttributionMarkClassName(
getAttributionMarkClassName?.({ contentType, modificationType }),
"tooltip",
),
};
};
const hideTooltip = () => {
if (!activeAnchor) {
return;
}
activeAnchor = undefined;
store.setState(undefined);
};
// The innermost attributed mark at or above `el`, skipping unattributed
// wrappers so an attributed ancestor still wins.
const innermostAttributed = (
el: Element | null,
): HTMLElement | undefined => {
while (el) {
const wrapper = el.closest<HTMLElement>(ATTRIBUTION_MARK_SELECTOR);
if (!wrapper) {
return undefined;
}
if (attributionIdentity(wrapper)) {
return wrapper;
}
el = wrapper.parentElement;
}
return undefined;
};
const onPointerOver = (event: Event) => {
const target = event.target instanceof Element ? event.target : null;
const innermost = innermostAttributed(target);
if (!innermost) {
// Not over an attributed mark — drop the current tooltip.
hideTooltip();
return;
}
const identity = attributionIdentity(innermost);
// Anchor on the outermost ancestor with the *same* attribution so one
// tooltip covers the whole region; a differently-attributed ancestor
// breaks the chain, and unattributed ones are climbed past.
let anchor = innermost;
let el: Element | null = innermost.parentElement;
while (el) {
const ancestor = el.closest<HTMLElement>(ATTRIBUTION_MARK_SELECTOR);
if (!ancestor) {
break;
}
const ancestorIdentity = attributionIdentity(ancestor);
if (ancestorIdentity === identity) {
anchor = ancestor;
} else if (ancestorIdentity) {
break;
}
el = ancestor.parentElement;
}
if (activeAnchor === anchor) {
return;
}
activeAnchor = anchor;
store.setState(buildState(anchor));
// First hover renders raw ids (cache-only); load the authors and refresh
// the resolved usernames once loaded, if this mark is still active.
const ids = parseUserIds(anchor.dataset["userIds"]);
if (ids.length > 0) {
void userStore.loadUsers(ids).then(() => {
if (activeAnchor !== anchor) {
return;
}
store.setState(buildState(anchor));
});
}
};
root.addEventListener("mouseover", onPointerOver, { signal });
signal.addEventListener("abort", hideTooltip);
// Seed from the cache, then keep the vars in sync as users resolve.
syncRootVars();
const unsubscribe = userStore.store.subscribe(syncRootVars);
signal.addEventListener("abort", unsubscribe);
},
// The `y-attributed-*` marks aren't in the default schema — register them
// here so the block specs can allow them (collaboration-only).
blockNoteExtensions: [
YAttributionMarksExtension({
getAttributionMarkClassName: options?.getAttributionMarkClassName,
}),
],
};
},
);