diff --git a/packages/context-editor/src/commands/blocks.ts b/packages/context-editor/src/commands/blocks.ts new file mode 100644 index 000000000..36c38c451 --- /dev/null +++ b/packages/context-editor/src/commands/blocks.ts @@ -0,0 +1,89 @@ +import { lift, setBlockType, wrapIn } from "prosemirror-commands"; +import { Node, NodeType } from "prosemirror-model"; +import { NodeSelection } from "prosemirror-state"; + +import type { Attrs, ToggleCommandFn, ToggleOptions } from "./types"; +import { createTypeToggle } from "./utils"; + +const nodeMatchesTypeAndAttrs = (node: Node, type: NodeType, attrs?: Attrs) => { + if (node.type === type) { + if (!attrs) { + return true; + } + return Object.keys(attrs).every((key) => attrs[key] === node.attrs[key]); + } + return false; +}; + +const blockTypeIsActive = (options: ToggleOptions) => { + const { state, type, withAttrs } = options; + if (!type) { + return false; + } + + const { $from } = state.selection; + const selectedNode = (state.selection as NodeSelection).node; + if (selectedNode && nodeMatchesTypeAndAttrs(selectedNode, type, withAttrs)) { + return true; + } + + let currentDepth = $from.depth; + while (currentDepth > 0) { + const currentNodeAtDepth = $from.node(currentDepth); + if (nodeMatchesTypeAndAttrs(currentNodeAtDepth, type, withAttrs)) { + return true; + } + currentDepth -= 1; + } + + return false; +}; + +const toggleBlockType = (options: ToggleOptions) => { + const { state, type, withAttrs, dispatch } = options; + const { schema } = state; + const isActive = blockTypeIsActive(options); + const newNodeType = isActive ? schema.nodes.paragraph : type; + const setBlockFunction = setBlockType(newNodeType, withAttrs); + return setBlockFunction(state, dispatch); +}; + +const toggleWrap = (options: ToggleOptions) => { + const { state, type, dispatch } = options; + if (blockTypeIsActive(options)) { + return lift(state, dispatch); + } + return wrapIn(type)(state, dispatch); +}; + +const createBlockTypeToggle = (options: { + typeName: string; + withAttrs?: Attrs; + commandFn?: ToggleCommandFn; +}) => { + const { typeName, withAttrs, commandFn = toggleBlockType } = options; + return createTypeToggle({ + withAttrs, + commandFn, + isActiveFn: blockTypeIsActive, + getTypeFromSchema: (schema) => schema.nodes[typeName] as NodeType, + }); +}; + +export const createHeadingBlockTypeToggle = (level: number) => { + return createBlockTypeToggle({ typeName: "heading", withAttrs: { level } }); +}; + +export const paragraphToggle = createBlockTypeToggle({ typeName: "paragraph" }); +export const heading1Toggle = createHeadingBlockTypeToggle(1); +export const heading2Toggle = createHeadingBlockTypeToggle(2); +export const heading3Toggle = createHeadingBlockTypeToggle(3); +export const heading4Toggle = createHeadingBlockTypeToggle(4); +export const heading5Toggle = createHeadingBlockTypeToggle(5); +export const heading6Toggle = createHeadingBlockTypeToggle(6); +export const blockquoteToggle = createBlockTypeToggle({ + typeName: "blockquote", + commandFn: toggleWrap, +}); +// TODO +export const codeBlockToggle = createBlockTypeToggle({ typeName: "code_block" }); diff --git a/packages/context-editor/src/commands/marks.ts b/packages/context-editor/src/commands/marks.ts new file mode 100644 index 000000000..fb43c303f --- /dev/null +++ b/packages/context-editor/src/commands/marks.ts @@ -0,0 +1,32 @@ +import type { MarkType } from "prosemirror-model"; + +import { toggleMark as pmToggleMark } from "prosemirror-commands"; + +import type { ToggleOptions } from "./types"; +import { createTypeToggle } from "./utils"; + +export const markIsActive = (options: ToggleOptions) => { + const { type, state } = options; + const { from, $from, to, empty } = state.selection; + if (empty) { + return !!type.isInSet(state.storedMarks || $from.marks()); + } + return state.doc.rangeHasMark(from, to, type); +}; + +const toggleMark = (options: ToggleOptions) => { + const { state, dispatch, type } = options; + return pmToggleMark(type)(state, dispatch); +}; + +export const createMarkToggle = (typeName: string) => { + return createTypeToggle({ + getTypeFromSchema: (schema) => schema.marks[typeName] as MarkType, + commandFn: toggleMark, + isActiveFn: markIsActive, + }); +}; + +export const strongToggle = createMarkToggle("strong"); +export const emToggle = createMarkToggle("em"); +export const codeToggle = createMarkToggle("code"); diff --git a/packages/context-editor/src/commands/types.ts b/packages/context-editor/src/commands/types.ts new file mode 100644 index 000000000..534f35035 --- /dev/null +++ b/packages/context-editor/src/commands/types.ts @@ -0,0 +1,48 @@ +import type { Mark, MarkType, Node, NodeType, Schema } from "prosemirror-model"; +import type { EditorState } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import type { ReactNode } from "react"; + +export type Dispatch = EditorView["dispatch"]; +export type Attrs = Node["attrs"] | Mark["attrs"]; + +export type CommandState = { + run: () => unknown; + canRun: boolean; + isActive: boolean; +}; + +export type MenuItemBase = { + key: string; + icon: ReactNode; +}; + +export type CommandStateBuilder = (dispatch: Dispatch, state: EditorState) => CommandState; +export type CommandSpec = (view: EditorView) => (state: EditorState) => CommandState; + +export type CommandDefinition = MenuItemBase & { + command?: CommandSpec; +}; + +export type CommandSubmenu = MenuItemBase & { + commands: CommandDefinition[]; +}; + +export type SchemaType = NodeType | MarkType; + +export type ToggleActiveFn = (options: ToggleOptions) => boolean; +export type ToggleCommandFn = (options: ToggleOptions) => boolean; + +export type ToggleOptions = { + state: EditorState; + type: S; + withAttrs?: Attrs; + dispatch?: Dispatch; +}; + +export type CreateToggleOptions = { + withAttrs?: Attrs; + getTypeFromSchema: (schema: Schema) => S; + commandFn: ToggleCommandFn; + isActiveFn: ToggleActiveFn; +}; diff --git a/packages/context-editor/src/commands/utils.ts b/packages/context-editor/src/commands/utils.ts new file mode 100644 index 000000000..0e75bf308 --- /dev/null +++ b/packages/context-editor/src/commands/utils.ts @@ -0,0 +1,27 @@ +import type { EditorState } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; + +import type { + CommandSpec, + CommandStateBuilder, + CreateToggleOptions, + SchemaType, + ToggleOptions, +} from "./types"; + +export const createCommandSpec = (builder: CommandStateBuilder): CommandSpec => { + return (view: EditorView) => (state: EditorState) => builder(view.dispatch, state); +}; + +export const createTypeToggle = (options: CreateToggleOptions) => { + const { getTypeFromSchema, withAttrs, commandFn, isActiveFn } = options; + return createCommandSpec((dispatch, state) => { + const type = getTypeFromSchema(state.schema); + const toggleOptions: ToggleOptions = { state, type, withAttrs }; + return { + run: () => commandFn({ ...toggleOptions, dispatch }), + canRun: commandFn(toggleOptions), + isActive: type && isActiveFn(toggleOptions), + }; + }); +}; diff --git a/packages/context-editor/src/components/MenuBar.tsx b/packages/context-editor/src/components/MenuBar.tsx index a9b8f35a2..4d618807f 100644 --- a/packages/context-editor/src/components/MenuBar.tsx +++ b/packages/context-editor/src/components/MenuBar.tsx @@ -1,58 +1,154 @@ -import type { MarkType } from "prosemirror-model"; -import type { Command, EditorState } from "prosemirror-state"; import type { ReactNode } from "react"; import React from "react"; import { usePluginViewContext } from "@prosemirror-adapter/react"; -import { toggleMark } from "prosemirror-commands"; +import { Quote } from "lucide-react"; import { Button } from "ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select"; import { cn } from "utils"; -import { baseSchema } from "../schemas"; -import { markIsActive } from "../utils/marks"; +import type { CommandSpec } from "../commands/types"; +import { + blockquoteToggle, + heading1Toggle, + heading2Toggle, + heading3Toggle, + heading4Toggle, + heading5Toggle, + heading6Toggle, + paragraphToggle, +} from "../commands/blocks"; +import { emToggle, strongToggle } from "../commands/marks"; -interface MenuItem { - name: string; +type MenuItem = { + key: string; + name?: string; icon: ReactNode; - type: MarkType; // eventually should also be NodeType - command: Command; -} + command: CommandSpec; +}; const menuItems: MenuItem[] = [ { - name: "strong", + key: "strong", icon: "B", - type: baseSchema.marks.strong, - command: toggleMark(baseSchema.marks.strong), + command: strongToggle, }, { - name: "em", + key: "em", icon: I, - type: baseSchema.marks.em, - command: toggleMark(baseSchema.marks.em), + command: emToggle, + }, + { + key: "blockquote", + icon: , + command: blockquoteToggle, + }, +]; + +const paragraphTypeItems: MenuItem[] = [ + { + key: "paragraph", + name: "Paragraph", + icon: "Paragraph", + command: paragraphToggle, + }, + { + key: "h1", + name: "Heading 1", + icon: Heading 1, + command: heading1Toggle, + }, + { + key: "h2", + name: "Heading 2", + icon: Heading 2, + command: heading2Toggle, + }, + { + key: "h3", + name: "Heading 3", + icon: Heading 3, + command: heading3Toggle, + }, + { + key: "h4", + name: "Heading 4", + icon: Heading 4, + command: heading4Toggle, + }, + { + key: "h5", + name: "Heading 5", + icon: Heading 5, + command: heading5Toggle, + }, + { + key: "h6", + name: "Heading 6", + icon: Heading 6, + command: heading6Toggle, }, ]; +const ParagraphDropdown = () => { + const { view } = usePluginViewContext(); + const activeType = paragraphTypeItems.find((item) => item.command(view)(view.state).isActive); + + return ( + + ); +}; + export const MenuBar = () => { const { view } = usePluginViewContext(); return ( -
+
{menuItems.map((menuItem) => { - const { name, icon, command, type } = menuItem; - // Returns if given command can be applied at the cursor selection - const isApplicable = command(view.state, undefined, view); - const isActive = markIsActive(type, view.state); + const { key, icon, command } = menuItem; + const { run, canRun, isActive } = command(view)(view.state); return ( ); })} +
); }; diff --git a/packages/context-editor/src/plugins/inputRules.test.ts b/packages/context-editor/src/plugins/inputRules.test.ts index 665052f13..97da60838 100644 --- a/packages/context-editor/src/plugins/inputRules.test.ts +++ b/packages/context-editor/src/plugins/inputRules.test.ts @@ -2,8 +2,8 @@ import { EditorState, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { describe, expect, test } from "vitest"; +import { markIsActive } from "../commands/marks"; import { baseSchema } from "../schemas"; -import { markIsActive } from "../utils/marks"; import customRules from "./inputRules"; describe("inputRules", () => { @@ -61,8 +61,10 @@ describe("inputRules", () => { ])("italics $text", ({ text, expected }) => { expect(write(text)).toEqual(expected.text); moveSelection(1); - expect(markIsActive(baseSchema.marks.em, view.state)).toEqual(expected.isItalicized); - expect(markIsActive(baseSchema.marks.strong, view.state)).toBeFalsy(); + expect(markIsActive({ state: view.state, type: baseSchema.marks.em })).toEqual( + expected.isItalicized + ); + expect(markIsActive({ state: view.state, type: baseSchema.marks.strong })).toBeFalsy(); }); test.each([ @@ -75,7 +77,11 @@ describe("inputRules", () => { ])("bold $text", ({ text, expected }) => { expect(write(text)).toEqual(expected.text); moveSelection(1); - expect(markIsActive(baseSchema.marks.strong, view.state)).toEqual(expected.isBold); - expect(markIsActive(baseSchema.marks.em, view.state)).toEqual(expected.isItalicized); + expect(markIsActive({ state: view.state, type: baseSchema.marks.strong })).toEqual( + expected.isBold + ); + expect(markIsActive({ state: view.state, type: baseSchema.marks.em })).toEqual( + expected.isItalicized + ); }); }); diff --git a/packages/context-editor/src/plugins/inputRules.ts b/packages/context-editor/src/plugins/inputRules.ts index f3969122c..7a714ea60 100644 --- a/packages/context-editor/src/plugins/inputRules.ts +++ b/packages/context-editor/src/plugins/inputRules.ts @@ -1,7 +1,7 @@ -import type { MarkType } from "prosemirror-model"; +import type { MarkType, NodeType } from "prosemirror-model"; import type { EditorState } from "prosemirror-state"; -import { InputRule, inputRules } from "prosemirror-inputrules"; +import { InputRule, inputRules, wrappingInputRule } from "prosemirror-inputrules"; import { Fragment, Schema } from "prosemirror-model"; import initialDoc from "../stories/initialDoc.json"; @@ -39,6 +39,7 @@ const applyMarkRule = (markType: MarkType, regex: RegExp) => { } ); }; +const blockQuoteRule = (nodeType: NodeType) => wrappingInputRule(/^\s*>\s$/, nodeType); export default (schema: Schema) => { const rules = [ @@ -54,6 +55,7 @@ export default (schema: Schema) => { // Prosemirror applies the first rule that matches applyMarkRule(schema.marks.strong, boldRegex), applyMarkRule(schema.marks.em, italicsRegex), + blockQuoteRule(schema.nodes.blockquote), ]; return inputRules({ rules }); }; diff --git a/packages/context-editor/src/schemas/blockquote.ts b/packages/context-editor/src/schemas/blockquote.ts new file mode 100644 index 000000000..983567cad --- /dev/null +++ b/packages/context-editor/src/schemas/blockquote.ts @@ -0,0 +1,31 @@ +import type { DOMOutputSpec, MarkSpec, NodeSpec } from "prosemirror-model"; + +export default { + attrs: { + id: { default: null }, + class: { default: null }, + }, + content: "block+", + group: "block", + selectable: false, + parseDOM: [ + { + tag: "blockquote", + getAttrs: (node) => { + return { + id: (node as Element).getAttribute("id"), + }; + }, + }, + ], + toDOM: (node) => { + return [ + "blockquote", + { + class: node.attrs.class, + ...(node.attrs.id && { id: node.attrs.id }), + }, + 0, + ] as DOMOutputSpec; + }, +} satisfies NodeSpec; diff --git a/packages/context-editor/src/schemas/code.ts b/packages/context-editor/src/schemas/code.ts new file mode 100644 index 000000000..e900692fe --- /dev/null +++ b/packages/context-editor/src/schemas/code.ts @@ -0,0 +1,18 @@ +import type { DOMOutputSpec, MarkSpec } from "prosemirror-model"; + +export default { + attrs: { + id: { default: null }, + class: { default: null }, + }, + parseDOM: [{ tag: "code" }], + toDOM: (mark) => { + return [ + "code", + { + class: mark.attrs.class, + ...(mark.attrs.id && { id: mark.attrs.id }), + }, + ] as DOMOutputSpec; + }, +} satisfies MarkSpec; diff --git a/packages/context-editor/src/schemas/index.ts b/packages/context-editor/src/schemas/index.ts index 5c2d8b3a4..2a7fc1272 100644 --- a/packages/context-editor/src/schemas/index.ts +++ b/packages/context-editor/src/schemas/index.ts @@ -1,5 +1,7 @@ import { Schema } from "prosemirror-model"; +import blockquote from "./blockquote"; +import inlineCode from "./code"; import contextAtom from "./contextAtom"; import contextDoc from "./contextDoc"; // import { marks, nodes } from "prosemirror-schema-basic"; @@ -21,10 +23,12 @@ export const baseSchema = new Schema({ heading, contextDoc, contextAtom, + blockquote, }, marks: { strong, em, + code: inlineCode, }, topNode: "doc", }); diff --git a/packages/context-editor/src/style.css b/packages/context-editor/src/style.css index 69d8dacea..7f0deda5a 100644 --- a/packages/context-editor/src/style.css +++ b/packages/context-editor/src/style.css @@ -138,3 +138,7 @@ overflow: scroll; position: relative; } +.ProseMirror blockquote { + padding-left: 1rem; + border-left: solid 4px rgba(181, 181, 181, 0.5); +} diff --git a/packages/context-editor/src/utils/marks.ts b/packages/context-editor/src/utils/marks.ts deleted file mode 100644 index 3a6ddf1c4..000000000 --- a/packages/context-editor/src/utils/marks.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MarkType } from "prosemirror-model"; -import type { EditorState } from "prosemirror-state"; - -export const markIsActive = (markType: MarkType, editorState: EditorState) => { - const { from, $from, to, empty } = editorState.selection; - if (empty) { - return !!markType.isInSet(editorState.storedMarks || $from.marks()); - } - return editorState.doc.rangeHasMark(from, to, markType); -};