@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
212 lines (183 loc) • 5.87 kB
text/typescript
import { EditorState, Plugin, PluginKey, PluginView } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { ySyncPluginKey } from "y-prosemirror";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js";
import type {
BlockFromConfig,
FileBlockConfig,
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
export type FilePanelState<
I extends InlineContentSchema,
S extends StyleSchema,
> = UiElementPosition & {
// TODO: This typing is not quite right (children should be from BSchema)
block: BlockFromConfig<FileBlockConfig, I, S>;
};
export class FilePanelView<I extends InlineContentSchema, S extends StyleSchema>
implements PluginView
{
public state?: FilePanelState<I, S>;
public emitUpdate: () => void;
constructor(
private readonly editor: BlockNoteEditor<
Record<string, FileBlockConfig>,
I,
S
>,
private readonly pluginKey: PluginKey<FilePanelState<I, S>>,
private readonly pmView: EditorView,
emitUpdate: (state: FilePanelState<I, S>) => void,
) {
this.emitUpdate = () => {
if (!this.state) {
throw new Error("Attempting to update uninitialized file panel");
}
emitUpdate(this.state);
};
pmView.dom.addEventListener("mousedown", this.mouseDownHandler);
pmView.dom.addEventListener("dragstart", this.dragstartHandler);
// Setting capture=true ensures that any parent container of the editor that
// gets scrolled will trigger the scroll event. Scroll events do not bubble
// and so won't propagate to the document by default.
pmView.root.addEventListener("scroll", this.scrollHandler, true);
}
mouseDownHandler = () => {
if (this.state?.show) {
this.state.show = false;
this.emitUpdate();
}
};
// For dragging the whole editor.
dragstartHandler = () => {
if (this.state?.show) {
this.state.show = false;
this.emitUpdate();
}
};
scrollHandler = () => {
if (this.state?.show) {
const blockElement = this.pmView.root.querySelector(
`[data-node-type="blockContainer"][data-id="${this.state.block.id}"]`,
);
if (!blockElement) {
return;
}
this.state.referencePos = blockElement.getBoundingClientRect();
this.emitUpdate();
}
};
update(view: EditorView, prevState: EditorState) {
const pluginState = this.pluginKey.getState(view.state);
const prevPluginState = this.pluginKey.getState(prevState);
if (!this.state?.show && pluginState?.block && this.editor.isEditable) {
const blockElement = this.pmView.root.querySelector(
`[data-node-type="blockContainer"][data-id="${pluginState.block.id}"]`,
);
if (!blockElement) {
return;
}
this.state = {
show: true,
referencePos: blockElement.getBoundingClientRect(),
block: pluginState.block,
};
this.emitUpdate();
return;
}
const isOpening = pluginState?.block && !prevPluginState?.block;
const isClosing = !pluginState?.block && prevPluginState?.block;
if (isOpening && this.state && !this.state.show) {
this.state.show = true;
this.emitUpdate();
}
if (isClosing && this.state?.show) {
this.state.show = false;
this.emitUpdate();
}
}
closeMenu = () => {
if (this.state?.show) {
this.state.show = false;
this.emitUpdate();
}
};
destroy() {
this.pmView.dom.removeEventListener("mousedown", this.mouseDownHandler);
this.pmView.dom.removeEventListener("dragstart", this.dragstartHandler);
this.pmView.root.removeEventListener("scroll", this.scrollHandler, true);
}
}
const filePanelPluginKey = new PluginKey<FilePanelState<any, any>>(
"FilePanelPlugin",
);
export class FilePanelProsemirrorPlugin<
I extends InlineContentSchema,
S extends StyleSchema,
> extends BlockNoteExtension {
public static key() {
return "filePanel";
}
private view: FilePanelView<I, S> | undefined;
constructor(editor: BlockNoteEditor<Record<string, FileBlockConfig>, I, S>) {
super();
this.addProsemirrorPlugin(
new Plugin<{
block: BlockFromConfig<FileBlockConfig, I, S> | undefined;
}>({
key: filePanelPluginKey,
view: (editorView) => {
this.view = new FilePanelView<I, S>(
editor,
filePanelPluginKey,
editorView,
(state) => {
this.emit("update", state);
},
);
return this.view;
},
props: {
handleKeyDown: (_view, event: KeyboardEvent) => {
if (event.key === "Escape" && this.shown) {
this.view?.closeMenu();
return true;
}
return false;
},
},
state: {
init: () => {
return {
block: undefined,
};
},
apply: (transaction, prev) => {
const state: FilePanelState<I, S> | undefined =
transaction.getMeta(filePanelPluginKey);
if (state) {
return state;
}
if (
!transaction.getMeta(ySyncPluginKey) &&
(transaction.selectionSet || transaction.docChanged)
) {
return { block: undefined };
}
return prev;
},
},
}),
);
}
public get shown() {
return this.view?.state?.show || false;
}
public onUpdate(callback: (state: FilePanelState<I, S>) => void) {
return this.on("update", callback);
}
public closeMenu = () => this.view?.closeMenu();
}