Skip to content

Commit

Permalink
🎉 feat: editor upgrades (#351)
Browse files Browse the repository at this point in the history
  • Loading branch information
casperiv0 authored Jan 29, 2022
1 parent ee85360 commit 72d9968
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 53 deletions.
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"date-fns": "^2.28.0",
"formik": "^2.2.9",
"hex-color-regex": "^1.1.0",
"is-hotkey": "^0.2.0",
"jquery": "^3.6.0",
"leaflet": "^1.7.1",
"leaflet.markercluster": "^1.5.3",
Expand Down
27 changes: 25 additions & 2 deletions packages/client/src/components/modal/DescriptionModal/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { Editable, ReactEditor, Slate, withReact } from "slate-react";
import { withHistory } from "slate-history";
import type { JsonArray } from "type-fest";
import { Toolbar } from "./Toolbar";
import { toggleMark } from "lib/editor/utils";
import isHotkey from "is-hotkey";
import { withShortcuts } from "lib/editor/withShortcuts";

type CustomElement = { type: "paragraph"; children: CustomText[] };
type CustomText = { text: string };
Expand All @@ -30,10 +33,17 @@ export const DEFAULT_EDITOR_DATA = [
},
] as Descendant[];

const HOTKEYS = {
"mod+b": "bold",
"mod+i": "italic",
"mod+u": "underline",
"mod+s": "strikethrough",
} as const;

export function Editor({ isReadonly, value, onChange }: EditorProps) {
const renderElement = React.useCallback((props) => <Element {...props} />, []);
const renderLeaf = React.useCallback((props) => <Leaf {...props} />, []);
const editor = React.useMemo(() => withHistory(withReact(createEditor())), []);
const editor = React.useMemo(() => withShortcuts(withHistory(withReact(createEditor()))), []);

function handleChange(value: Descendant[]) {
onChange?.(value);
Expand All @@ -53,6 +63,15 @@ export function Editor({ isReadonly, value, onChange }: EditorProps) {
disabled:cursor-not-allowed disabled:opacity-80`,
)}
placeholder="Start typing..."
onKeyDown={(event) => {
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey)(event)) {
event.preventDefault();
const mark = HOTKEYS[hotkey as keyof typeof HOTKEYS];
toggleMark(editor, mark);
}
}
}}
/>
</Slate>
</div>
Expand Down Expand Up @@ -106,7 +125,11 @@ function Element({ attributes, children, element }: any) {
</h2>
);
case "list-item":
return <li {...attributes}>{children}</li>;
return (
<li {...attributes} data-list-item="true">
{children}
</li>
);
case "numbered-list":
return <ol {...attributes}>{children}</ol>;
default:
Expand Down
56 changes: 5 additions & 51 deletions packages/client/src/components/modal/DescriptionModal/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as RToolbar from "@radix-ui/react-toolbar";
import {
ListCheck,
ListUl,
Quote,
TypeBold,
TypeH1,
Expand All @@ -9,10 +10,10 @@ import {
TypeStrikethrough,
TypeUnderline,
} from "react-bootstrap-icons";
import { Editor, BaseEditor, Transforms, Element as SlateElement } from "slate";
import { useSlate, ReactEditor } from "slate-react";
import { useSlate } from "slate-react";
import { Button } from "components/Button";
import { classNames } from "lib/classNames";
import { isBlockActive, toggleMark, toggleBlock, isMarkActive } from "lib/editor/utils";

/**
* mostly example code from: https://github.com/ianstormtaylor/slate/blob/main/site/examples/richtext.tsx
Expand Down Expand Up @@ -43,6 +44,7 @@ export function Toolbar() {
<BlockButton format="heading-one" icon={<TypeH1 aria-label="heading-one" />} />
<BlockButton format="heading-two" icon={<TypeH2 aria-label="heading-two" />} />
<BlockButton format="block-quote" icon={<Quote aria-label="block-quote" />} />
<BlockButton format="bulleted-list" icon={<ListUl aria-label="bulleted-list" />} />
<BlockButton format="checklist" icon={<ListCheck aria-label="checklist" />} />
</RToolbar.ToolbarToggleGroup>
</RToolbar.Root>
Expand Down Expand Up @@ -79,7 +81,7 @@ function BlockButton({ format, icon }: ButtonProps) {

const MarkButton = ({ format, icon }: ButtonProps) => {
const editor = useSlate();
const isActive = isBlockActive(editor, format);
const isActive = isMarkActive(editor, format);

return (
<RToolbar.ToolbarToggleItem asChild value={format}>
Expand All @@ -97,51 +99,3 @@ const MarkButton = ({ format, icon }: ButtonProps) => {
</RToolbar.ToolbarToggleItem>
);
};

function isMarkActive(editor: BaseEditor & ReactEditor, format: string) {
const marks = Editor.marks(editor);

return marks ? (marks as any)[format] === true : false;
}

function toggleBlock(editor: BaseEditor & ReactEditor, format: string) {
const isActive = isBlockActive(editor, format);

const newProperties: Partial<SlateElement> = {
// @ts-expect-error ignore
type: isActive ? "paragraph" : format,
};
Transforms.setNodes<SlateElement>(editor, newProperties);
}

const toggleMark = (editor: BaseEditor & ReactEditor, format: string) => {
const isActive = isMarkActive(editor, format);

if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};

const isBlockActive = (editor: BaseEditor & ReactEditor, format: string) => {
const { selection } = editor;
if (!selection) return false;

const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: (n) => {
if (Editor.isEditor(n)) return false;

if ("text" in n) {
return (n as any)[format];
}

return n.type === format;
},
}),
);

return !!match;
};
64 changes: 64 additions & 0 deletions packages/client/src/lib/editor/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Editor, type BaseEditor, Transforms, Element as SlateElement } from "slate";
import type { ReactEditor } from "slate-react";

const LIST_TYPES = ["numbered-list", "bulleted-list"];

export function isMarkActive(editor: BaseEditor & ReactEditor, format: string) {
const marks = Editor.marks(editor);

return marks ? (marks as any)[format] === true : false;
}

export function toggleBlock(editor: BaseEditor & ReactEditor, format: string) {
const isActive = isBlockActive(editor, format);
const isList = LIST_TYPES.includes(format);

Transforms.unwrapNodes(editor, {
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && LIST_TYPES.includes(n.type),
split: true,
});

const newProperties: Partial<SlateElement> = {
// @ts-expect-error ignore
type: isActive ? "paragraph" : isList ? "list-item" : format,
};
Transforms.setNodes<SlateElement>(editor, newProperties);

if (!isActive && isList) {
const block = { type: format, children: [] };
// @ts-expect-error ignore
Transforms.wrapNodes(editor, block);
}
}

export function toggleMark(editor: BaseEditor & ReactEditor, format: string) {
const isActive = isMarkActive(editor, format);

if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
}

export function isBlockActive(editor: BaseEditor & ReactEditor, format: string) {
const { selection } = editor;
if (!selection) return false;

const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: (n) => {
if (Editor.isEditor(n)) return false;

if ("text" in n) {
return (n as any)[format];
}

return n.type === format;
},
}),
);

return !!match;
}
103 changes: 103 additions & 0 deletions packages/client/src/lib/editor/withShortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import type { ReactEditor } from "slate-react";
import { Editor, Transforms, Range, Point, Element as SlateElement, type BaseEditor } from "slate";

const SHORTCUTS = {
"*": "list-item",
"-": "list-item",
"+": "list-item",
">": "block-quote",
"#": "heading-one",
"##": "heading-two",
};

export const withShortcuts = (editor: BaseEditor & ReactEditor) => {
const { deleteBackward, insertText } = editor;

editor.insertText = (text) => {
const { selection } = editor;

if (text === " " && selection && Range.isCollapsed(selection)) {
const { anchor } = selection;
const block = Editor.above(editor, {
match: (n) => Editor.isBlock(editor, n),
});
const path = block ? block[1] : [];
const start = Editor.start(editor, path);
const range = { anchor, focus: start };
const beforeText = Editor.string(editor, range);
const type = SHORTCUTS[beforeText as keyof typeof SHORTCUTS];

if (type) {
Transforms.select(editor, range);
Transforms.delete(editor);
const newProperties = {
type,
};

Transforms.setNodes<SlateElement>(editor, newProperties as any, {
match: (n) => Editor.isBlock(editor, n),
});

if (type === "list-item") {
const list = {
type: "bulleted-list",
children: [],
};

Transforms.wrapNodes(editor, list as any, {
match: (n) =>
!Editor.isEditor(n) && SlateElement.isElement(n) && (n as any).type === "list-item",
});
}

return;
}
}

insertText(text);
};

editor.deleteBackward = (...args) => {
const { selection } = editor;

if (selection && Range.isCollapsed(selection)) {
const match = Editor.above(editor, {
match: (n) => Editor.isBlock(editor, n),
});

if (match) {
const [block, path] = match;
const start = Editor.start(editor, path);

if (
!Editor.isEditor(block) &&
SlateElement.isElement(block) &&
block.type !== "paragraph" &&
Point.equals(selection.anchor, start)
) {
const newProperties: Partial<SlateElement> = {
type: "paragraph",
};
Transforms.setNodes(editor, newProperties);

if (block.type === "list-item") {
Transforms.unwrapNodes(editor, {
match: (n) =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
(n as any).type === "bulleted-list",
split: true,
});
}

return;
}
}

deleteBackward(...args);
}
};

return editor;
};
5 changes: 5 additions & 0 deletions packages/client/src/styles/globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ li {
@apply text-lg;
}

[data-list-item="true"]::before {
content: "";
padding: 0 5px ;
}

table {
@apply w-full overflow-auto;
}
Expand Down
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1273,6 +1273,7 @@ __metadata:
date-fns: ^2.28.0
formik: ^2.2.9
hex-color-regex: ^1.1.0
is-hotkey: ^0.2.0
jquery: ^3.6.0
leaflet: ^1.7.1
leaflet.markercluster: ^1.5.3
Expand Down Expand Up @@ -5806,6 +5807,13 @@ __metadata:
languageName: node
linkType: hard

"is-hotkey@npm:^0.2.0":
version: 0.2.0
resolution: "is-hotkey@npm:0.2.0"
checksum: 97d295cfd8c3eb2c9b218daee5bff0ddaf47210930e3eb1eff36d2e6ad74854028203c640f35ea2d183d0cba94ac4c4bcd291925bc3a343d8a4c7d2c5ab3e2a6
languageName: node
linkType: hard

"is-installed-globally@npm:^0.4.0":
version: 0.4.0
resolution: "is-installed-globally@npm:0.4.0"
Expand Down

0 comments on commit 72d9968

Please sign in to comment.