Skip to content

Commit 88a09a4

Browse files
feat: customizable formatting toolbar (#142)
* simplify formattingtoolbar * Fixed React component types and added customizable formatting toolbar factory * Finished formatting toolbar customization with old props * Changed formatting toolbar props to use BlockNoteEditor * Fixed text alignment with basic selection object * Fixed block nesting tests * Removed multiple block shorthand for updateBlock * Added comments * Removed redundant useCallback hooks * Split `getActiveLink` to get text & url separately * Removed unnecessary functions and `useCallback` hooks * removed unnecessary `focus` calls * Small fix * Inline code style fix * Added docs * Small update * Made selection undefined if nothing is selected * Added selection docs * Added styling docs * Added nesting & un-nesting docs * Small fix * Added selection demo * Minor fixes * fix: hyperlink creation menu losing focus on click (#168) * Doc changes & cleanup * feat: customizable sidemenu (#143) * simplify colorpicker * Added basic mouse cursor position * Added drag handle menu customization API * Copied changes from PR and minor improvements * Small changes * Added `DragHandleMenuItem` component * Fixed side menu unnecessary updates * Removed unnecessary state * Cleaned up code * Changed how the side menu gets the hovered block * Added side menu image * Added docs * Fixed comments in demo * Made all menus/toolbars scroll with page * Revert "Made all menus/toolbars scroll with page" This reverts commit ee61bd0. * Removed `editor` from dynamic props * Documentation changes * Small cleanup * Vanilla example fix --------- Co-authored-by: Matthew Lipski <[email protected]> Co-authored-by: Matthew Lipski <[email protected]> --------- Co-authored-by: Matthew Lipski <[email protected]> Co-authored-by: Matthew Lipski <[email protected]>
1 parent c864e1a commit 88a09a4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1563
-778
lines changed

examples/vanilla/src/ui/formattingToolbarFactory.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ export const formattingToolbarFactory: FormattingToolbarFactory = (
1414
container.style.padding = "10px";
1515
container.style.opacity = "0.8";
1616
const boldBtn = createButton("set bold", () => {
17-
staticParams.toggleBold();
17+
staticParams.editor.toggleStyles({ bold: true });
1818
});
1919
container.appendChild(boldBtn);
2020

2121
const linkBtn = createButton("set link", () => {
22-
staticParams.setHyperlink("https://www.google.com");
22+
staticParams.editor.createLink("https://www.google.com");
2323
});
2424

2525
container.appendChild(boldBtn);
@@ -34,7 +34,8 @@ export const formattingToolbarFactory: FormattingToolbarFactory = (
3434
container.style.display = "block";
3535
}
3636

37-
boldBtn.text = params.boldIsActive ? "unset bold" : "set bold";
37+
boldBtn.text =
38+
"bold" in staticParams.editor.getActiveStyles() ? "unset bold" : "set bold";
3839
container.style.top = params.referenceRect.y + "px";
3940
container.style.left = params.referenceRect.x + "px";
4041
},

packages/core/src/BlockNoteEditor.ts

Lines changed: 213 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ import {
2323
BlockIdentifier,
2424
PartialBlock,
2525
} from "./extensions/Blocks/api/blockTypes";
26+
import {
27+
ColorStyle,
28+
Styles,
29+
ToggledStyle,
30+
} from "./extensions/Blocks/api/inlineContentTypes";
2631
import { TextCursorPosition } from "./extensions/Blocks/api/cursorPositionTypes";
32+
import { Selection } from "./extensions/Blocks/api/selectionTypes";
2733
import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos";
2834
import {
2935
BaseSlashMenuItem,
@@ -102,6 +108,10 @@ export class BlockNoteEditor {
102108
return this._tiptapEditor.view.dom as HTMLDivElement;
103109
}
104110

111+
public focus() {
112+
this._tiptapEditor.view.focus();
113+
}
114+
105115
constructor(options: Partial<BlockNoteEditorOptions> = {}) {
106116
// apply defaults
107117
options = {
@@ -229,15 +239,15 @@ export class BlockNoteEditor {
229239

230240
function traverseBlockArray(blockArray: Block[]): boolean {
231241
for (const block of blockArray) {
232-
if (callback(block) === false) {
242+
if (!callback(block)) {
233243
return false;
234244
}
235245

236246
const children = reverse
237247
? block.children.slice().reverse()
238248
: block.children;
239249

240-
if (traverseBlockArray(children) === false) {
250+
if (!traverseBlockArray(children)) {
241251
return false;
242252
}
243253
}
@@ -319,6 +329,44 @@ export class BlockNoteEditor {
319329
}
320330
}
321331

332+
/**
333+
* Gets a snapshot of the current selection.
334+
*/
335+
public getSelection(): Selection | undefined {
336+
if (
337+
this._tiptapEditor.state.selection.from ===
338+
this._tiptapEditor.state.selection.to
339+
) {
340+
return undefined;
341+
}
342+
343+
const blocks: Block[] = [];
344+
345+
this._tiptapEditor.state.doc.descendants((node, pos) => {
346+
if (node.type.spec.group !== "blockContent") {
347+
return true;
348+
}
349+
350+
if (
351+
pos + node.nodeSize < this._tiptapEditor.state.selection.from ||
352+
pos > this._tiptapEditor.state.selection.to
353+
) {
354+
return true;
355+
}
356+
357+
blocks.push(
358+
nodeToBlock(
359+
this._tiptapEditor.state.doc.resolve(pos).node(),
360+
this.blockCache
361+
)
362+
);
363+
364+
return false;
365+
});
366+
367+
return { blocks: blocks };
368+
}
369+
322370
/**
323371
* Checks if the editor is currently editable, or if it's locked.
324372
* @returns True if the editor is editable, false otherwise.
@@ -384,6 +432,169 @@ export class BlockNoteEditor {
384432
replaceBlocks(blocksToRemove, blocksToInsert, this._tiptapEditor);
385433
}
386434

435+
/**
436+
* Gets the active text styles at the text cursor position or at the end of the current selection if it's active.
437+
*/
438+
public getActiveStyles() {
439+
const styles: Styles = {};
440+
const marks = this._tiptapEditor.state.selection.$to.marks();
441+
442+
const toggleStyles = new Set<ToggledStyle>([
443+
"bold",
444+
"italic",
445+
"underline",
446+
"strike",
447+
"code",
448+
]);
449+
const colorStyles = new Set<ColorStyle>(["textColor", "backgroundColor"]);
450+
451+
for (const mark of marks) {
452+
if (toggleStyles.has(mark.type.name as ToggledStyle)) {
453+
styles[mark.type.name as ToggledStyle] = true;
454+
} else if (colorStyles.has(mark.type.name as ColorStyle)) {
455+
styles[mark.type.name as ColorStyle] = mark.attrs.color;
456+
}
457+
}
458+
459+
return styles;
460+
}
461+
462+
/**
463+
* Adds styles to the currently selected content.
464+
* @param styles The styles to add.
465+
*/
466+
public addStyles(styles: Styles) {
467+
const toggleStyles = new Set<ToggledStyle>([
468+
"bold",
469+
"italic",
470+
"underline",
471+
"strike",
472+
"code",
473+
]);
474+
const colorStyles = new Set<ColorStyle>(["textColor", "backgroundColor"]);
475+
476+
for (const [style, value] of Object.entries(styles)) {
477+
if (toggleStyles.has(style as ToggledStyle)) {
478+
this._tiptapEditor.commands.setMark(style);
479+
} else if (colorStyles.has(style as ColorStyle)) {
480+
this._tiptapEditor.commands.setMark(style, { color: value });
481+
}
482+
}
483+
}
484+
485+
/**
486+
* Removes styles from the currently selected content.
487+
* @param styles The styles to remove.
488+
*/
489+
public removeStyles(styles: Styles) {
490+
for (const style of Object.keys(styles)) {
491+
this._tiptapEditor.commands.unsetMark(style);
492+
}
493+
}
494+
495+
/**
496+
* Toggles styles on the currently selected content.
497+
* @param styles The styles to toggle.
498+
*/
499+
public toggleStyles(styles: Styles) {
500+
const toggleStyles = new Set<ToggledStyle>([
501+
"bold",
502+
"italic",
503+
"underline",
504+
"strike",
505+
"code",
506+
]);
507+
const colorStyles = new Set<ColorStyle>(["textColor", "backgroundColor"]);
508+
509+
for (const [style, value] of Object.entries(styles)) {
510+
if (toggleStyles.has(style as ToggledStyle)) {
511+
this._tiptapEditor.commands.toggleMark(style);
512+
} else if (colorStyles.has(style as ColorStyle)) {
513+
this._tiptapEditor.commands.toggleMark(style, { color: value });
514+
}
515+
}
516+
}
517+
518+
/**
519+
* Gets the currently selected text.
520+
*/
521+
public getSelectedText() {
522+
return this._tiptapEditor.state.doc.textBetween(
523+
this._tiptapEditor.state.selection.from,
524+
this._tiptapEditor.state.selection.to
525+
);
526+
}
527+
528+
/**
529+
* Gets the URL of the last link in the current selection, or `undefined` if there are no links in the selection.
530+
*/
531+
public getSelectedLinkUrl() {
532+
return this._tiptapEditor.getAttributes("link").href as string | undefined;
533+
}
534+
535+
/**
536+
* Creates a new link to replace the selected content.
537+
* @param url The link URL.
538+
* @param text The text to display the link with.
539+
*/
540+
public createLink(url: string, text?: string) {
541+
if (url === "") {
542+
return;
543+
}
544+
545+
let { from, to } = this._tiptapEditor.state.selection;
546+
547+
if (!text) {
548+
text = this._tiptapEditor.state.doc.textBetween(from, to);
549+
}
550+
551+
const mark = this._tiptapEditor.schema.mark("link", { href: url });
552+
553+
this._tiptapEditor.view.dispatch(
554+
this._tiptapEditor.view.state.tr
555+
.insertText(text, from, to)
556+
.addMark(from, from + text.length, mark)
557+
);
558+
}
559+
560+
/**
561+
* Checks if the block containing the text cursor can be nested.
562+
*/
563+
public canNestBlock() {
564+
const { startPos, depth } = getBlockInfoFromPos(
565+
this._tiptapEditor.state.doc,
566+
this._tiptapEditor.state.selection.from
567+
)!;
568+
569+
return this._tiptapEditor.state.doc.resolve(startPos).index(depth - 1) > 0;
570+
}
571+
572+
/**
573+
* Nests the block containing the text cursor into the block above it.
574+
*/
575+
public nestBlock() {
576+
this._tiptapEditor.commands.sinkListItem("blockContainer");
577+
}
578+
579+
/**
580+
* Checks if the block containing the text cursor is nested.
581+
*/
582+
public canUnnestBlock() {
583+
const { depth } = getBlockInfoFromPos(
584+
this._tiptapEditor.state.doc,
585+
this._tiptapEditor.state.selection.from
586+
)!;
587+
588+
return depth > 2;
589+
}
590+
591+
/**
592+
* Lifts the block containing the text cursor out of its parent.
593+
*/
594+
public unnestBlock() {
595+
this._tiptapEditor.commands.liftListItem("blockContainer");
596+
}
597+
387598
/**
388599
* Serializes blocks into an HTML string. To better conform to HTML standards, children of blocks which aren't list
389600
* items are un-nested in the output HTML.

packages/core/src/BlockNoteExtensions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ import { FormattingToolbarFactory } from "./extensions/FormattingToolbar/Formatt
2424
import HyperlinkMark from "./extensions/HyperlinkToolbar/HyperlinkMark";
2525
import { HyperlinkToolbarFactory } from "./extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes";
2626
import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension";
27-
import { SlashMenuExtension } from "./extensions/SlashMenu";
28-
import { BaseSlashMenuItem } from "./extensions/SlashMenu";
27+
import { BaseSlashMenuItem, SlashMenuExtension } from "./extensions/SlashMenu";
2928
import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension";
3029
import { TextColorExtension } from "./extensions/TextColor/TextColorExtension";
3130
import { TextColorMark } from "./extensions/TextColor/TextColorMark";
@@ -100,6 +99,7 @@ export const getBlockNoteExtensions = (opts: {
10099
if (opts.uiFactories.blockSideMenuFactory) {
101100
ret.push(
102101
DraggableBlocksExtension.configure({
102+
editor: opts.editor,
103103
blockSideMenuFactory: opts.uiFactories.blockSideMenuFactory,
104104
})
105105
);
@@ -108,6 +108,7 @@ export const getBlockNoteExtensions = (opts: {
108108
if (opts.uiFactories.formattingToolbarFactory) {
109109
ret.push(
110110
FormattingToolbarExtension.configure({
111+
editor: opts.editor,
111112
formattingToolbarFactory: opts.uiFactories.formattingToolbarFactory,
112113
})
113114
);

packages/core/src/api/nodeConversions/nodeConversions.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,27 @@ import {
66
PartialBlock,
77
} from "../../extensions/Blocks/api/blockTypes";
88
import {
9-
ColorStyles,
9+
ColorStyle,
1010
InlineContent,
1111
Link,
1212
PartialInlineContent,
1313
PartialLink,
1414
StyledText,
1515
Styles,
16-
ToggledStyles,
16+
ToggledStyle,
1717
} from "../../extensions/Blocks/api/inlineContentTypes";
1818
import { getBlockInfoFromPos } from "../../extensions/Blocks/helpers/getBlockInfoFromPos";
1919
import UniqueID from "../../extensions/UniqueID/UniqueID";
2020
import { UnreachableCaseError } from "../../shared/utils";
2121

22-
const toggleStyles = new Set<ToggledStyles>([
22+
const toggleStyles = new Set<ToggledStyle>([
2323
"bold",
2424
"italic",
2525
"underline",
2626
"strike",
2727
"code",
2828
]);
29-
const colorStyles = new Set<ColorStyles>(["textColor", "backgroundColor"]);
29+
const colorStyles = new Set<ColorStyle>(["textColor", "backgroundColor"]);
3030

3131
/**
3232
* Convert a StyledText inline element to a
@@ -36,9 +36,9 @@ function styledTextToNode(styledText: StyledText, schema: Schema): Node {
3636
const marks: Mark[] = [];
3737

3838
for (const [style, value] of Object.entries(styledText.styles)) {
39-
if (toggleStyles.has(style as ToggledStyles)) {
39+
if (toggleStyles.has(style as ToggledStyle)) {
4040
marks.push(schema.mark(style));
41-
} else if (colorStyles.has(style as ColorStyles)) {
41+
} else if (colorStyles.has(style as ColorStyle)) {
4242
marks.push(schema.mark(style, { color: value }));
4343
}
4444
}
@@ -168,10 +168,10 @@ function contentNodeToInlineContent(contentNode: Node) {
168168
for (const mark of node.marks) {
169169
if (mark.type.name === "link") {
170170
linkMark = mark;
171-
} else if (toggleStyles.has(mark.type.name as ToggledStyles)) {
172-
styles[mark.type.name as ToggledStyles] = true;
173-
} else if (colorStyles.has(mark.type.name as ColorStyles)) {
174-
styles[mark.type.name as ColorStyles] = mark.attrs.color;
171+
} else if (toggleStyles.has(mark.type.name as ToggledStyle)) {
172+
styles[mark.type.name as ToggledStyle] = true;
173+
} else if (colorStyles.has(mark.type.name as ColorStyle)) {
174+
styles[mark.type.name as ColorStyle] = mark.attrs.color;
175175
} else {
176176
throw Error("Mark is of an unrecognized type: " + mark.type.name);
177177
}

packages/core/src/extensions/Blocks/api/inlineContentTypes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ export type Styles = {
88
backgroundColor?: string;
99
};
1010

11-
export type ToggledStyles = {
11+
export type ToggledStyle = {
1212
[K in keyof Styles]-?: Required<Styles>[K] extends true ? K : never;
1313
}[keyof Styles];
1414

15-
export type ColorStyles = {
15+
export type ColorStyle = {
1616
[K in keyof Styles]-?: Required<Styles>[K] extends string ? K : never;
1717
}[keyof Styles];
1818

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Block } from "./blockTypes";
2+
3+
export type Selection = {
4+
blocks: Block[];
5+
};

0 commit comments

Comments
 (0)