Skip to content

Commit

Permalink
Paragraph selector (#999)
Browse files Browse the repository at this point in the history
* Refactor using functions and types from legacy

* Paragraph selector

* Get active state

* Fix label of selected element

* Add blockquotes

* Restore placeholder

* Simplify finding activeType

Co-authored-by: Eric McDaniel <[email protected]>

---------

Co-authored-by: Eric McDaniel <[email protected]>
  • Loading branch information
allisonking and 3mcd authored Feb 27, 2025
1 parent ba26e86 commit 20eba94
Show file tree
Hide file tree
Showing 12 changed files with 389 additions and 41 deletions.
89 changes: 89 additions & 0 deletions packages/context-editor/src/commands/blocks.ts
Original file line number Diff line number Diff line change
@@ -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<NodeType>) => {
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<NodeType>) => {
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<NodeType>) => {
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<NodeType>;
}) => {
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" });
32 changes: 32 additions & 0 deletions packages/context-editor/src/commands/marks.ts
Original file line number Diff line number Diff line change
@@ -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<MarkType>) => {
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<MarkType>) => {
const { state, dispatch, type } = options;
return pmToggleMark(type)(state, dispatch);
};

export const createMarkToggle = (typeName: string) => {
return createTypeToggle<MarkType>({
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");
48 changes: 48 additions & 0 deletions packages/context-editor/src/commands/types.ts
Original file line number Diff line number Diff line change
@@ -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<S extends SchemaType> = (options: ToggleOptions<S>) => boolean;
export type ToggleCommandFn<S extends SchemaType> = (options: ToggleOptions<S>) => boolean;

export type ToggleOptions<S extends SchemaType> = {
state: EditorState;
type: S;
withAttrs?: Attrs;
dispatch?: Dispatch;
};

export type CreateToggleOptions<S extends SchemaType> = {
withAttrs?: Attrs;
getTypeFromSchema: (schema: Schema) => S;
commandFn: ToggleCommandFn<S>;
isActiveFn: ToggleActiveFn<S>;
};
27 changes: 27 additions & 0 deletions packages/context-editor/src/commands/utils.ts
Original file line number Diff line number Diff line change
@@ -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 = <S extends SchemaType>(options: CreateToggleOptions<S>) => {
const { getTypeFromSchema, withAttrs, commandFn, isActiveFn } = options;
return createCommandSpec((dispatch, state) => {
const type = getTypeFromSchema(state.schema);
const toggleOptions: ToggleOptions<S> = { state, type, withAttrs };
return {
run: () => commandFn({ ...toggleOptions, dispatch }),
canRun: commandFn(toggleOptions),
isActive: type && isActiveFn(toggleOptions),
};
});
};
145 changes: 121 additions & 24 deletions packages/context-editor/src/components/MenuBar.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,162 @@
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: <span className="italic">I</span>,
type: baseSchema.marks.em,
command: toggleMark(baseSchema.marks.em),
command: emToggle,
},
{
key: "blockquote",
icon: <Quote />,
command: blockquoteToggle,
},
];

const paragraphTypeItems: MenuItem[] = [
{
key: "paragraph",
name: "Paragraph",
icon: "Paragraph",
command: paragraphToggle,
},
{
key: "h1",
name: "Heading 1",
icon: <span className="text-3xl font-bold">Heading 1</span>,
command: heading1Toggle,
},
{
key: "h2",
name: "Heading 2",
icon: <span className="text-2xl font-bold">Heading 2</span>,
command: heading2Toggle,
},
{
key: "h3",
name: "Heading 3",
icon: <span className="text-xl">Heading 3</span>,
command: heading3Toggle,
},
{
key: "h4",
name: "Heading 4",
icon: <span className="text-lg">Heading 4</span>,
command: heading4Toggle,
},
{
key: "h5",
name: "Heading 5",
icon: <span className="text-base">Heading 5</span>,
command: heading5Toggle,
},
{
key: "h6",
name: "Heading 6",
icon: <span className="text-sm font-normal">Heading 6</span>,
command: heading6Toggle,
},
];

const ParagraphDropdown = () => {
const { view } = usePluginViewContext();
const activeType = paragraphTypeItems.find((item) => item.command(view)(view.state).isActive);

return (
<Select
value={activeType?.key}
onValueChange={(value) => {
const item = paragraphTypeItems.find((i) => i.key === value);
if (!item) {
return;
}

const { run } = item.command(view)(view.state);
view.focus();
run();
}}
disabled={!activeType}
>
<SelectTrigger className="w-fit border-none">
<SelectValue placeholder="Paragraph">
{activeType ? activeType.name || activeType.key : "Paragraph"}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-white">
{paragraphTypeItems.map(({ key, icon, command }) => {
return (
<SelectItem key={key} value={key}>
{icon}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
};

export const MenuBar = () => {
const { view } = usePluginViewContext();
return (
<div className="rounded border bg-slate-50">
<div
className="flex items-center rounded border bg-slate-50"
role="toolbar"
aria-label="Formatting tools"
>
{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 (
<Button
key={name}
key={key}
onClick={() => {
view.focus();
command(view.state, view.dispatch, view);
run();
}}
variant="ghost"
size="sm"
disabled={!isApplicable}
disabled={!canRun}
type="button"
className={cn("w-6 rounded-none", { "bg-slate-300": isActive })}
>
{icon}
</Button>
);
})}
<ParagraphDropdown />
</div>
);
};
Loading

0 comments on commit 20eba94

Please sign in to comment.