Skip to content

Commit

Permalink
feat: Color scheme customization (#305)
Browse files Browse the repository at this point in the history
* Added color scheme customization

* Improved theming API

* Improved theming and added user-definable DOM element classes

* Separated light/dark themes and added system theme detection

* Added `BlockNoteView` prop for styling components with CSS

* Added util function to merge CSS classes

* Fixed placeholder color

* Cleaned up auto theme switching and editor is no longer re-created on theme switch

* Cleaned up `BlockNoteTheme.ts`

* Minor fixes

* Cleaned up theme highlight colors

* Custom component styles now get deep merged with defaults

* Fixed highlight colors

* Added theming & styling docs

* Cleaned up theme types and component styles

* Added user-defined classes to remaining nodes

* Updated theme selection in docs

* Removed type field from default themes

* Reverted `App.tsx`

* Implemented PR feedback

* Fixed side menu button colors

* Added dark theme tests

* Fixed test

* Added screenshots
  • Loading branch information
matthewlipski authored Aug 13, 2023
1 parent 7b505b4 commit 6309de0
Show file tree
Hide file tree
Showing 64 changed files with 1,269 additions and 503 deletions.
9 changes: 5 additions & 4 deletions examples/editor/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ function App() {
onEditorContentChange: (editor) => {
console.log(editor.topLevelBlocks);
},
editorDOMAttributes: {
class: styles.editor,
"data-test": "editor",
domAttributes: {
editor: {
class: styles.editor,
"data-test": "editor",
},
},
theme: "light",
});

// Give tests a way to get prosemirror instance
Expand Down
6 changes: 4 additions & 2 deletions examples/vanilla/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ const editor = new BlockNoteEditor({
onEditorContentChange: () => {
console.log(editor.topLevelBlocks);
},
editorDOMAttributes: {
class: "editor",
domAttributes: {
editor: {
class: "editor",
},
},
});

Expand Down
26 changes: 11 additions & 15 deletions packages/core/src/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {
updateBlock,
} from "./api/blockManipulation/blockManipulation";
import {
HTMLToBlocks,
blocksToHTML,
blocksToMarkdown,
HTMLToBlocks,
markdownToBlocks,
} from "./api/formatConversions/formatConversions";
import {
Expand All @@ -25,6 +25,7 @@ import styles from "./editor.module.css";
import {
Block,
BlockIdentifier,
BlockNoteDOMAttributes,
BlockSchema,
PartialBlock,
} from "./extensions/Blocks/api/blockTypes";
Expand All @@ -48,6 +49,7 @@ import { BaseSlashMenuItem } from "./extensions/SlashMenu/BaseSlashMenuItem";
import { SlashMenuProsemirrorPlugin } from "./extensions/SlashMenu/SlashMenuPlugin";
import { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems";
import { UniqueID } from "./extensions/UniqueID/UniqueID";
import { mergeCSSClasses } from "./shared/utils";

export type BlockNoteEditorOptions<BSchema extends BlockSchema> = {
// TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them.
Expand All @@ -67,11 +69,11 @@ export type BlockNoteEditorOptions<BSchema extends BlockSchema> = {
*/
parentElement: HTMLElement;
/**
* An object containing attributes that should be added to the editor's HTML element.
* An object containing attributes that should be added to HTML elements of the editor.
*
* @example { class: "my-editor-class" }
* @example { editor: { class: "my-editor-class" } }
*/
editorDOMAttributes: Record<string, string>;
domAttributes: Partial<BlockNoteDOMAttributes>;
/**
* A callback function that runs when the editor is ready to be used.
*/
Expand All @@ -98,12 +100,6 @@ export type BlockNoteEditorOptions<BSchema extends BlockSchema> = {
* @default true
*/
defaultStyles: boolean;
/**
* Whether to use the light or dark theme.
*
* @default "light"
*/
theme: "light" | "dark";

/**
* A list of block types that should be available in the editor.
Expand Down Expand Up @@ -185,6 +181,7 @@ export class BlockNoteEditor<BSchema extends BlockSchema = DefaultBlockSchema> {

const extensions = getBlockNoteExtensions<BSchema>({
editor: this,
domAttributes: newOptions.domAttributes || {},
blockSchema: newOptions.blockSchema,
collaboration: newOptions.collaboration,
});
Expand Down Expand Up @@ -266,14 +263,13 @@ export class BlockNoteEditor<BSchema extends BlockSchema = DefaultBlockSchema> {
: [...(newOptions._tiptapOptions?.extensions || []), ...extensions],
editorProps: {
attributes: {
"data-theme": options.theme || "light",
...(newOptions.editorDOMAttributes || {}),
class: [
...newOptions.domAttributes?.editor,
class: mergeCSSClasses(
styles.bnEditor,
styles.bnRoot,
newOptions.defaultStyles ? styles.defaultStyles : "",
newOptions.editorDOMAttributes?.class || "",
].join(" "),
newOptions.domAttributes?.editor?.class || ""
),
},
},
};
Expand Down
23 changes: 18 additions & 5 deletions packages/core/src/BlockNoteExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ import * as Y from "yjs";
import styles from "./editor.module.css";
import { BackgroundColorExtension } from "./extensions/BackgroundColor/BackgroundColorExtension";
import { BackgroundColorMark } from "./extensions/BackgroundColor/BackgroundColorMark";
import { blocks } from "./extensions/Blocks";
import { BlockSchema } from "./extensions/Blocks/api/blockTypes";
import { BlockContainer, BlockGroup, Doc } from "./extensions/Blocks";
import {
BlockNoteDOMAttributes,
BlockSchema,
} from "./extensions/Blocks/api/blockTypes";
import { CustomBlockSerializerExtension } from "./extensions/Blocks/api/serialization";
import blockStyles from "./extensions/Blocks/nodes/Block.module.css";
import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension";
Expand All @@ -35,6 +38,7 @@ import UniqueID from "./extensions/UniqueID/UniqueID";
*/
export const getBlockNoteExtensions = <BSchema extends BlockSchema>(opts: {
editor: BlockNoteEditor<BSchema>;
domAttributes: Partial<BlockNoteDOMAttributes>;
blockSchema: BSchema;
collaboration?: {
fragment: Y.XmlFragment;
Expand Down Expand Up @@ -86,10 +90,19 @@ export const getBlockNoteExtensions = <BSchema extends BlockSchema>(opts: {
BackgroundColorExtension,
TextAlignmentExtension,

// custom blocks:
...blocks,
// nodes
Doc,
BlockContainer.configure({
domAttributes: opts.domAttributes,
}),
BlockGroup.configure({
domAttributes: opts.domAttributes,
}),
...Object.values(opts.blockSchema).map((blockSpec) =>
blockSpec.node.configure({ editor: opts.editor })
blockSpec.node.configure({
editor: opts.editor,
domAttributes: opts.domAttributes,
})
),
CustomBlockSerializerExtension,

Expand Down
11 changes: 0 additions & 11 deletions packages/core/src/editor.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
.bnEditor {
outline: none;
padding-inline: 54px;
border-radius: 8px;

/* Define a set of colors to be used throughout the app for consistency
see https://atlassian.design/foundations/color for more info */
Expand Down Expand Up @@ -55,16 +54,6 @@ Tippy popups that are appended to document.body directly
-moz-osx-font-smoothing: grayscale;
}

[data-theme="light"] {
background-color: #FFFFFF;
color: #3F3F3F;
}

[data-theme="dark"] {
background-color: #1F1F1F;
color: #CFCFCF;
}

.dragPreview {
position: absolute;
top: -1000px;
Expand Down
77 changes: 55 additions & 22 deletions packages/core/src/extensions/Blocks/api/block.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Attribute, Node } from "@tiptap/core";
import { BlockNoteEditor } from "../../..";
import { BlockNoteDOMAttributes, BlockNoteEditor } from "../../..";
import styles from "../nodes/Block.module.css";
import {
BlockConfig,
Expand All @@ -9,6 +9,7 @@ import {
TipTapNode,
TipTapNodeConfig,
} from "./blockTypes";
import { mergeCSSClasses } from "../../../shared/utils";

export function camelToDataKebab(str: string): string {
return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
Expand Down Expand Up @@ -124,17 +125,17 @@ export function createBlockSpec<
>(
blockConfig: BlockConfig<BType, PSchema, ContainsInlineContent, BSchema>
): BlockSpec<BType, PSchema> {
const node = createTipTapBlock<BType>({
const node = createTipTapBlock<
BType,
{
editor: BlockNoteEditor<BSchema>;
domAttributes?: BlockNoteDOMAttributes;
}
>({
name: blockConfig.type,
content: blockConfig.containsInlineContent ? "inline*" : "",
selectable: blockConfig.containsInlineContent,

addOptions() {
return {
editor: undefined,
};
},

addAttributes() {
return propsToAttributes(blockConfig);
},
Expand All @@ -151,8 +152,21 @@ export function createBlockSpec<
return ({ HTMLAttributes, getPos }) => {
// Create blockContent element
const blockContent = document.createElement("div");
// Sets blockContent class
blockContent.className = styles.blockContent;
// Add custom HTML attributes
const blockContentDOMAttributes =
this.options.domAttributes?.blockContent || {};
for (const [attribute, value] of Object.entries(
blockContentDOMAttributes
)) {
if (attribute !== "class") {
blockContent.setAttribute(attribute, value);
}
}
// Set blockContent & custom classes
blockContent.className = mergeCSSClasses(
styles.blockContent,
blockContentDOMAttributes.class
);
// Add blockContent HTML attribute
blockContent.setAttribute("data-content-type", blockConfig.type);
// Add props as HTML attributes in kebab-case with "data-" prefix
Expand Down Expand Up @@ -186,13 +200,24 @@ export function createBlockSpec<

// Render elements
const rendered = blockConfig.render(block as any, editor);
// Add inlineContent class to inline content
// Add HTML attributes to contentDOM
if ("contentDOM" in rendered) {
rendered.contentDOM.className = `${
rendered.contentDOM.className
? rendered.contentDOM.className + " "
: ""
}${styles.inlineContent}`;
const inlineContentDOMAttributes =
this.options.domAttributes?.inlineContent || {};
// Add custom HTML attributes
for (const [attribute, value] of Object.entries(
inlineContentDOMAttributes
)) {
if (attribute !== "class") {
rendered.contentDOM.setAttribute(attribute, value);
}
}
// Merge existing classes with inlineContent & custom classes
rendered.contentDOM.className = mergeCSSClasses(
rendered.contentDOM.className,
styles.inlineContent,
inlineContentDOMAttributes.class
);
}
// Add elements to blockContent
blockContent.appendChild(rendered.dom);
Expand All @@ -210,20 +235,28 @@ export function createBlockSpec<
});

return {
node: node,
node: node as TipTapNode<BType>,
propSchema: blockConfig.propSchema,
};
}

export function createTipTapBlock<Type extends string>(
config: TipTapNodeConfig<Type>
): TipTapNode<Type> {
export function createTipTapBlock<
Type extends string,
Options extends {
domAttributes?: BlockNoteDOMAttributes;
} = {
domAttributes?: BlockNoteDOMAttributes;
},
Storage = any
>(
config: TipTapNodeConfig<Type, Options, Storage>
): TipTapNode<Type, Options, Storage> {
// Type cast is needed as Node.name is mutable, though there is basically no
// reason to change it after creation. Alternative is to wrap Node in a new
// class, which I don't think is worth it since we'd only be changing 1
// attribute to be read only.
return Node.create({
return Node.create<Options, Storage>({
...config,
group: "blockContent",
}) as TipTapNode<Type>;
}) as TipTapNode<Type, Options, Storage>;
}
25 changes: 22 additions & 3 deletions packages/core/src/extensions/Blocks/api/blockTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,28 @@ import { BlockNoteEditor } from "../../../BlockNoteEditor";
import { InlineContent, PartialInlineContent } from "./inlineContentTypes";
import { DefaultBlockSchema } from "./defaultBlocks";

export type BlockNoteDOMElement =
| "editor"
| "blockContainer"
| "blockGroup"
| "blockContent"
| "inlineContent";

export type BlockNoteDOMAttributes = Partial<{
[DOMElement in BlockNoteDOMElement]: Record<string, string>;
}>;

// A configuration for a TipTap node, but with stricter type constraints on the
// "name" and "group" properties. The "name" property is now always a string
// literal type, and the "blockGroup" property cannot be configured as it should
// always be "blockContent". Used as the parameter in `createTipTapNode`.
export type TipTapNodeConfig<
Name extends string,
Options = any,
Options extends {
domAttributes?: BlockNoteDOMAttributes;
} = {
domAttributes?: BlockNoteDOMAttributes;
},
Storage = any
> = {
[K in keyof NodeConfig<Options, Storage>]: K extends "name"
Expand All @@ -25,7 +40,11 @@ export type TipTapNodeConfig<
// "blockGroup" property is now "blockContent". Returned by `createTipTapNode`.
export type TipTapNode<
Name extends string,
Options = any,
Options extends {
domAttributes?: BlockNoteDOMAttributes;
} = {
domAttributes?: BlockNoteDOMAttributes;
},
Storage = any
> = Node<Options, Storage> & {
name: Name;
Expand Down Expand Up @@ -104,7 +123,7 @@ export type BlockConfig<
// allowing for more advanced custom blocks.
export type BlockSpec<Type extends string, PSchema extends PropSchema> = {
readonly propSchema: PSchema;
node: TipTapNode<Type>;
node: TipTapNode<Type, any>;
};

// Utility type. For a given object block schema, ensures that the key of each
Expand Down
19 changes: 7 additions & 12 deletions packages/core/src/extensions/Blocks/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { Node } from "@tiptap/core";
import { BlockContainer } from "./nodes/BlockContainer";
import { BlockGroup } from "./nodes/BlockGroup";

export const blocks: any[] = [
BlockContainer,
BlockGroup,
Node.create({
name: "doc",
topNode: true,
content: "blockGroup",
}),
];
export { BlockContainer } from "./nodes/BlockContainer";
export { BlockGroup } from "./nodes/BlockGroup";
export const Doc = Node.create({
name: "doc",
topNode: true,
content: "blockGroup",
});
2 changes: 1 addition & 1 deletion packages/core/src/extensions/Blocks/nodes/Block.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ NESTED BLOCKS

/* TODO: would be nicer if defined from code */

.isEmpty.hasAnchor .inlineContent:before {
.blockContent.isEmpty.hasAnchor .inlineContent:before {
content: "Enter text or type '/' for commands";
}

Expand Down
Loading

1 comment on commit 6309de0

@vercel
Copy link

@vercel vercel bot commented on 6309de0 Aug 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.