diff --git a/apps/block-editor/.gitignore b/apps/block-editor/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/block-editor/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/block-editor/@/components/tiptap-extension/node-background-extension.ts b/apps/block-editor/@/components/tiptap-extension/node-background-extension.ts new file mode 100644 index 0000000..e26901c --- /dev/null +++ b/apps/block-editor/@/components/tiptap-extension/node-background-extension.ts @@ -0,0 +1,150 @@ +import type { NodeWithPos } from "@tiptap/core" +import { Extension } from "@tiptap/core" +import type { EditorState, Transaction } from "@tiptap/pm/state" +import { getSelectedNodesOfType } from "@/lib/tiptap-utils" +import { updateNodesAttr } from "@/lib/tiptap-utils" + +declare module "@tiptap/core" { + interface Commands { + nodeBackground: { + setNodeBackgroundColor: (backgroundColor: string) => ReturnType + unsetNodeBackgroundColor: () => ReturnType + toggleNodeBackgroundColor: (backgroundColor: string) => ReturnType + } + } +} + +export interface NodeBackgroundOptions { + /** + * Node types that should support background colors + * @default ["paragraph", "heading", "blockquote", "taskList", "bulletList", "orderedList", "tableCell", "tableHeader"] + */ + types: string[] + /** + * Use inline style instead of data attribute + * @default true + */ + useStyle?: boolean +} + +/** + * Determines the target color for toggle operations + */ +function getToggleColor( + targets: NodeWithPos[], + inputColor: string +): string | null { + if (targets.length === 0) return null + + for (const target of targets) { + const currentColor = target.node.attrs?.backgroundColor ?? null + if (currentColor !== inputColor) { + return inputColor + } + } + + return null +} + +export const NodeBackground = Extension.create({ + name: "nodeBackground", + + addOptions() { + return { + types: [ + "paragraph", + "heading", + "blockquote", + "taskList", + "bulletList", + "orderedList", + "tableCell", + "tableHeader", + ], + useStyle: true, + } + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + backgroundColor: { + default: null as string | null, + + parseHTML: (element: HTMLElement) => { + const styleColor = element.style?.backgroundColor + if (styleColor) return styleColor + + const dataColor = element.getAttribute("data-background-color") + return dataColor || null + }, + + renderHTML: (attributes) => { + const color = attributes.backgroundColor as string | null + if (!color) return {} + + if (this.options.useStyle) { + return { + style: `background-color: ${color}`, + } + } else { + return { + "data-background-color": color, + } + } + }, + }, + }, + }, + ] + }, + + addCommands() { + /** + * Generic command executor for background color operations + */ + const executeBackgroundCommand = ( + getTargetColor: ( + targets: NodeWithPos[], + inputColor?: string + ) => string | null + ) => { + return (inputColor?: string) => + ({ state, tr }: { state: EditorState; tr: Transaction }) => { + const targets = getSelectedNodesOfType( + state.selection, + this.options.types + ) + + if (targets.length === 0) return false + + const targetColor = getTargetColor(targets, inputColor) + + return updateNodesAttr(tr, targets, "backgroundColor", targetColor) + } + } + + return { + /** + * Set background color to specific value + */ + setNodeBackgroundColor: executeBackgroundCommand( + (_, inputColor) => inputColor || null + ), + + /** + * Remove background color + */ + unsetNodeBackgroundColor: executeBackgroundCommand(() => null), + + /** + * Toggle background color (set if different/missing, unset if all have it) + */ + toggleNodeBackgroundColor: executeBackgroundCommand( + (targets, inputColor) => getToggleColor(targets, inputColor || "") + ), + } + }, +}) diff --git a/apps/block-editor/@/components/tiptap-icons/align-center-icon.tsx b/apps/block-editor/@/components/tiptap-icons/align-center-icon.tsx new file mode 100644 index 0000000..bb72060 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/align-center-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignCenterIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignCenterIcon.displayName = "AlignCenterIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/align-justify-icon.tsx b/apps/block-editor/@/components/tiptap-icons/align-justify-icon.tsx new file mode 100644 index 0000000..61cc6de --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/align-justify-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignJustifyIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignJustifyIcon.displayName = "AlignJustifyIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/align-left-icon.tsx b/apps/block-editor/@/components/tiptap-icons/align-left-icon.tsx new file mode 100644 index 0000000..2972bdb --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/align-left-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignLeftIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignLeftIcon.displayName = "AlignLeftIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/align-right-icon.tsx b/apps/block-editor/@/components/tiptap-icons/align-right-icon.tsx new file mode 100644 index 0000000..c93fc05 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/align-right-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignRightIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignRightIcon.displayName = "AlignRightIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/arrow-left-icon.tsx b/apps/block-editor/@/components/tiptap-icons/arrow-left-icon.tsx new file mode 100644 index 0000000..7cf04d2 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/arrow-left-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ArrowLeftIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ArrowLeftIcon.displayName = "ArrowLeftIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/ban-icon.tsx b/apps/block-editor/@/components/tiptap-icons/ban-icon.tsx new file mode 100644 index 0000000..1995de0 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/ban-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const BanIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +BanIcon.displayName = "BanIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/blockquote-icon.tsx b/apps/block-editor/@/components/tiptap-icons/blockquote-icon.tsx new file mode 100644 index 0000000..50e665f --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/blockquote-icon.tsx @@ -0,0 +1,44 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const BlockquoteIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + ) +}) + +BlockquoteIcon.displayName = "BlockquoteIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/bold-icon.tsx b/apps/block-editor/@/components/tiptap-icons/bold-icon.tsx new file mode 100644 index 0000000..a61cca4 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/bold-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const BoldIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +BoldIcon.displayName = "BoldIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/chevron-down-icon.tsx b/apps/block-editor/@/components/tiptap-icons/chevron-down-icon.tsx new file mode 100644 index 0000000..8f7844d --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/chevron-down-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ChevronDownIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ChevronDownIcon.displayName = "ChevronDownIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/close-icon.tsx b/apps/block-editor/@/components/tiptap-icons/close-icon.tsx new file mode 100644 index 0000000..8b506a9 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/close-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CloseIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +CloseIcon.displayName = "CloseIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/code-block-icon.tsx b/apps/block-editor/@/components/tiptap-icons/code-block-icon.tsx new file mode 100644 index 0000000..9d42238 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/code-block-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CodeBlockIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +CodeBlockIcon.displayName = "CodeBlockIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/code2-icon.tsx b/apps/block-editor/@/components/tiptap-icons/code2-icon.tsx new file mode 100644 index 0000000..e8d70d8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/code2-icon.tsx @@ -0,0 +1,32 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const Code2Icon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +Code2Icon.displayName = "Code2Icon" diff --git a/apps/block-editor/@/components/tiptap-icons/corner-down-left-icon.tsx b/apps/block-editor/@/components/tiptap-icons/corner-down-left-icon.tsx new file mode 100644 index 0000000..0dea639 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/corner-down-left-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CornerDownLeftIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +CornerDownLeftIcon.displayName = "CornerDownLeftIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/external-link-icon.tsx b/apps/block-editor/@/components/tiptap-icons/external-link-icon.tsx new file mode 100644 index 0000000..a4afe67 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/external-link-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ExternalLinkIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +ExternalLinkIcon.displayName = "ExternalLinkIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/heading-five-icon.tsx b/apps/block-editor/@/components/tiptap-icons/heading-five-icon.tsx new file mode 100644 index 0000000..e1450cf --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/heading-five-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingFiveIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingFiveIcon.displayName = "HeadingFiveIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/heading-four-icon.tsx b/apps/block-editor/@/components/tiptap-icons/heading-four-icon.tsx new file mode 100644 index 0000000..3f35a8b --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/heading-four-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingFourIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingFourIcon.displayName = "HeadingFourIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/heading-icon.tsx b/apps/block-editor/@/components/tiptap-icons/heading-icon.tsx new file mode 100644 index 0000000..ba9aa15 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/heading-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +HeadingIcon.displayName = "HeadingIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/heading-one-icon.tsx b/apps/block-editor/@/components/tiptap-icons/heading-one-icon.tsx new file mode 100644 index 0000000..d35093d --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/heading-one-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingOneIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingOneIcon.displayName = "HeadingOneIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/heading-six-icon.tsx b/apps/block-editor/@/components/tiptap-icons/heading-six-icon.tsx new file mode 100644 index 0000000..9cce22b --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/heading-six-icon.tsx @@ -0,0 +1,30 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingSixIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingSixIcon.displayName = "HeadingSixIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/heading-three-icon.tsx b/apps/block-editor/@/components/tiptap-icons/heading-three-icon.tsx new file mode 100644 index 0000000..f244836 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/heading-three-icon.tsx @@ -0,0 +1,36 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingThreeIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +HeadingThreeIcon.displayName = "HeadingThreeIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/heading-two-icon.tsx b/apps/block-editor/@/components/tiptap-icons/heading-two-icon.tsx new file mode 100644 index 0000000..24417ac --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/heading-two-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingTwoIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingTwoIcon.displayName = "HeadingTwoIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/highlighter-icon.tsx b/apps/block-editor/@/components/tiptap-icons/highlighter-icon.tsx new file mode 100644 index 0000000..b6374fe --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/highlighter-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HighlighterIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +HighlighterIcon.displayName = "HighlighterIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/image-plus-icon.tsx b/apps/block-editor/@/components/tiptap-icons/image-plus-icon.tsx new file mode 100644 index 0000000..b1fd8c2 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/image-plus-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ImagePlusIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ImagePlusIcon.displayName = "ImagePlusIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/italic-icon.tsx b/apps/block-editor/@/components/tiptap-icons/italic-icon.tsx new file mode 100644 index 0000000..7c69b63 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/italic-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ItalicIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ItalicIcon.displayName = "ItalicIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/link-icon.tsx b/apps/block-editor/@/components/tiptap-icons/link-icon.tsx new file mode 100644 index 0000000..27e1574 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/link-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const LinkIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +LinkIcon.displayName = "LinkIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/list-icon.tsx b/apps/block-editor/@/components/tiptap-icons/list-icon.tsx new file mode 100644 index 0000000..a3183d2 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/list-icon.tsx @@ -0,0 +1,56 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ListIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + + ) +}) + +ListIcon.displayName = "ListIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/list-ordered-icon.tsx b/apps/block-editor/@/components/tiptap-icons/list-ordered-icon.tsx new file mode 100644 index 0000000..119da7b --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/list-ordered-icon.tsx @@ -0,0 +1,56 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ListOrderedIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + + ) +}) + +ListOrderedIcon.displayName = "ListOrderedIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/list-todo-icon.tsx b/apps/block-editor/@/components/tiptap-icons/list-todo-icon.tsx new file mode 100644 index 0000000..9b899c1 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/list-todo-icon.tsx @@ -0,0 +1,50 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ListTodoIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + ) +}) + +ListTodoIcon.displayName = "ListTodoIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/moon-star-icon.tsx b/apps/block-editor/@/components/tiptap-icons/moon-star-icon.tsx new file mode 100644 index 0000000..e2d4422 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/moon-star-icon.tsx @@ -0,0 +1,30 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const MoonStarIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +MoonStarIcon.displayName = "MoonStarIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/redo2-icon.tsx b/apps/block-editor/@/components/tiptap-icons/redo2-icon.tsx new file mode 100644 index 0000000..0302a16 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/redo2-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const Redo2Icon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +Redo2Icon.displayName = "Redo2Icon" diff --git a/apps/block-editor/@/components/tiptap-icons/strike-icon.tsx b/apps/block-editor/@/components/tiptap-icons/strike-icon.tsx new file mode 100644 index 0000000..df4b8c8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/strike-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const StrikeIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +StrikeIcon.displayName = "StrikeIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/subscript-icon.tsx b/apps/block-editor/@/components/tiptap-icons/subscript-icon.tsx new file mode 100644 index 0000000..671030d --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/subscript-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const SubscriptIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +SubscriptIcon.displayName = "SubscriptIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/sun-icon.tsx b/apps/block-editor/@/components/tiptap-icons/sun-icon.tsx new file mode 100644 index 0000000..607fd65 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/sun-icon.tsx @@ -0,0 +1,58 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const SunIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + + + + + ) +}) + +SunIcon.displayName = "SunIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/superscript-icon.tsx b/apps/block-editor/@/components/tiptap-icons/superscript-icon.tsx new file mode 100644 index 0000000..6af567b --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/superscript-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const SuperscriptIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +SuperscriptIcon.displayName = "SuperscriptIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/trash-icon.tsx b/apps/block-editor/@/components/tiptap-icons/trash-icon.tsx new file mode 100644 index 0000000..ac470bb --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/trash-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const TrashIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +TrashIcon.displayName = "TrashIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/underline-icon.tsx b/apps/block-editor/@/components/tiptap-icons/underline-icon.tsx new file mode 100644 index 0000000..9765a3b --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/underline-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const UnderlineIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +UnderlineIcon.displayName = "UnderlineIcon" diff --git a/apps/block-editor/@/components/tiptap-icons/undo2-icon.tsx b/apps/block-editor/@/components/tiptap-icons/undo2-icon.tsx new file mode 100644 index 0000000..bfb643f --- /dev/null +++ b/apps/block-editor/@/components/tiptap-icons/undo2-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const Undo2Icon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +Undo2Icon.displayName = "Undo2Icon" diff --git a/apps/block-editor/@/components/tiptap-node/blockquote-node/blockquote-node.scss b/apps/block-editor/@/components/tiptap-node/blockquote-node/blockquote-node.scss new file mode 100644 index 0000000..b49c5e1 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/blockquote-node/blockquote-node.scss @@ -0,0 +1,37 @@ +.tiptap.ProseMirror { + --blockquote-bg-color: var(--tt-gray-light-900); + + .dark & { + --blockquote-bg-color: var(--tt-gray-dark-900); + } +} + +/* ===================== + BLOCKQUOTE + ===================== */ +.tiptap.ProseMirror { + blockquote { + position: relative; + padding-left: 1em; + padding-top: 0.375em; + padding-bottom: 0.375em; + margin: 1.5rem 0; + + p { + margin-top: 0; + } + + &::before, + &.is-empty::before { + position: absolute; + bottom: 0; + left: 0; + top: 0; + height: 100%; + width: 0.25em; + background-color: var(--blockquote-bg-color); + content: ""; + border-radius: 0; + } + } +} diff --git a/apps/block-editor/@/components/tiptap-node/code-block-node/code-block-node.scss b/apps/block-editor/@/components/tiptap-node/code-block-node/code-block-node.scss new file mode 100644 index 0000000..d31b312 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/code-block-node/code-block-node.scss @@ -0,0 +1,54 @@ +.tiptap.ProseMirror { + --tt-inline-code-bg-color: var(--tt-gray-light-a-100); + --tt-inline-code-text-color: var(--tt-gray-light-a-700); + --tt-inline-code-border-color: var(--tt-gray-light-a-200); + --tt-codeblock-bg: var(--tt-gray-light-a-50); + --tt-codeblock-text: var(--tt-gray-light-a-800); + --tt-codeblock-border: var(--tt-gray-light-a-200); + + .dark & { + --tt-inline-code-bg-color: var(--tt-gray-dark-a-100); + --tt-inline-code-text-color: var(--tt-gray-dark-a-700); + --tt-inline-code-border-color: var(--tt-gray-dark-a-200); + --tt-codeblock-bg: var(--tt-gray-dark-a-50); + --tt-codeblock-text: var(--tt-gray-dark-a-800); + --tt-codeblock-border: var(--tt-gray-dark-a-200); + } +} + +/* ===================== + CODE FORMATTING + ===================== */ +.tiptap.ProseMirror { + // Inline code + code { + background-color: var(--tt-inline-code-bg-color); + color: var(--tt-inline-code-text-color); + border: 1px solid var(--tt-inline-code-border-color); + font-family: "JetBrains Mono NL", monospace; + font-size: 0.875em; + line-height: 1.4; + border-radius: 6px/0.375rem; + padding: 0.1em 0.2em; + } + + // Code blocks + pre { + background-color: var(--tt-codeblock-bg); + color: var(--tt-codeblock-text); + border: 1px solid var(--tt-codeblock-border); + margin-top: 1.5em; + margin-bottom: 1.5em; + padding: 1em; + font-size: 1rem; + border-radius: 6px/0.375rem; + + code { + background-color: transparent; + border: none; + border-radius: 0; + -webkit-text-fill-color: inherit; + color: inherit; + } + } +} diff --git a/apps/block-editor/@/components/tiptap-node/heading-node/heading-node.scss b/apps/block-editor/@/components/tiptap-node/heading-node/heading-node.scss new file mode 100644 index 0000000..4dd908c --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/heading-node/heading-node.scss @@ -0,0 +1,45 @@ +.tiptap.ProseMirror { + h1, + h2, + h3, + h4 { + position: relative; + color: inherit; + font-style: inherit; + } + + > h1:first-child, + > h2:first-child, + > h3:first-child, + > h4:first-child, + > .ProseMirror-widget + h1, + > .ProseMirror-widget + h2, + > .ProseMirror-widget + h3, + > .ProseMirror-widget + h4 { + margin-top: 0; + } + + h1 { + font-size: 1.5em; + font-weight: 700; + margin-top: 3em; + } + + h2 { + font-size: 1.25em; + font-weight: 700; + margin-top: 2.5em; + } + + h3 { + font-size: 1.125em; + font-weight: 600; + margin-top: 2em; + } + + h4 { + font-size: 1em; + font-weight: 600; + margin-top: 2em; + } +} diff --git a/apps/block-editor/@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts b/apps/block-editor/@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts new file mode 100644 index 0000000..de28208 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts @@ -0,0 +1,14 @@ +import { mergeAttributes } from "@tiptap/react" +import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule" + +export const HorizontalRule = TiptapHorizontalRule.extend({ + renderHTML() { + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, { "data-type": this.name }), + ["hr"], + ] + }, +}) + +export default HorizontalRule diff --git a/apps/block-editor/@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss b/apps/block-editor/@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss new file mode 100644 index 0000000..4626e65 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss @@ -0,0 +1,25 @@ +.tiptap.ProseMirror { + --horizontal-rule-color: var(--tt-gray-light-a-200); + + .dark & { + --horizontal-rule-color: var(--tt-gray-dark-a-200); + } +} + +/* ===================== + HORIZONTAL RULE + ===================== */ +.tiptap.ProseMirror { + hr { + border: none; + height: 1px; + background-color: var(--horizontal-rule-color); + } + + [data-type="horizontalRule"] { + margin-top: 2.25em; + margin-bottom: 2.25em; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + } +} diff --git a/apps/block-editor/@/components/tiptap-node/image-node/image-node.scss b/apps/block-editor/@/components/tiptap-node/image-node/image-node.scss new file mode 100644 index 0000000..ae2239d --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/image-node/image-node.scss @@ -0,0 +1,35 @@ +.tiptap.ProseMirror { + img { + max-width: 100%; + height: auto; + display: block; + } + + p > img { + display: inline-block; + } + + > img:not([data-type="emoji"] img) { + margin: 2rem 0; + outline: 0.125rem solid transparent; + border-radius: var(--tt-radius-xs, 0.25rem); + } + + img:not([data-type="emoji"] img).ProseMirror-selectednode { + outline-color: var(--tt-brand-color-500); + } + + // Thread image handling + .tiptap-thread:has(> img) { + margin: 2rem 0; + + img { + outline: 0.125rem solid transparent; + border-radius: var(--tt-radius-xs, 0.25rem); + } + } + + .tiptap-thread img { + margin: 0; + } +} diff --git a/apps/block-editor/@/components/tiptap-node/image-upload-node/image-upload-node-extension.ts b/apps/block-editor/@/components/tiptap-node/image-upload-node/image-upload-node-extension.ts new file mode 100644 index 0000000..c282b1b --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/image-upload-node/image-upload-node-extension.ts @@ -0,0 +1,162 @@ +import { mergeAttributes, Node } from "@tiptap/react" +import { ReactNodeViewRenderer } from "@tiptap/react" +import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/tiptap-node/image-upload-node/image-upload-node" +import type { NodeType } from "@tiptap/pm/model" + +export type UploadFunction = ( + file: File, + onProgress?: (event: { progress: number }) => void, + abortSignal?: AbortSignal +) => Promise + +export interface ImageUploadNodeOptions { + /** + * The type of the node. + * @default 'image' + */ + type?: string | NodeType | undefined + /** + * Acceptable file types for upload. + * @default 'image/*' + */ + accept?: string + /** + * Maximum number of files that can be uploaded. + * @default 1 + */ + limit?: number + /** + * Maximum file size in bytes (0 for unlimited). + * @default 0 + */ + maxSize?: number + /** + * Function to handle the upload process. + */ + upload?: UploadFunction + /** + * Callback for upload errors. + */ + onError?: (error: Error) => void + /** + * Callback for successful uploads. + */ + onSuccess?: (url: string) => void + /** + * HTML attributes to add to the image element. + * @default {} + * @example { class: 'foo' } + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + HTMLAttributes: Record +} + +declare module "@tiptap/react" { + interface Commands { + imageUpload: { + setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType + } + } +} + +/** + * A Tiptap node extension that creates an image upload component. + * @see registry/tiptap-node/image-upload-node/image-upload-node + */ +export const ImageUploadNode = Node.create({ + name: "imageUpload", + + group: "block", + + draggable: true, + + selectable: true, + + atom: true, + + addOptions() { + return { + type: "image", + accept: "image/*", + limit: 1, + maxSize: 0, + upload: undefined, + onError: undefined, + onSuccess: undefined, + HTMLAttributes: {}, + } + }, + + addAttributes() { + return { + accept: { + default: this.options.accept, + }, + limit: { + default: this.options.limit, + }, + maxSize: { + default: this.options.maxSize, + }, + } + }, + + parseHTML() { + return [{ tag: 'div[data-type="image-upload"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes({ "data-type": "image-upload" }, HTMLAttributes), + ] + }, + + addNodeView() { + return ReactNodeViewRenderer(ImageUploadNodeComponent) + }, + + addCommands() { + return { + setImageUploadNode: + (options) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }) + }, + } + }, + + /** + * Adds Enter key handler to trigger the upload component when it's selected. + */ + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + const { selection } = editor.state + const { nodeAfter } = selection.$from + + if ( + nodeAfter && + nodeAfter.type.name === "imageUpload" && + editor.isActive("imageUpload") + ) { + const nodeEl = editor.view.nodeDOM(selection.$from.pos) + if (nodeEl && nodeEl instanceof HTMLElement) { + // Since NodeViewWrapper is wrapped with a div, we need to click the first child + const firstChild = nodeEl.firstChild + if (firstChild && firstChild instanceof HTMLElement) { + firstChild.click() + return true + } + } + } + return false + }, + } + }, +}) + +export default ImageUploadNode diff --git a/apps/block-editor/@/components/tiptap-node/image-upload-node/image-upload-node.scss b/apps/block-editor/@/components/tiptap-node/image-upload-node/image-upload-node.scss new file mode 100644 index 0000000..b85e1e3 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/image-upload-node/image-upload-node.scss @@ -0,0 +1,249 @@ +:root { + --tiptap-image-upload-active: var(--tt-brand-color-500); + --tiptap-image-upload-progress-bg: var(--tt-brand-color-50); + --tiptap-image-upload-icon-bg: var(--tt-brand-color-500); + + --tiptap-image-upload-text-color: var(--tt-gray-light-a-700); + --tiptap-image-upload-subtext-color: var(--tt-gray-light-a-400); + --tiptap-image-upload-border: var(--tt-gray-light-a-300); + --tiptap-image-upload-border-hover: var(--tt-gray-light-a-400); + --tiptap-image-upload-border-active: var(--tt-brand-color-500); + + --tiptap-image-upload-icon-doc-bg: var(--tt-gray-light-a-200); + --tiptap-image-upload-icon-doc-border: var(--tt-gray-light-300); + --tiptap-image-upload-icon-color: var(--white); +} + +.dark { + --tiptap-image-upload-active: var(--tt-brand-color-400); + --tiptap-image-upload-progress-bg: var(--tt-brand-color-900); + --tiptap-image-upload-icon-bg: var(--tt-brand-color-400); + + --tiptap-image-upload-text-color: var(--tt-gray-dark-a-700); + --tiptap-image-upload-subtext-color: var(--tt-gray-dark-a-400); + --tiptap-image-upload-border: var(--tt-gray-dark-a-300); + --tiptap-image-upload-border-hover: var(--tt-gray-dark-a-400); + --tiptap-image-upload-border-active: var(--tt-brand-color-400); + + --tiptap-image-upload-icon-doc-bg: var(--tt-gray-dark-a-200); + --tiptap-image-upload-icon-doc-border: var(--tt-gray-dark-300); + --tiptap-image-upload-icon-color: var(--black); +} + +.tiptap-image-upload { + margin: 2rem 0; + + input[type="file"] { + display: none; + } + + .tiptap-image-upload-dropzone { + position: relative; + width: 3.125rem; + height: 3.75rem; + display: inline-flex; + align-items: flex-start; + justify-content: center; + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* IE 10 and IE 11 */ + user-select: none; + } + + .tiptap-image-upload-icon-container { + position: absolute; + width: 1.75rem; + height: 1.75rem; + bottom: 0; + right: 0; + background-color: var(--tiptap-image-upload-icon-bg); + border-radius: var(--tt-radius-lg, 0.75rem); + display: flex; + align-items: center; + justify-content: center; + } + + .tiptap-image-upload-icon { + width: 0.875rem; + height: 0.875rem; + color: var(--tiptap-image-upload-icon-color); + } + + .tiptap-image-upload-dropzone-rect-primary { + color: var(--tiptap-image-upload-icon-doc-bg); + position: absolute; + } + + .tiptap-image-upload-dropzone-rect-secondary { + position: absolute; + top: 0; + right: 0.25rem; + bottom: 0; + color: var(--tiptap-image-upload-icon-doc-border); + } + + .tiptap-image-upload-text { + color: var(--tiptap-image-upload-text-color); + font-weight: 500; + font-size: 0.875rem; + line-height: normal; + + em { + font-style: normal; + text-decoration: underline; + } + } + + .tiptap-image-upload-subtext { + color: var(--tiptap-image-upload-subtext-color); + font-weight: 600; + line-height: normal; + font-size: 0.75rem; + } + + .tiptap-image-upload-drag-area { + padding: 2rem 1.5rem; + border: 1.5px dashed var(--tiptap-image-upload-border); + border-radius: var(--tt-radius-md, 0.5rem); + text-align: center; + cursor: pointer; + position: relative; + overflow: hidden; + transition: all 0.2s ease; + + &:hover { + border-color: var(--tiptap-image-upload-border-hover); + } + + &.drag-active { + border-color: var(--tiptap-image-upload-border-active); + background-color: rgba( + var(--tiptap-image-upload-active-rgb, 0, 123, 255), + 0.05 + ); + } + + &.drag-over { + border-color: var(--tiptap-image-upload-border-active); + background-color: rgba( + var(--tiptap-image-upload-active-rgb, 0, 123, 255), + 0.1 + ); + } + } + + .tiptap-image-upload-content { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 0.25rem; + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* IE 10 and IE 11 */ + user-select: none; + } + + .tiptap-image-upload-previews { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .tiptap-image-upload-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + border-bottom: 1px solid var(--tiptap-image-upload-border); + margin-bottom: 0.5rem; + + span { + font-size: 0.875rem; + font-weight: 500; + color: var(--tiptap-image-upload-text-color); + } + } + + // === Individual File Preview Styles === + .tiptap-image-upload-preview { + position: relative; + border-radius: var(--tt-radius-md, 0.5rem); + overflow: hidden; + + .tiptap-image-upload-progress { + position: absolute; + inset: 0; + background-color: var(--tiptap-image-upload-progress-bg); + transition: all 300ms ease-out; + } + + .tiptap-image-upload-preview-content { + position: relative; + border: 1px solid var(--tiptap-image-upload-border); + border-radius: var(--tt-radius-md, 0.5rem); + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + } + + .tiptap-image-upload-file-info { + display: flex; + align-items: center; + gap: 0.75rem; + height: 2rem; + + .tiptap-image-upload-file-icon { + padding: 0.5rem; + background-color: var(--tiptap-image-upload-icon-bg); + border-radius: var(--tt-radius-lg, 0.75rem); + + svg { + width: 0.875rem; + height: 0.875rem; + color: var(--tiptap-image-upload-icon-color); + } + } + } + + .tiptap-image-upload-details { + display: flex; + flex-direction: column; + } + + .tiptap-image-upload-actions { + display: flex; + align-items: center; + gap: 0.5rem; + + .tiptap-image-upload-progress-text { + font-size: 0.75rem; + color: var(--tiptap-image-upload-border-active); + font-weight: 600; + } + } + } +} + +.tiptap.ProseMirror.ProseMirror-focused { + .ProseMirror-selectednode .tiptap-image-upload-drag-area { + border-color: var(--tiptap-image-upload-active); + } +} + +@media (max-width: 480px) { + .tiptap-image-upload { + .tiptap-image-upload-drag-area { + padding: 1.5rem 1rem; + } + + .tiptap-image-upload-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .tiptap-image-upload-preview-content { + padding: 0.75rem; + } + } +} diff --git a/apps/block-editor/@/components/tiptap-node/image-upload-node/image-upload-node.tsx b/apps/block-editor/@/components/tiptap-node/image-upload-node/image-upload-node.tsx new file mode 100644 index 0000000..e92b1bc --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/image-upload-node/image-upload-node.tsx @@ -0,0 +1,554 @@ +"use client" + +import { useRef, useState } from "react" +import type { NodeViewProps } from "@tiptap/react" +import { NodeViewWrapper } from "@tiptap/react" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { CloseIcon } from "@/components/tiptap-icons/close-icon" +import "@/components/tiptap-node/image-upload-node/image-upload-node.scss" +import { focusNextNode, isValidPosition } from "@/lib/tiptap-utils" + +export interface FileItem { + /** + * Unique identifier for the file item + */ + id: string + /** + * The actual File object being uploaded + */ + file: File + /** + * Current upload progress as a percentage (0-100) + */ + progress: number + /** + * Current status of the file upload process + * @default "uploading" + */ + status: "uploading" | "success" | "error" + + /** + * URL to the uploaded file, available after successful upload + * @optional + */ + url?: string + /** + * Controller that can be used to abort the upload process + * @optional + */ + abortController?: AbortController +} + +export interface UploadOptions { + /** + * Maximum allowed file size in bytes + */ + maxSize: number + /** + * Maximum number of files that can be uploaded + */ + limit: number + /** + * String specifying acceptable file types (MIME types or extensions) + * @example ".jpg,.png,image/jpeg" or "image/*" + */ + accept: string + /** + * Function that handles the actual file upload process + * @param {File} file - The file to be uploaded + * @param {Function} onProgress - Callback function to report upload progress + * @param {AbortSignal} signal - Signal that can be used to abort the upload + * @returns {Promise} Promise resolving to the URL of the uploaded file + */ + upload: ( + file: File, + onProgress: (event: { progress: number }) => void, + signal: AbortSignal + ) => Promise + /** + * Callback triggered when a file is uploaded successfully + * @param {string} url - URL of the successfully uploaded file + * @optional + */ + onSuccess?: (url: string) => void + /** + * Callback triggered when an error occurs during upload + * @param {Error} error - The error that occurred + * @optional + */ + onError?: (error: Error) => void +} + +/** + * Custom hook for managing multiple file uploads with progress tracking and cancellation + */ +function useFileUpload(options: UploadOptions) { + const [fileItems, setFileItems] = useState([]) + + const uploadFile = async (file: File): Promise => { + if (file.size > options.maxSize) { + const error = new Error( + `File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)` + ) + options.onError?.(error) + return null + } + + const abortController = new AbortController() + const fileId = crypto.randomUUID() + + const newFileItem: FileItem = { + id: fileId, + file, + progress: 0, + status: "uploading", + abortController, + } + + setFileItems((prev) => [...prev, newFileItem]) + + try { + if (!options.upload) { + throw new Error("Upload function is not defined") + } + + const url = await options.upload( + file, + (event: { progress: number }) => { + setFileItems((prev) => + prev.map((item) => + item.id === fileId ? { ...item, progress: event.progress } : item + ) + ) + }, + abortController.signal + ) + + if (!url) throw new Error("Upload failed: No URL returned") + + if (!abortController.signal.aborted) { + setFileItems((prev) => + prev.map((item) => + item.id === fileId + ? { ...item, status: "success", url, progress: 100 } + : item + ) + ) + options.onSuccess?.(url) + return url + } + + return null + } catch (error) { + if (!abortController.signal.aborted) { + setFileItems((prev) => + prev.map((item) => + item.id === fileId + ? { ...item, status: "error", progress: 0 } + : item + ) + ) + options.onError?.( + error instanceof Error ? error : new Error("Upload failed") + ) + } + return null + } + } + + const uploadFiles = async (files: File[]): Promise => { + if (!files || files.length === 0) { + options.onError?.(new Error("No files to upload")) + return [] + } + + if (options.limit && files.length > options.limit) { + options.onError?.( + new Error( + `Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed` + ) + ) + return [] + } + + // Upload all files concurrently + const uploadPromises = files.map((file) => uploadFile(file)) + const results = await Promise.all(uploadPromises) + + // Filter out null results (failed uploads) + return results.filter((url): url is string => url !== null) + } + + const removeFileItem = (fileId: string) => { + setFileItems((prev) => { + const fileToRemove = prev.find((item) => item.id === fileId) + if (fileToRemove?.abortController) { + fileToRemove.abortController.abort() + } + if (fileToRemove?.url) { + URL.revokeObjectURL(fileToRemove.url) + } + return prev.filter((item) => item.id !== fileId) + }) + } + + const clearAllFiles = () => { + fileItems.forEach((item) => { + if (item.abortController) { + item.abortController.abort() + } + if (item.url) { + URL.revokeObjectURL(item.url) + } + }) + setFileItems([]) + } + + return { + fileItems, + uploadFiles, + removeFileItem, + clearAllFiles, + } +} + +const CloudUploadIcon: React.FC = () => ( + + + + +) + +const FileIcon: React.FC = () => ( + + + +) + +const FileCornerIcon: React.FC = () => ( + + + +) + +interface ImageUploadDragAreaProps { + /** + * Callback function triggered when files are dropped or selected + * @param {File[]} files - Array of File objects that were dropped or selected + */ + onFile: (files: File[]) => void + /** + * Optional child elements to render inside the drag area + * @optional + * @default undefined + */ + children?: React.ReactNode +} + +/** + * A component that creates a drag-and-drop area for image uploads + */ +const ImageUploadDragArea: React.FC = ({ + onFile, + children, +}) => { + const [isDragOver, setIsDragOver] = useState(false) + const [isDragActive, setIsDragActive] = useState(false) + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragActive(false) + setIsDragOver(false) + } + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(true) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) + setIsDragOver(false) + + const files = Array.from(e.dataTransfer.files) + if (files.length > 0) { + onFile(files) + } + } + + return ( +
+ {children} +
+ ) +} + +interface ImageUploadPreviewProps { + /** + * The file item to preview + */ + fileItem: FileItem + /** + * Callback to remove this file from upload queue + */ + onRemove: () => void +} + +/** + * Component that displays a preview of an uploading file with progress + */ +const ImageUploadPreview: React.FC = ({ + fileItem, + onRemove, +}) => { + const formatFileSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes" + const k = 1024 + const sizes = ["Bytes", "KB", "MB", "GB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` + } + + return ( +
+ {fileItem.status === "uploading" && ( +
+ )} + +
+
+
+ +
+
+ + {fileItem.file.name} + + + {formatFileSize(fileItem.file.size)} + +
+
+
+ {fileItem.status === "uploading" && ( + + {fileItem.progress}% + + )} + +
+
+
+ ) +} + +const DropZoneContent: React.FC<{ maxSize: number; limit: number }> = ({ + maxSize, + limit, +}) => ( + <> +
+ + +
+ +
+
+ +
+ + Click to upload or drag and drop + + + Maximum {limit} file{limit === 1 ? "" : "s"}, {maxSize / 1024 / 1024}MB + each. + +
+ +) + +export const ImageUploadNode: React.FC = (props) => { + const { accept, limit, maxSize } = props.node.attrs + const inputRef = useRef(null) + const extension = props.extension + + const uploadOptions: UploadOptions = { + maxSize, + limit, + accept, + upload: extension.options.upload, + onSuccess: extension.options.onSuccess, + onError: extension.options.onError, + } + + const { fileItems, uploadFiles, removeFileItem, clearAllFiles } = + useFileUpload(uploadOptions) + + const handleUpload = async (files: File[]) => { + const urls = await uploadFiles(files) + + if (urls.length > 0) { + const pos = props.getPos() + + if (isValidPosition(pos)) { + const imageNodes = urls.map((url, index) => { + const filename = + files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown" + return { + type: extension.options.type, + attrs: { + ...extension.options, + src: url, + alt: filename, + title: filename, + }, + } + }) + + props.editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + props.node.nodeSize }) + .insertContentAt(pos, imageNodes) + .run() + + focusNextNode(props.editor) + } + } + } + + const handleChange = (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) { + extension.options.onError?.(new Error("No file selected")) + return + } + handleUpload(Array.from(files)) + } + + const handleClick = () => { + if (inputRef.current && fileItems.length === 0) { + inputRef.current.value = "" + inputRef.current.click() + } + } + + const hasFiles = fileItems.length > 0 + + return ( + + {!hasFiles && ( + + + + )} + + {hasFiles && ( +
+ {fileItems.length > 1 && ( +
+ Uploading {fileItems.length} files + +
+ )} + {fileItems.map((fileItem) => ( + removeFileItem(fileItem.id)} + /> + ))} +
+ )} + + 1} + onChange={handleChange} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> +
+ ) +} diff --git a/apps/block-editor/@/components/tiptap-node/image-upload-node/index.tsx b/apps/block-editor/@/components/tiptap-node/image-upload-node/index.tsx new file mode 100644 index 0000000..2510a62 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/image-upload-node/index.tsx @@ -0,0 +1 @@ +export * from "./image-upload-node-extension" diff --git a/apps/block-editor/@/components/tiptap-node/list-node/list-node.scss b/apps/block-editor/@/components/tiptap-node/list-node/list-node.scss new file mode 100644 index 0000000..8562329 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/list-node/list-node.scss @@ -0,0 +1,208 @@ +.tiptap.ProseMirror { + --tt-checklist-bg-color: var(--tt-gray-light-a-100); + --tt-checklist-bg-active-color: var(--tt-gray-light-a-900); + --tt-checklist-border-color: var(--tt-gray-light-a-200); + --tt-checklist-border-active-color: var(--tt-gray-light-a-900); + --tt-checklist-check-icon-color: var(--white); + --tt-checklist-text-active: var(--tt-gray-light-a-500); + + .dark & { + --tt-checklist-bg-color: var(--tt-gray-dark-a-100); + --tt-checklist-bg-active-color: var(--tt-gray-dark-a-900); + --tt-checklist-border-color: var(--tt-gray-dark-a-200); + --tt-checklist-border-active-color: var(--tt-gray-dark-a-900); + --tt-checklist-check-icon-color: var(--black); + --tt-checklist-text-active: var(--tt-gray-dark-a-500); + } +} + +/* ===================== + LISTS + ===================== */ +.tiptap.ProseMirror { + // Common list styles + ol, + ul { + margin-top: 1.5em; + margin-bottom: 1.5em; + padding-left: 1.5em; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + ol, + ul { + margin-top: 0; + margin-bottom: 0; + } + } + + li { + p { + margin-top: 0; + line-height: 1.6; + } + } + + // Ordered lists + ol { + list-style: decimal; + + ol { + list-style: lower-alpha; + + ol { + list-style: lower-roman; + + ol { + list-style: decimal; + + ol { + list-style: lower-alpha; + + ol { + list-style: lower-roman; + + ol { + list-style: decimal; + + ol { + list-style: lower-alpha; + + ol { + list-style: lower-roman; + } + } + } + } + } + } + } + } + } + + // Unordered lists + ul:not([data-type="taskList"]) { + list-style: disc; + + ul { + list-style: circle; + + ul { + list-style: square; + + ul { + list-style: disc; + + ul { + list-style: circle; + + ul { + list-style: square; + + ul { + list-style: disc; + + ul { + list-style: circle; + + ul { + list-style: square; + } + } + } + } + } + } + } + } + } + + // Task lists + ul[data-type="taskList"] { + padding-left: 0.25em; + + li { + display: flex; + flex-direction: row; + align-items: flex-start; + + &:not(:has(> p:first-child)) { + list-style-type: none; + } + + &[data-checked="true"] { + > div > p { + opacity: 0.5; + text-decoration: line-through; + } + + > div > p span { + text-decoration: line-through; + } + } + + label { + position: relative; + padding-top: 0.375rem; + padding-right: 0.5rem; + + input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + span { + display: block; + width: 1em; + height: 1em; + border: 1px solid var(--tt-checklist-border-color); + border-radius: var(--tt-radius-xs, 0.25rem); + position: relative; + cursor: pointer; + background-color: var(--tt-checklist-bg-color); + transition: + background-color 80ms ease-out, + border-color 80ms ease-out; + + &::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 0.75em; + height: 0.75em; + background-color: var(--tt-checklist-check-icon-color); + opacity: 0; + -webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") + center/contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") + center/contain no-repeat; + } + } + + input[type="checkbox"]:checked + span { + background: var(--tt-checklist-bg-active-color); + border-color: var(--tt-checklist-border-active-color); + + &::before { + opacity: 1; + } + } + } + + div { + flex: 1 1 0%; + min-width: 0; + } + } + } +} diff --git a/apps/block-editor/@/components/tiptap-node/paragraph-node/paragraph-node.scss b/apps/block-editor/@/components/tiptap-node/paragraph-node/paragraph-node.scss new file mode 100644 index 0000000..7d4145a --- /dev/null +++ b/apps/block-editor/@/components/tiptap-node/paragraph-node/paragraph-node.scss @@ -0,0 +1,273 @@ +.tiptap.ProseMirror { + --tt-collaboration-carets-label: var(--tt-gray-light-900); + --link-text-color: var(--tt-brand-color-500); + --thread-text: var(--tt-gray-light-900); + --placeholder-color: var(--tt-gray-light-a-400); + --thread-bg-color: var(--tt-color-yellow-inc-2); + + // ai + --tiptap-ai-insertion-color: var(--tt-brand-color-600); + + .dark & { + --tt-collaboration-carets-label: var(--tt-gray-dark-100); + --link-text-color: var(--tt-brand-color-400); + --thread-text: var(--tt-gray-dark-900); + --placeholder-color: var(--tt-gray-dark-a-400); + --thread-bg-color: var(--tt-color-yellow-dec-2); + + --tiptap-ai-insertion-color: var(--tt-brand-color-400); + } +} + +/* Ensure each top-level node has relative positioning + so absolutely positioned placeholders work correctly */ +.tiptap.ProseMirror > * { + position: relative; +} + +/* ===================== + CORE EDITOR STYLES + ===================== */ +.tiptap.ProseMirror { + white-space: pre-wrap; + outline: none; + caret-color: var(--tt-cursor-color); + + // Paragraph spacing + p:not(:first-child):not(td p):not(th p) { + font-size: 1rem; + line-height: 1.6; + font-weight: normal; + margin-top: 20px; + } + + // Selection styles + &:not(.readonly):not(.ProseMirror-hideselection) { + ::selection { + background-color: var(--tt-selection-color); + } + + .selection::selection { + background: transparent; + } + } + + .selection { + display: inline; + background-color: var(--tt-selection-color); + } + + // Selected node styles + .ProseMirror-selectednode:not(img):not(pre):not(.react-renderer) { + border-radius: var(--tt-radius-md); + background-color: var(--tt-selection-color); + } + + .ProseMirror-hideselection { + caret-color: transparent; + } + + // Resize cursor + &.resize-cursor { + cursor: ew-resize; + cursor: col-resize; + } +} + +/* ===================== + TEXT DECORATION + ===================== */ +.tiptap.ProseMirror { + // Text decoration inheritance for spans + a span { + text-decoration: underline; + } + + s span { + text-decoration: line-through; + } + + u span { + text-decoration: underline; + } + + .tiptap-ai-insertion { + color: var(--tiptap-ai-insertion-color); + } +} + +/* ===================== + COLLABORATION + ===================== */ +.tiptap.ProseMirror { + .collaboration-carets { + &__caret { + border-right: 1px solid transparent; + border-left: 1px solid transparent; + pointer-events: none; + margin-left: -1px; + margin-right: -1px; + position: relative; + word-break: normal; + } + + &__label { + color: var(--tt-collaboration-carets-label); + border-radius: 0.25rem; + border-bottom-left-radius: 0; + font-size: 0.75rem; + font-weight: 600; + left: -1px; + line-height: 1; + padding: 0.125rem 0.375rem; + position: absolute; + top: -1.3em; + user-select: none; + white-space: nowrap; + } + } +} + +/* ===================== + EMOJI + ===================== */ +.tiptap.ProseMirror [data-type="emoji"] img { + display: inline-block; + width: 1.25em; + height: 1.25em; + cursor: text; +} + +/* ===================== + LINKS + ===================== */ +.tiptap.ProseMirror { + a { + color: var(--link-text-color); + text-decoration: underline; + } +} + +/* ===================== + MENTION + ===================== */ +.tiptap.ProseMirror { + [data-type="mention"] { + display: inline-block; + color: var(--tt-brand-color-500); + } +} + +/* ===================== + THREADS + ===================== */ +.tiptap.ProseMirror { + // Base styles for inline threads + .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--inline { + transition: + color 0.2s ease-in-out, + background-color 0.2s ease-in-out; + color: var(--thread-text); + border-bottom: 2px dashed var(--tt-color-yellow-base); + font-weight: 600; + + &.tiptap-thread--selected, + &.tiptap-thread--hovered { + background-color: var(--thread-bg-color); + border-bottom-color: transparent; + } + } + + // Block thread styles with images + .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--block { + &:has(img) { + outline: 0.125rem solid var(--tt-color-yellow-base); + border-radius: var(--tt-radius-xs, 0.25rem); + overflow: hidden; + width: fit-content; + + &.tiptap-thread--selected { + outline-width: 0.25rem; + outline-color: var(--tt-color-yellow-base); + } + + &.tiptap-thread--hovered { + outline-width: 0.25rem; + } + } + + // Block thread styles without images + &:not(:has(img)) { + border-radius: 0.25rem; + border-bottom: 0.125rem dashed var(--tt-color-yellow-base); + border-top: 0.125rem dashed var(--tt-color-yellow-base); + // padding-bottom: 0.5rem; + outline: 0.25rem solid transparent; + + &.tiptap-thread--hovered, + &.tiptap-thread--selected { + background-color: var(--tt-color-yellow-base); + outline-color: var(--tt-color-yellow-base); + } + } + } + + // Resolved thread styles + .tiptap-thread.tiptap-thread--resolved.tiptap-thread--inline.tiptap-thread--selected { + background-color: var(--tt-color-yellow-base); + border-color: transparent; + opacity: 0.5; + } + + // React renderer specific styles + .tiptap-thread.tiptap-thread--block:has(.react-renderer) { + margin-top: 3rem; + margin-bottom: 3rem; + } +} + +/* ===================== + PLACEHOLDER + ===================== */ +.is-empty:not(.with-slash)[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + )::before { + content: attr(data-placeholder); +} + +.is-empty.with-slash[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + )::before { + content: "Write, type '/' for commands…"; + font-style: italic; +} + +.is-empty[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + ):before { + pointer-events: none; + height: 0; + position: absolute; + width: 100%; + text-align: inherit; + left: 0; + right: 0; +} + +.is-empty[data-placeholder]:has(> .ProseMirror-trailingBreak):before { + color: var(--placeholder-color); +} + +/* ===================== + DROPCURSOR + ===================== */ +.prosemirror-dropcursor-block, +.prosemirror-dropcursor-inline { + background: var(--tt-brand-color-400) !important; + border-radius: 0.25rem; + margin-left: -1px; + margin-right: -1px; + width: 100%; + height: 0.188rem; + cursor: grabbing; +} diff --git a/apps/block-editor/@/components/tiptap-templates/simple/data/content.json b/apps/block-editor/@/components/tiptap-templates/simple/data/content.json new file mode 100644 index 0000000..4a3c0e8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-templates/simple/data/content.json @@ -0,0 +1,477 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "textAlign": null, + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Getting started" + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "text": "Welcome to the " + }, + { + "type": "text", + "marks": [ + { + "type": "italic" + }, + { + "type": "highlight", + "attrs": { + "color": "var(--tt-color-highlight-yellow)" + } + } + ], + "text": "Simple Editor" + }, + { + "type": "text", + "text": " template! This template integrates " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "open source" + }, + { + "type": "text", + "text": " UI components and Tiptap extensions licensed under " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "MIT" + }, + { + "type": "text", + "text": "." + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "text": "Integrate it by following the " + }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor", + "target": "_blank", + "rel": "noopener noreferrer nofollow", + "class": null + } + } + ], + "text": "Tiptap UI Components docs" + }, + { + "type": "text", + "text": " or using our CLI tool." + } + ] + }, + { + "type": "codeBlock", + "attrs": { + "language": null + }, + "content": [ + { + "type": "text", + "text": "npx @tiptap/cli init" + } + ] + }, + { + "type": "heading", + "attrs": { + "textAlign": null, + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Features" + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "italic" + } + ], + "text": "A fully responsive rich text editor with built-in support for common formatting and layout tools. Type markdown " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "**" + }, + { + "type": "text", + "marks": [ + { + "type": "italic" + } + ], + "text": " or use keyboard shortcuts " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "⌘+B" + }, + { + "type": "text", + "text": " for " + }, + { + "type": "text", + "marks": [ + { + "type": "strike" + } + ], + "text": "most" + }, + { + "type": "text", + "text": " all common markdown marks. 🪄" + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "text": "Add images, customize alignment, and apply " + }, + { + "type": "text", + "marks": [ + { + "type": "highlight", + "attrs": { + "color": "var(--tt-color-highlight-blue)" + } + } + ], + "text": "advanced formatting" + }, + { + "type": "text", + "text": " to make your writing more engaging and professional." + } + ] + }, + { + "type": "image", + "attrs": { + "src": "/images/tiptap-ui-placeholder-image.jpg", + "alt": "placeholder-image", + "title": "placeholder-image" + } + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Superscript" + }, + { + "type": "text", + "text": " (x" + }, + { + "type": "text", + "marks": [ + { + "type": "superscript" + } + ], + "text": "2" + }, + { + "type": "text", + "text": ") and " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Subscript" + }, + { + "type": "text", + "text": " (H" + }, + { + "type": "text", + "marks": [ + { + "type": "subscript" + } + ], + "text": "2" + }, + { + "type": "text", + "text": "O) for precision." + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Typographic conversion" + }, + { + "type": "text", + "text": ": automatically convert to " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "->" + }, + { + "type": "text", + "text": " an arrow " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "→" + }, + { + "type": "text", + "text": "." + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "italic" + } + ], + "text": "→ " + }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor#features", + "target": "_blank", + "rel": "noopener noreferrer nofollow", + "class": null + } + } + ], + "text": "Learn more" + } + ] + }, + { + "type": "horizontalRule" + }, + { + "type": "heading", + "attrs": { + "textAlign": "left", + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Make it your own" + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "text": "Switch between light and dark modes, and tailor the editor's appearance with customizable CSS to match your style." + } + ] + }, + { + "type": "taskList", + "content": [ + { + "type": "taskItem", + "attrs": { + "checked": true + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "text": "Test template" + } + ] + } + ] + }, + { + "type": "taskItem", + "attrs": { + "checked": false + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor", + "target": "_blank", + "rel": "noopener noreferrer nofollow", + "class": null + } + } + ], + "text": "Integrate the free template" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + } + } + ] +} diff --git a/apps/block-editor/@/components/tiptap-templates/simple/simple-editor.scss b/apps/block-editor/@/components/tiptap-templates/simple/simple-editor.scss new file mode 100644 index 0000000..8faf836 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-templates/simple/simple-editor.scss @@ -0,0 +1,82 @@ +@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); + +body { + --tt-toolbar-height: 44px; + --tt-theme-text: var(--tt-gray-light-900); + + .dark & { + --tt-theme-text: var(--tt-gray-dark-900); + } +} + +body { + font-family: "Inter", sans-serif; + color: var(--tt-theme-text); + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + padding: 0; + overscroll-behavior-y: none; +} + +html, +body { + overscroll-behavior-x: none; +} + +html, +body, +#root, +#app { + height: 100%; + background-color: var(--tt-bg-color); +} + +::-webkit-scrollbar { + width: 0.25rem; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--tt-scrollbar-color) transparent; +} + +::-webkit-scrollbar-thumb { + background-color: var(--tt-scrollbar-color); + border-radius: 9999px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +.tiptap.ProseMirror { + font-family: "DM Sans", sans-serif; +} + +.simple-editor-wrapper { + width: 100vw; + height: 100vh; + overflow: auto; +} + +.simple-editor-content { + max-width: 648px; + width: 100%; + margin: 0 auto; + height: 100%; + display: flex; + flex-direction: column; + flex: 1; +} + +.simple-editor-content .tiptap.ProseMirror.simple-editor { + flex: 1; + padding: 3rem 3rem 30vh; +} + +@media screen and (max-width: 480px) { + .simple-editor-content .tiptap.ProseMirror.simple-editor { + padding: 1rem 1.5rem 30vh; + } +} diff --git a/apps/block-editor/@/components/tiptap-templates/simple/simple-editor.tsx b/apps/block-editor/@/components/tiptap-templates/simple/simple-editor.tsx new file mode 100644 index 0000000..ec356e9 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-templates/simple/simple-editor.tsx @@ -0,0 +1,280 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { EditorContent, EditorContext, useEditor } from "@tiptap/react" + +// --- Tiptap Core Extensions --- +import { StarterKit } from "@tiptap/starter-kit" +import { Image } from "@tiptap/extension-image" +import { TaskItem, TaskList } from "@tiptap/extension-list" +import { TextAlign } from "@tiptap/extension-text-align" +import { Typography } from "@tiptap/extension-typography" +import { Highlight } from "@tiptap/extension-highlight" +import { Subscript } from "@tiptap/extension-subscript" +import { Superscript } from "@tiptap/extension-superscript" +import { Selection } from "@tiptap/extensions" + +// --- UI Primitives --- +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Spacer } from "@/components/tiptap-ui-primitive/spacer" +import { + Toolbar, + ToolbarGroup, + ToolbarSeparator, +} from "@/components/tiptap-ui-primitive/toolbar" + +// --- Tiptap Node --- +import { ImageUploadNode } from "@/components/tiptap-node/image-upload-node/image-upload-node-extension" +import { HorizontalRule } from "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" +import "@/components/tiptap-node/blockquote-node/blockquote-node.scss" +import "@/components/tiptap-node/code-block-node/code-block-node.scss" +import "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss" +import "@/components/tiptap-node/list-node/list-node.scss" +import "@/components/tiptap-node/image-node/image-node.scss" +import "@/components/tiptap-node/heading-node/heading-node.scss" +import "@/components/tiptap-node/paragraph-node/paragraph-node.scss" + +// --- Tiptap UI --- +import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu" +import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button" +import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu" +import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button" +import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button" +import { + ColorHighlightPopover, + ColorHighlightPopoverContent, + ColorHighlightPopoverButton, +} from "@/components/tiptap-ui/color-highlight-popover" +import { + LinkPopover, + LinkContent, + LinkButton, +} from "@/components/tiptap-ui/link-popover" +import { MarkButton } from "@/components/tiptap-ui/mark-button" +import { TextAlignButton } from "@/components/tiptap-ui/text-align-button" +import { UndoRedoButton } from "@/components/tiptap-ui/undo-redo-button" + +// --- Icons --- +import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon" +import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon" +import { LinkIcon } from "@/components/tiptap-icons/link-icon" + +// --- Hooks --- +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" +import { useWindowSize } from "@/hooks/use-window-size" +import { useCursorVisibility } from "@/hooks/use-cursor-visibility" + +// --- Components --- +import { ThemeToggle } from "@/components/tiptap-templates/simple/theme-toggle" + +// --- Lib --- +import { handleImageUpload, MAX_FILE_SIZE } from "@/lib/tiptap-utils" + +// --- Styles --- +import "@/components/tiptap-templates/simple/simple-editor.scss" + +import content from "@/components/tiptap-templates/simple/data/content.json" + +const MainToolbarContent = ({ + onHighlighterClick, + onLinkClick, + isMobile, +}: { + onHighlighterClick: () => void + onLinkClick: () => void + isMobile: boolean +}) => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + {!isMobile ? ( + + ) : ( + + )} + {!isMobile ? : } + + + + + + + + + + + + + + + + + + + + + + + + + + + {isMobile && } + + + + + + ) +} + +const MobileToolbarContent = ({ + type, + onBack, +}: { + type: "highlighter" | "link" + onBack: () => void +}) => ( + <> + + + + + + + {type === "highlighter" ? ( + + ) : ( + + )} + +) + +export function SimpleEditor() { + const isMobile = useIsBreakpoint() + const { height } = useWindowSize() + const [mobileView, setMobileView] = useState<"main" | "highlighter" | "link">( + "main" + ) + const toolbarRef = useRef(null) + + const editor = useEditor({ + immediatelyRender: false, + editorProps: { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + "aria-label": "Main content area, start typing to enter text.", + class: "simple-editor", + }, + }, + extensions: [ + StarterKit.configure({ + horizontalRule: false, + link: { + openOnClick: false, + enableClickSelection: true, + }, + }), + HorizontalRule, + TextAlign.configure({ types: ["heading", "paragraph"] }), + TaskList, + TaskItem.configure({ nested: true }), + Highlight.configure({ multicolor: true }), + Image, + Typography, + Superscript, + Subscript, + Selection, + ImageUploadNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: handleImageUpload, + onError: (error) => console.error("Upload failed:", error), + }), + ], + content, + }) + + const rect = useCursorVisibility({ + editor, + overlayHeight: toolbarRef.current?.getBoundingClientRect().height ?? 0, + }) + + useEffect(() => { + if (!isMobile && mobileView !== "main") { + setMobileView("main") + } + }, [isMobile, mobileView]) + + return ( +
+ + + {mobileView === "main" ? ( + setMobileView("highlighter")} + onLinkClick={() => setMobileView("link")} + isMobile={isMobile} + /> + ) : ( + setMobileView("main")} + /> + )} + + + + +
+ ) +} diff --git a/apps/block-editor/@/components/tiptap-templates/simple/theme-toggle.tsx b/apps/block-editor/@/components/tiptap-templates/simple/theme-toggle.tsx new file mode 100644 index 0000000..45355b8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-templates/simple/theme-toggle.tsx @@ -0,0 +1,44 @@ +import { Button } from "@/components/tiptap-ui-primitive/button" + +// --- Icons --- +import { MoonStarIcon } from "@/components/tiptap-icons/moon-star-icon" +import { SunIcon } from "@/components/tiptap-icons/sun-icon" +import { useEffect, useState } from "react" + +export function ThemeToggle() { + const [isDarkMode, setIsDarkMode] = useState(false) + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handleChange = () => setIsDarkMode(mediaQuery.matches) + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, []) + + useEffect(() => { + const initialDarkMode = + !!document.querySelector('meta[name="color-scheme"][content="dark"]') || + window.matchMedia("(prefers-color-scheme: dark)").matches + setIsDarkMode(initialDarkMode) + }, []) + + useEffect(() => { + document.documentElement.classList.toggle("dark", isDarkMode) + }, [isDarkMode]) + + const toggleDarkMode = () => setIsDarkMode((isDark) => !isDark) + + return ( + + ) +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge-colors.scss b/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge-colors.scss new file mode 100644 index 0000000..8f8a988 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge-colors.scss @@ -0,0 +1,395 @@ +.tiptap-badge { + /************************************************** + Default + **************************************************/ + + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-light-a-600 + ); //more important badge + --tt-badge-bg-color: var(--white); + --tt-badge-bg-color-subdued: var(--white); //less important badge + --tt-badge-bg-color-emphasized: var(--white); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-dark-a-600 + ); //more important badge + --tt-badge-bg-color: var(--black); + --tt-badge-bg-color-subdued: var(--black); //less important badge + --tt-badge-bg-color-emphasized: var(--black); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + } + + /************************************************** + Ghost + **************************************************/ + + &[data-style="ghost"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-light-a-600 + ); //more important badge + --tt-badge-bg-color: var(--transparent); + --tt-badge-bg-color-subdued: var(--transparent); //less important badge + --tt-badge-bg-color-emphasized: var(--transparent); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-dark-a-600 + ); //more important badge + --tt-badge-bg-color: var(--transparent); + --tt-badge-bg-color-subdued: var(--transparent); //less important badge + --tt-badge-bg-color-emphasized: var(--transparent); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + } + } + + /************************************************** + Gray + **************************************************/ + + &[data-style="gray"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-500); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var(--white); //more important badge + --tt-badge-bg-color: var(--tt-gray-light-a-100); + --tt-badge-bg-color-subdued: var( + --tt-gray-light-a-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-gray-light-a-700 + ); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var(--white); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var(--black); //more important badge + --tt-badge-bg-color: var(--tt-gray-dark-a-100); + --tt-badge-bg-color-subdued: var( + --tt-gray-dark-a-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-gray-dark-a-800 + ); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var(--black); //more important badge + } + } + + /************************************************** + Green + **************************************************/ + + &[data-style="green"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-green-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-green-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-green-dec-2); + --tt-badge-text-color: var(--tt-color-green-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-green-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-green-inc-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-green-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-green-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-green-dec-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-green-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-green-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-green-inc-5 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-green-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-green-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-green-base); + --tt-badge-text-color: var(--tt-color-green-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-green-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-green-dec-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-green-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-green-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-green-inc-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-green-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-green-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-green-dec-5 + ); //more important badge + } + } + + /************************************************** + Yellow + **************************************************/ + + &[data-style="yellow"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-yellow-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-yellow-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-yellow-dec-1); + --tt-badge-text-color: var(--tt-color-yellow-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-yellow-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-yellow-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-yellow-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-yellow-base + ); //more important badge + --tt-badge-icon-color: var(--tt-color-yellow-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-yellow-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-yellow-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-yellow-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-yellow-inc-1); + --tt-badge-text-color: var(--tt-color-yellow-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-yellow-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-yellow-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-yellow-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-yellow-base + ); //more important badge + --tt-badge-icon-color: var(--tt-color-yellow-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-yellow-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + } + } + + /************************************************** + Red + **************************************************/ + + &[data-style="red"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-red-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-red-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-red-dec-2); + --tt-badge-text-color: var(--tt-color-red-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-red-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-red-inc-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-red-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-red-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-red-dec-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-red-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-red-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-red-inc-5 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-red-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-red-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-red-base); + --tt-badge-text-color: var(--tt-color-red-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-red-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-red-dec-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-red-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-red-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-red-inc-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-red-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-red-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-red-dec-5 + ); //more important badge + } + } + + /************************************************** + Brand + **************************************************/ + + &[data-style="brand"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-brand-color-300); + --tt-badge-border-color-subdued: var(--tt-brand-color-200); + --tt-badge-border-color-emphasized: var(--tt-brand-color-600); + --tt-badge-text-color: var(--tt-brand-color-800); + --tt-badge-text-color-subdued: var( + --tt-brand-color-700 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-brand-color-50 + ); //more important badge + --tt-badge-bg-color: var(--tt-brand-color-100); + --tt-badge-bg-color-subdued: var( + --tt-brand-color-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + --tt-badge-icon-color: var(--tt-brand-color-800); + --tt-badge-icon-color-subdued: var( + --tt-brand-color-700 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-100 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-brand-color-700); + --tt-badge-border-color-subdued: var(--tt-brand-color-800); + --tt-badge-border-color-emphasized: var(--tt-brand-color-400); + --tt-badge-text-color: var(--tt-brand-color-200); + --tt-badge-text-color-subdued: var( + --tt-brand-color-300 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-brand-color-950 + ); //more important badge + --tt-badge-bg-color: var(--tt-brand-color-900); + --tt-badge-bg-color-subdued: var( + --tt-brand-color-950 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + --tt-badge-icon-color: var(--tt-brand-color-200); + --tt-badge-icon-color-subdued: var( + --tt-brand-color-300 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-900 + ); //more important badge + } + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge-group.scss b/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge-group.scss new file mode 100644 index 0000000..91bd45b --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge-group.scss @@ -0,0 +1,16 @@ +.tiptap-badge-group { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.tiptap-badge-group { + [data-orientation="vertical"] { + flex-direction: column; + } + + [data-orientation="horizontal"] { + flex-direction: row; + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge.scss b/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge.scss new file mode 100644 index 0000000..b2ca9a8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge.scss @@ -0,0 +1,99 @@ +.tiptap-badge { + font-size: 0.625rem; + font-weight: 700; + font-feature-settings: + "salt" on, + "cv01" on; + line-height: 1.15; + height: 1.25rem; + min-width: 1.25rem; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + border: solid 1px; + border-radius: var(--tt-radius-sm, 0.375rem); + transition-property: background, color, opacity; + transition-duration: var(--tt-transition-duration-default); + transition-timing-function: var(--tt-transition-easing-default); + + /* button size large */ + &[data-size="large"] { + font-size: 0.75rem; + height: 1.5rem; + min-width: 1.5rem; + padding: 0.375rem; + border-radius: var(--tt-radius-md, 0.375rem); + } + + /* button size small */ + &[data-size="small"] { + height: 1rem; + min-width: 1rem; + padding: 0.125rem; + border-radius: var(--tt-radius-xs, 0.25rem); + } + + /* trim / expand text of the button */ + .tiptap-badge-text { + padding: 0 0.125rem; + flex-grow: 1; + text-align: left; + } + + &[data-text-trim="on"] { + .tiptap-badge-text { + text-overflow: ellipsis; + overflow: hidden; + } + } + + /* standard icon, what is used */ + .tiptap-badge-icon { + pointer-events: none; + flex-shrink: 0; + width: 0.625rem; + height: 0.625rem; + } + + &[data-size="large"] .tiptap-badge-icon { + width: 0.75rem; + height: 0.75rem; + } +} + +/* -------------------------------------------- +----------- BADGE COLOR SETTINGS ------------- +-------------------------------------------- */ + +.tiptap-badge { + background-color: var(--tt-badge-bg-color); + border-color: var(--tt-badge-border-color); + color: var(--tt-badge-text-color); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color); + } + + /* Emphasized */ + &[data-appearance="emphasized"] { + background-color: var(--tt-badge-bg-color-emphasized); + border-color: var(--tt-badge-border-color-emphasized); + color: var(--tt-badge-text-color-emphasized); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color-emphasized); + } + } + + /* Subdued */ + &[data-appearance="subdued"] { + background-color: var(--tt-badge-bg-color-subdued); + border-color: var(--tt-badge-border-color-subdued); + color: var(--tt-badge-text-color-subdued); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color-subdued); + } + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge.tsx new file mode 100644 index 0000000..56ecb59 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/badge/badge.tsx @@ -0,0 +1,44 @@ +import { forwardRef } from "react" +import "@/components/tiptap-ui-primitive/badge/badge-colors.scss" +import "@/components/tiptap-ui-primitive/badge/badge-group.scss" +import "@/components/tiptap-ui-primitive/badge/badge.scss" + +export interface BadgeProps extends React.HTMLAttributes { + variant?: "ghost" | "white" | "gray" | "green" | "default" + size?: "default" | "small" + appearance?: "default" | "subdued" | "emphasized" + trimText?: boolean +} + +export const Badge = forwardRef( + ( + { + variant, + size = "default", + appearance = "default", + trimText = false, + className, + children, + ...props + }, + ref + ) => { + return ( +
+ {children} +
+ ) + } +) + +Badge.displayName = "Badge" + +export default Badge diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/badge/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/badge/index.tsx new file mode 100644 index 0000000..051fa6e --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/badge/index.tsx @@ -0,0 +1 @@ +export * from "./badge" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/button/button-colors.scss b/apps/block-editor/@/components/tiptap-ui-primitive/button/button-colors.scss new file mode 100644 index 0000000..fc0dd35 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/button/button-colors.scss @@ -0,0 +1,429 @@ +.tiptap-button { + /************************************************** + Default button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--tt-gray-light-a-100); + --tt-button-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-bg-color: var(--tt-gray-light-a-200); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-light-a-200 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-light-300); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-light-a-300 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-light-a-50); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--tt-gray-dark-a-100); + --tt-button-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-bg-color: var(--tt-gray-dark-a-200); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-dark-a-200 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-dark-300); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-dark-a-300 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-dark-a-50); + } + + /************************************************** + Default button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--tt-gray-light-a-600); + --tt-button-hover-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--tt-gray-dark-a-600); + --tt-button-hover-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Default button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--tt-gray-light-a-600); + --tt-button-hover-icon-color: var(--tt-gray-light-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-500); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--tt-gray-dark-a-600); + --tt-button-hover-icon-color: var(--tt-gray-dark-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400); + --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); + } + + /************************************************** + Default button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Default button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-light-a-600); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-dark-a-600); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + + /* ---------------------------------------------------------------- + --------------------------- GHOST BUTTON -------------------------- + ---------------------------------------------------------------- */ + + &[data-style="ghost"] { + /************************************************** + Ghost button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--transparent); + --tt-button-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-bg-color: var(--tt-gray-light-a-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-light-a-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-light-a-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--transparent); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--transparent); + --tt-button-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-bg-color: var(--tt-gray-dark-a-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-dark-a-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-dark-a-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--transparent); + } + + /************************************************** + Ghost button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--tt-gray-light-a-600); + --tt-button-hover-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--tt-gray-dark-a-600); + --tt-button-hover-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Ghost button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--tt-gray-light-a-600); + --tt-button-hover-icon-color: var(--tt-gray-light-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-500); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--tt-gray-dark-a-600); + --tt-button-hover-icon-color: var(--tt-gray-dark-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-300); + --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); + } + + /************************************************** + Ghost button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Ghost button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-light-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-dark-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + } + + /* ---------------------------------------------------------------- + -------------------------- PRIMARY BUTTON ------------------------- + ---------------------------------------------------------------- */ + + &[data-style="primary"] { + /************************************************** + Primary button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--tt-brand-color-500); + --tt-button-hover-bg-color: var(--tt-brand-color-600); + --tt-button-active-bg-color: var(--tt-brand-color-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-brand-color-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-brand-color-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-brand-color-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--tt-brand-color-500); + --tt-button-hover-bg-color: var(--tt-brand-color-600); + --tt-button-active-bg-color: var(--tt-brand-color-900); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-brand-color-900 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-brand-color-800); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-brand-color-800 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Primary button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--white); + --tt-button-hover-text-color: var(--white); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--white); + --tt-button-hover-text-color: var(--white); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Primary button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--white); + --tt-button-hover-icon-color: var(--white); + --tt-button-active-icon-color: var(--tt-brand-color-600); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-brand-color-600); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--white); + --tt-button-hover-icon-color: var(--white); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400); + --tt-button-active-icon-color-subdued: var(--tt-brand-color-400); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Primary button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-500); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Primary button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--white); + --tt-button-hover-dropdown-arrows-color: var(--white); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-light-a-700 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--white); + --tt-button-hover-dropdown-arrows-color: var(--white); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-600 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-dark-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/button/button-group.scss b/apps/block-editor/@/components/tiptap-ui-primitive/button/button-group.scss new file mode 100644 index 0000000..59fd256 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/button/button-group.scss @@ -0,0 +1,22 @@ +.tiptap-button-group { + position: relative; + display: flex; + vertical-align: middle; + + &[data-orientation="vertical"] { + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-width: max-content; + + > .tiptap-button { + width: 100%; + } + } + + &[data-orientation="horizontal"] { + gap: 0.125rem; + flex-direction: row; + align-items: center; + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/button/button.scss b/apps/block-editor/@/components/tiptap-ui-primitive/button/button.scss new file mode 100644 index 0000000..32d1499 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/button/button.scss @@ -0,0 +1,314 @@ +.tiptap-button { + font-size: 0.875rem; + font-weight: 500; + font-feature-settings: + "salt" on, + "cv01" on; + line-height: 1.15; + height: 2rem; + min-width: 2rem; + border: none; + padding: 0.5rem; + gap: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--tt-radius-lg, 0.75rem); + transition-property: background, color, opacity; + transition-duration: var(--tt-transition-duration-default); + transition-timing-function: var(--tt-transition-easing-default); + + // focus-visible + &:focus-visible { + outline: none; + } + + &[data-highlighted="true"], + &[data-focus-visible="true"] { + background-color: var(--tt-button-hover-bg-color); + color: var(--tt-button-hover-text-color); + // outline: 2px solid var(--tt-button-active-icon-color); + } + + &[data-weight="small"] { + width: 1.5rem; + min-width: 1.5rem; + padding-right: 0; + padding-left: 0; + } + + /* button size large */ + &[data-size="large"] { + font-size: 0.9375rem; + height: 2.375rem; + min-width: 2.375rem; + padding: 0.625rem; + } + + /* button size small */ + &[data-size="small"] { + font-size: 0.75rem; + line-height: 1.2; + height: 1.5rem; + min-width: 1.5rem; + padding: 0.3125rem; + border-radius: var(--tt-radius-md, 0.5rem); + } + + /* trim / expand text of the button */ + .tiptap-button-text { + padding: 0 0.125rem; + flex-grow: 1; + text-align: left; + line-height: 1.5rem; + } + + &[data-text-trim="on"] { + .tiptap-button-text { + text-overflow: ellipsis; + overflow: hidden; + } + } + + /* global icon settings */ + .tiptap-button-icon, + .tiptap-button-icon-sub, + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + flex-shrink: 0; + } + + /* standard icon, what is used */ + .tiptap-button-icon { + width: 1rem; + height: 1rem; + } + + &[data-size="large"] .tiptap-button-icon { + width: 1.125rem; + height: 1.125rem; + } + + &[data-size="small"] .tiptap-button-icon { + width: 0.875rem; + height: 0.875rem; + } + + /* if 2 icons are used and this icon should be more subtle */ + .tiptap-button-icon-sub { + width: 1rem; + height: 1rem; + } + + &[data-size="large"] .tiptap-button-icon-sub { + width: 1.125rem; + height: 1.125rem; + } + + &[data-size="small"] .tiptap-button-icon-sub { + width: 0.875rem; + height: 0.875rem; + } + + /* dropdown menus or arrows that are slightly smaller */ + .tiptap-button-dropdown-arrows { + width: 0.75rem; + height: 0.75rem; + } + + &[data-size="large"] .tiptap-button-dropdown-arrows { + width: 0.875rem; + height: 0.875rem; + } + + &[data-size="small"] .tiptap-button-dropdown-arrows { + width: 0.625rem; + height: 0.625rem; + } + + /* dropdown menu for icon buttons only */ + .tiptap-button-dropdown-small { + width: 0.625rem; + height: 0.625rem; + } + + &[data-size="large"] .tiptap-button-dropdown-small { + width: 0.75rem; + height: 0.75rem; + } + + &[data-size="small"] .tiptap-button-dropdown-small { + width: 0.5rem; + height: 0.5rem; + } + + /* button only has icons */ + &:has(> svg):not(:has(> :not(svg))) { + gap: 0.125rem; + + &[data-size="large"], + &[data-size="small"] { + gap: 0.125rem; + } + } + + /* button only has 2 icons and one of them is dropdown small */ + &:has(> svg:nth-of-type(2)):has(> .tiptap-button-dropdown-small):not( + :has(> svg:nth-of-type(3)) + ):not(:has(> .tiptap-button-text)) { + gap: 0; + padding-right: 0.25rem; + + &[data-size="large"] { + padding-right: 0.375rem; + } + + &[data-size="small"] { + padding-right: 0.25rem; + } + } + + /* Emoji is used in a button */ + .tiptap-button-emoji { + width: 1rem; + display: flex; + justify-content: center; + } + + &[data-size="large"] .tiptap-button-emoji { + width: 1.125rem; + } + + &[data-size="small"] .tiptap-button-emoji { + width: 0.875rem; + } +} + +/* -------------------------------------------- +----------- BUTTON COLOR SETTINGS ------------- +-------------------------------------------- */ + +.tiptap-button { + background-color: var(--tt-button-default-bg-color); + color: var(--tt-button-default-text-color); + + .tiptap-button-icon { + color: var(--tt-button-default-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-default-icon-sub-color); + } + + .tiptap-button-dropdown-arrows { + color: var(--tt-button-default-dropdown-arrows-color); + } + + .tiptap-button-dropdown-small { + color: var(--tt-button-default-dropdown-arrows-color); + } + + /* hover state of a button */ + &:hover:not([data-active-item="true"]):not([disabled]), + &[data-active-item="true"]:not([disabled]), + &[data-highlighted]:not([disabled]):not([data-highlighted="false"]) { + background-color: var(--tt-button-hover-bg-color); + color: var(--tt-button-hover-text-color); + + .tiptap-button-icon { + color: var(--tt-button-hover-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-hover-icon-sub-color); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-hover-dropdown-arrows-color); + } + } + + /* Active state of a button */ + &[data-active-state="on"]:not([disabled]), + &[data-state="open"]:not([disabled]) { + background-color: var(--tt-button-active-bg-color); + color: var(--tt-button-active-text-color); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color); + } + + /* Emphasized */ + &[data-appearance="emphasized"] { + background-color: var(--tt-button-active-bg-color-emphasized); + color: var(--tt-button-active-text-color-emphasized); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-emphasized); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color-emphasized); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color-emphasized); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color-emphasized); + } + } + + /* Subdued */ + &[data-appearance="subdued"] { + background-color: var(--tt-button-active-bg-color-subdued); + color: var(--tt-button-active-text-color-subdued); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-subdued); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color-subdued); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color-subdued); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color-subdued); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-subdued); + } + } + } + } + + &:disabled { + background-color: var(--tt-button-disabled-bg-color); + color: var(--tt-button-disabled-text-color); + + .tiptap-button-icon { + color: var(--tt-button-disabled-icon-color); + } + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/button/button.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/button/button.tsx new file mode 100644 index 0000000..e38832f --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/button/button.tsx @@ -0,0 +1,120 @@ +import { forwardRef, Fragment, useMemo } from "react" + +// --- Tiptap UI Primitive --- +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/tiptap-ui-primitive/tooltip" + +// --- Lib --- +import { cn, parseShortcutKeys } from "@/lib/tiptap-utils" + +import "@/components/tiptap-ui-primitive/button/button-colors.scss" +import "@/components/tiptap-ui-primitive/button/button-group.scss" +import "@/components/tiptap-ui-primitive/button/button.scss" + +export type ButtonVariant = "ghost" | "primary" +export type ButtonSize = "small" | "default" | "large" + +export interface ButtonProps extends React.ButtonHTMLAttributes { + showTooltip?: boolean + tooltip?: React.ReactNode + shortcutKeys?: string + variant?: ButtonVariant + size?: ButtonSize +} + +export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({ + shortcuts, +}) => { + if (shortcuts.length === 0) return null + + return ( +
+ {shortcuts.map((key, index) => ( + + {index > 0 && +} + {key} + + ))} +
+ ) +} + +export const Button = forwardRef( + ( + { + className, + children, + tooltip, + showTooltip = true, + shortcutKeys, + variant, + size, + ...props + }, + ref + ) => { + const shortcuts = useMemo( + () => parseShortcutKeys({ shortcutKeys }), + [shortcutKeys] + ) + + if (!tooltip || !showTooltip) { + return ( + + ) + } + + return ( + + + {children} + + + {tooltip} + + + + ) + } +) + +Button.displayName = "Button" + +export const ButtonGroup = forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + orientation?: "horizontal" | "vertical" + } +>(({ className, children, orientation = "vertical", ...props }, ref) => { + return ( +
+ {children} +
+ ) +}) +ButtonGroup.displayName = "ButtonGroup" + +export default Button diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/button/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/button/index.tsx new file mode 100644 index 0000000..e93d26f --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/button/index.tsx @@ -0,0 +1 @@ +export * from "./button" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/card/card.scss b/apps/block-editor/@/components/tiptap-ui-primitive/card/card.scss new file mode 100644 index 0000000..97b757e --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/card/card.scss @@ -0,0 +1,77 @@ +:root { + --tiptap-card-bg-color: var(--white); + --tiptap-card-border-color: var(--tt-gray-light-a-100); + --tiptap-card-group-label-color: var(--tt-gray-light-a-800); +} + +.dark { + --tiptap-card-bg-color: var(--tt-gray-dark-50); + --tiptap-card-border-color: var(--tt-gray-dark-a-100); + --tiptap-card-group-label-color: var(--tt-gray-dark-a-800); +} + +.tiptap-card { + --padding: 0.375rem; + --border-width: 1px; + + border-radius: calc(var(--padding) + var(--tt-radius-lg)); + box-shadow: var(--tt-shadow-elevated-md); + background-color: var(--tiptap-card-bg-color); + border: 1px solid var(--tiptap-card-border-color); + display: flex; + flex-direction: column; + outline: none; + align-items: center; + + position: relative; + min-width: 0; + word-wrap: break-word; + background-clip: border-box; +} + +.tiptap-card-header { + padding: 0.375rem; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border-bottom: var(--border-width) solid var(--tiptap-card-border-color); +} + +.tiptap-card-body { + padding: 0.375rem; + flex: 1 1 auto; + overflow-y: auto; +} + +.tiptap-card-item-group { + position: relative; + display: flex; + vertical-align: middle; + min-width: max-content; + + &[data-orientation="vertical"] { + flex-direction: column; + justify-content: center; + } + + &[data-orientation="horizontal"] { + gap: 0.25rem; + flex-direction: row; + align-items: center; + } +} + +.tiptap-card-group-label { + padding-top: 0.75rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-bottom: 0.25rem; + line-height: normal; + font-size: 0.75rem; + font-weight: 600; + line-height: normal; + text-transform: capitalize; + color: var(--tiptap-card-group-label-color); +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/card/card.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/card/card.tsx new file mode 100644 index 0000000..1cd8d32 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/card/card.tsx @@ -0,0 +1,79 @@ +"use client" + +import { forwardRef } from "react" +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/card/card.scss" + +const Card = forwardRef>( + ({ className, ...props }, ref) => { + return
+ } +) +Card.displayName = "Card" + +const CardHeader = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + } +) +CardHeader.displayName = "CardHeader" + +const CardBody = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + } +) +CardBody.displayName = "CardBody" + +const CardItemGroup = forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + orientation?: "horizontal" | "vertical" + } +>(({ className, orientation = "vertical", ...props }, ref) => { + return ( +
+ ) +}) +CardItemGroup.displayName = "CardItemGroup" + +const CardGroupLabel = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + } +) +CardGroupLabel.displayName = "CardGroupLabel" + +const CardFooter = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + } +) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardBody, CardItemGroup, CardGroupLabel } diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/card/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/card/index.tsx new file mode 100644 index 0000000..288c75f --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/card/index.tsx @@ -0,0 +1 @@ +export * from "./card" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss b/apps/block-editor/@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss new file mode 100644 index 0000000..03b47e8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss @@ -0,0 +1,63 @@ +.tiptap-dropdown-menu { + --tt-dropdown-menu-bg-color: var(--white); + --tt-dropdown-menu-border-color: var(--tt-gray-light-a-100); + --tt-dropdown-menu-text-color: var(--tt-gray-light-a-600); + + .dark & { + --tt-dropdown-menu-border-color: var(--tt-gray-dark-a-50); + --tt-dropdown-menu-bg-color: var(--tt-gray-dark-50); + --tt-dropdown-menu-text-color: var(--tt-gray-dark-a-600); + } +} + +/* -------------------------------------------- + --------- DROPDOWN MENU STYLING SETTINGS ----------- + -------------------------------------------- */ +.tiptap-dropdown-menu { + z-index: 50; + outline: none; + transform-origin: var(--radix-dropdown-menu-content-transform-origin); + max-height: var(--radix-dropdown-menu-content-available-height); + + > * { + max-height: var(--radix-dropdown-menu-content-available-height); + } + + /* Animation states */ + &[data-state="open"] { + animation: + fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: + fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + /* Position-based animations */ + &[data-side="top"], + &[data-side="top-start"], + &[data-side="top-end"] { + animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="right"], + &[data-side="right-start"], + &[data-side="right-end"] { + animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="bottom"], + &[data-side="bottom-start"], + &[data-side="bottom-end"] { + animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="left"], + &[data-side="left-start"], + &[data-side="left-end"] { + animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1); + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 0000000..0a98060 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,96 @@ +import { forwardRef } from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return +} + +const DropdownMenuTrigger = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => ) +DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuItem = DropdownMenuPrimitive.Item + +const DropdownMenuSubTrigger = DropdownMenuPrimitive.SubTrigger + +const DropdownMenuSubContent = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + portal?: boolean | React.ComponentProps + } +>(({ className, portal = true, ...props }, ref) => { + const content = ( + + ) + + return portal ? ( + + {content} + + ) : ( + content + ) +}) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + portal?: boolean + } +>(({ className, sideOffset = 4, portal = false, ...props }, ref) => { + const content = ( + e.preventDefault()} + className={cn("tiptap-dropdown-menu", className)} + {...props} + /> + ) + + return portal ? ( + + {content} + + ) : ( + content + ) +}) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuSub, + DropdownMenuPortal, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/dropdown-menu/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/dropdown-menu/index.tsx new file mode 100644 index 0000000..c4adece --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from "./dropdown-menu" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/input/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/input/index.tsx new file mode 100644 index 0000000..be91c8e --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/input/index.tsx @@ -0,0 +1 @@ +export * from "./input" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/input/input.scss b/apps/block-editor/@/components/tiptap-ui-primitive/input/input.scss new file mode 100644 index 0000000..b9f777c --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/input/input.scss @@ -0,0 +1,45 @@ +:root { + --tiptap-input-placeholder: var(--tt-gray-light-a-400); +} + +.dark { + --tiptap-input-placeholder: var(--tt-gray-dark-a-400); +} + +.tiptap-input { + display: block; + width: 100%; + height: 2rem; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + padding: 0.375rem 0.5rem; + border-radius: 0.375rem; + background: none; + appearance: none; + outline: none; + + &::placeholder { + color: var(--tiptap-input-placeholder); + } +} + +.tiptap-input-clamp { + min-width: 12rem; + padding-right: 0; + + text-overflow: ellipsis; + white-space: nowrap; + + &:focus { + text-overflow: clip; + overflow: visible; + } +} + +.tiptap-input-group { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: stretch; +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/input/input.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/input/input.tsx new file mode 100644 index 0000000..39203ff --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/input/input.tsx @@ -0,0 +1,22 @@ +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/input/input.scss" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +function InputGroup({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ) +} + +export { Input, InputGroup } diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/popover/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/popover/index.tsx new file mode 100644 index 0000000..137ef5d --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/popover/index.tsx @@ -0,0 +1 @@ +export * from "./popover" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/popover/popover.scss b/apps/block-editor/@/components/tiptap-ui-primitive/popover/popover.scss new file mode 100644 index 0000000..07fb0e5 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/popover/popover.scss @@ -0,0 +1,63 @@ +.tiptap-popover { + --tt-popover-bg-color: var(--white); + --tt-popover-border-color: var(--tt-gray-light-a-100); + --tt-popover-text-color: var(--tt-gray-light-a-600); + + .dark & { + --tt-popover-border-color: var(--tt-gray-dark-a-50); + --tt-popover-bg-color: var(--tt-gray-dark-50); + --tt-popover-text-color: var(--tt-gray-dark-a-600); + } +} + +/* -------------------------------------------- + --------- POPOVER STYLING SETTINGS ----------- + -------------------------------------------- */ +.tiptap-popover { + z-index: 50; + outline: none; + transform-origin: var(--radix-popover-content-transform-origin); + max-height: var(--radix-popover-content-available-height); + + > * { + max-height: var(--radix-popover-content-available-height); + } + + /* Animation states */ + &[data-state="open"] { + animation: + fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: + fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + /* Position-based animations */ + &[data-side="top"], + &[data-side="top-start"], + &[data-side="top-end"] { + animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="right"], + &[data-side="right-start"], + &[data-side="right-end"] { + animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="bottom"], + &[data-side="bottom-start"], + &[data-side="bottom-end"] { + animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="left"], + &[data-side="left-start"], + &[data-side="left-end"] { + animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1); + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/popover/popover.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/popover/popover.tsx new file mode 100644 index 0000000..9bd52f1 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/popover/popover.tsx @@ -0,0 +1,35 @@ +import * as PopoverPrimitive from "@radix-ui/react-popover" +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/popover/popover.scss" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/separator/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/separator/index.tsx new file mode 100644 index 0000000..068cfa8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/separator/index.tsx @@ -0,0 +1 @@ +export * from "./separator" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/separator/separator.scss b/apps/block-editor/@/components/tiptap-ui-primitive/separator/separator.scss new file mode 100644 index 0000000..78ec9ac --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/separator/separator.scss @@ -0,0 +1,23 @@ +.tiptap-separator { + --tt-link-border-color: var(--tt-gray-light-a-200); + + .dark & { + --tt-link-border-color: var(--tt-gray-dark-a-200); + } +} + +.tiptap-separator { + flex-shrink: 0; + background-color: var(--tt-link-border-color); + + &[data-orientation="horizontal"] { + height: 1px; + width: 100%; + margin: 0.5rem 0; + } + + &[data-orientation="vertical"] { + height: 1.5rem; + width: 1px; + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/separator/separator.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/separator/separator.tsx new file mode 100644 index 0000000..e2ce9cd --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/separator/separator.tsx @@ -0,0 +1,31 @@ +import { forwardRef } from "react" +import "@/components/tiptap-ui-primitive/separator/separator.scss" +import { cn } from "@/lib/tiptap-utils" + +export type Orientation = "horizontal" | "vertical" + +export interface SeparatorProps extends React.HTMLAttributes { + orientation?: Orientation + decorative?: boolean +} + +export const Separator = forwardRef( + ({ decorative, orientation = "vertical", className, ...divProps }, ref) => { + const ariaOrientation = orientation === "vertical" ? orientation : undefined + const semanticProps = decorative + ? { role: "none" } + : { "aria-orientation": ariaOrientation, role: "separator" } + + return ( +
+ ) + } +) + +Separator.displayName = "Separator" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/spacer/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/spacer/index.tsx new file mode 100644 index 0000000..b0789bf --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/spacer/index.tsx @@ -0,0 +1 @@ +export * from "./spacer" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/spacer/spacer.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/spacer/spacer.tsx new file mode 100644 index 0000000..95e379c --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/spacer/spacer.tsx @@ -0,0 +1,26 @@ +"use client" + +export type SpacerOrientation = "horizontal" | "vertical" + +export interface SpacerProps extends React.HTMLAttributes { + orientation?: SpacerOrientation + size?: string | number +} + +export function Spacer({ + orientation = "horizontal", + size, + style = {}, + ...props +}: SpacerProps) { + const computedStyle = { + ...style, + ...(orientation === "horizontal" && !size && { flex: 1 }), + ...(size && { + width: orientation === "vertical" ? "1px" : size, + height: orientation === "horizontal" ? "1px" : size, + }), + } + + return
+} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/toolbar/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/toolbar/index.tsx new file mode 100644 index 0000000..94b1819 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/toolbar/index.tsx @@ -0,0 +1 @@ +export * from "./toolbar" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/toolbar/toolbar.scss b/apps/block-editor/@/components/tiptap-ui-primitive/toolbar/toolbar.scss new file mode 100644 index 0000000..3ce1862 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/toolbar/toolbar.scss @@ -0,0 +1,98 @@ +:root { + --tt-toolbar-height: 2.75rem; + --tt-safe-area-bottom: env(safe-area-inset-bottom, 0px); + --tt-toolbar-bg-color: var(--white); + --tt-toolbar-border-color: var(--tt-gray-light-a-100); +} + +.dark { + --tt-toolbar-bg-color: var(--black); + --tt-toolbar-border-color: var(--tt-gray-dark-a-50); +} + +.tiptap-toolbar { + display: flex; + align-items: center; + gap: 0.25rem; + + &-group { + display: flex; + align-items: center; + gap: 0.125rem; + + &:empty { + display: none; + } + + &:empty + .tiptap-separator, + .tiptap-separator + &:empty { + display: none; + } + } + + &[data-variant="fixed"] { + position: sticky; + top: 0; + z-index: 10; + width: 100%; + min-height: var(--tt-toolbar-height); + background: var(--tt-toolbar-bg-color); + border-bottom: 1px solid var(--tt-toolbar-border-color); + padding: 0 0.5rem; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + @media (max-width: 480px) { + position: absolute; + top: auto; + height: calc(var(--tt-toolbar-height) + var(--tt-safe-area-bottom)); + border-top: 1px solid var(--tt-toolbar-border-color); + border-bottom: none; + padding: 0 0.5rem var(--tt-safe-area-bottom); + flex-wrap: nowrap; + justify-content: flex-start; + + .tiptap-toolbar-group { + flex: 0 0 auto; + } + } + } + + &[data-variant="floating"] { + --tt-toolbar-padding: 0.125rem; + --tt-toolbar-border-width: 1px; + + padding: 0.188rem; + border-radius: calc( + var(--tt-toolbar-padding) + var(--tt-radius-lg) + + var(--tt-toolbar-border-width) + ); + border: var(--tt-toolbar-border-width) solid var(--tt-toolbar-border-color); + background-color: var(--tt-toolbar-bg-color); + box-shadow: var(--tt-shadow-elevated-md); + outline: none; + overflow: hidden; + + &[data-plain="true"] { + padding: 0; + border-radius: 0; + border: none; + box-shadow: none; + background-color: transparent; + } + + @media screen and (max-width: 480px) { + width: 100%; + border-radius: 0; + border: none; + box-shadow: none; + } + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/toolbar/toolbar.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/toolbar/toolbar.tsx new file mode 100644 index 0000000..b6de1f4 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/toolbar/toolbar.tsx @@ -0,0 +1,121 @@ +import { forwardRef, useCallback, useEffect, useRef, useState } from "react" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import "@/components/tiptap-ui-primitive/toolbar/toolbar.scss" +import { cn } from "@/lib/tiptap-utils" +import { useMenuNavigation } from "@/hooks/use-menu-navigation" +import { useComposedRef } from "@/hooks/use-composed-ref" + +type BaseProps = React.HTMLAttributes + +interface ToolbarProps extends BaseProps { + variant?: "floating" | "fixed" +} + +const useToolbarNavigation = ( + toolbarRef: React.RefObject +) => { + const [items, setItems] = useState([]) + + const collectItems = useCallback(() => { + if (!toolbarRef.current) return [] + return Array.from( + toolbarRef.current.querySelectorAll( + 'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])' + ) + ) + }, [toolbarRef]) + + useEffect(() => { + const toolbar = toolbarRef.current + if (!toolbar) return + + const updateItems = () => setItems(collectItems()) + + updateItems() + const observer = new MutationObserver(updateItems) + observer.observe(toolbar, { childList: true, subtree: true }) + + return () => observer.disconnect() + }, [collectItems, toolbarRef]) + + const { selectedIndex } = useMenuNavigation({ + containerRef: toolbarRef, + items, + orientation: "horizontal", + onSelect: (el) => el.click(), + autoSelectFirstItem: false, + }) + + useEffect(() => { + const toolbar = toolbarRef.current + if (!toolbar) return + + const handleFocus = (e: FocusEvent) => { + const target = e.target as HTMLElement + if (toolbar.contains(target)) + target.setAttribute("data-focus-visible", "true") + } + + const handleBlur = (e: FocusEvent) => { + const target = e.target as HTMLElement + if (toolbar.contains(target)) target.removeAttribute("data-focus-visible") + } + + toolbar.addEventListener("focus", handleFocus, true) + toolbar.addEventListener("blur", handleBlur, true) + + return () => { + toolbar.removeEventListener("focus", handleFocus, true) + toolbar.removeEventListener("blur", handleBlur, true) + } + }, [toolbarRef]) + + useEffect(() => { + if (selectedIndex !== undefined && items[selectedIndex]) { + items[selectedIndex].focus() + } + }, [selectedIndex, items]) +} + +export const Toolbar = forwardRef( + ({ children, className, variant = "fixed", ...props }, ref) => { + const toolbarRef = useRef(null) + const composedRef = useComposedRef(toolbarRef, ref) + useToolbarNavigation(toolbarRef) + + return ( +
+ {children} +
+ ) + } +) +Toolbar.displayName = "Toolbar" + +export const ToolbarGroup = forwardRef( + ({ children, className, ...props }, ref) => ( +
+ {children} +
+ ) +) +ToolbarGroup.displayName = "ToolbarGroup" + +export const ToolbarSeparator = forwardRef( + ({ ...props }, ref) => ( + + ) +) +ToolbarSeparator.displayName = "ToolbarSeparator" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/tooltip/index.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/tooltip/index.tsx new file mode 100644 index 0000000..e12712a --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/tooltip/index.tsx @@ -0,0 +1 @@ +export * from "./tooltip" diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/tooltip/tooltip.scss b/apps/block-editor/@/components/tiptap-ui-primitive/tooltip/tooltip.scss new file mode 100644 index 0000000..d717757 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/tooltip/tooltip.scss @@ -0,0 +1,43 @@ +.tiptap-tooltip { + --tt-tooltip-bg: var(--tt-gray-light-900); + --tt-tooltip-text: var(--white); + --tt-kbd: var(--tt-gray-dark-a-400); + + .dark & { + --tt-tooltip-bg: var(--white); + --tt-tooltip-text: var(--tt-gray-light-600); + --tt-kbd: var(--tt-gray-light-a-400); + } +} + +.tiptap-tooltip { + z-index: 200; + overflow: hidden; + border-radius: var(--tt-radius-md, 0.375rem); + background-color: var(--tt-tooltip-bg); + padding: 0.375rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--tt-tooltip-text); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + text-align: center; + + kbd { + display: inline-block; + text-align: center; + vertical-align: baseline; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + "Noto Sans", + sans-serif; + text-transform: capitalize; + color: var(--tt-kbd); + } +} diff --git a/apps/block-editor/@/components/tiptap-ui-primitive/tooltip/tooltip.tsx b/apps/block-editor/@/components/tiptap-ui-primitive/tooltip/tooltip.tsx new file mode 100644 index 0000000..59ecc44 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui-primitive/tooltip/tooltip.tsx @@ -0,0 +1,237 @@ +"use client" + +import { + cloneElement, + createContext, + forwardRef, + isValidElement, + useContext, + useMemo, + useState, + version, +} from "react" +import { + useFloating, + autoUpdate, + offset, + flip, + shift, + useHover, + useFocus, + useDismiss, + useRole, + useInteractions, + useMergeRefs, + FloatingPortal, + type Placement, + type UseFloatingReturn, + type ReferenceType, + FloatingDelayGroup, +} from "@floating-ui/react" +import "@/components/tiptap-ui-primitive/tooltip/tooltip.scss" + +interface TooltipProviderProps { + children: React.ReactNode + initialOpen?: boolean + placement?: Placement + open?: boolean + onOpenChange?: (open: boolean) => void + delay?: number + closeDelay?: number + timeout?: number + useDelayGroup?: boolean +} + +interface TooltipTriggerProps + extends Omit, "ref"> { + asChild?: boolean + children: React.ReactNode +} + +interface TooltipContentProps + extends Omit, "ref"> { + children?: React.ReactNode + portal?: boolean + portalProps?: Omit, "children"> +} + +interface TooltipContextValue extends UseFloatingReturn { + open: boolean + setOpen: (open: boolean) => void + getReferenceProps: ( + userProps?: React.HTMLProps + ) => Record + getFloatingProps: ( + userProps?: React.HTMLProps + ) => Record +} + +function useTooltip({ + initialOpen = false, + placement = "top", + open: controlledOpen, + onOpenChange: setControlledOpen, + delay = 600, + closeDelay = 0, +}: Omit = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen) + + const open = controlledOpen ?? uncontrolledOpen + const setOpen = setControlledOpen ?? setUncontrolledOpen + + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(4), + flip({ + crossAxis: placement.includes("-"), + fallbackAxisSideDirection: "start", + padding: 4, + }), + shift({ padding: 4 }), + ], + }) + + const context = data.context + + const hover = useHover(context, { + mouseOnly: true, + move: false, + restMs: delay, + enabled: controlledOpen == null, + delay: { + close: closeDelay, + }, + }) + const focus = useFocus(context, { + enabled: controlledOpen == null, + }) + const dismiss = useDismiss(context) + const role = useRole(context, { role: "tooltip" }) + + const interactions = useInteractions([hover, focus, dismiss, role]) + + return useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + }), + [open, setOpen, interactions, data] + ) +} + +const TooltipContext = createContext(null) + +function useTooltipContext() { + const context = useContext(TooltipContext) + + if (context == null) { + throw new Error("Tooltip components must be wrapped in ") + } + + return context +} + +export function Tooltip({ children, ...props }: TooltipProviderProps) { + const tooltip = useTooltip(props) + + if (!props.useDelayGroup) { + return ( + + {children} + + ) + } + + return ( + + + {children} + + + ) +} + +export const TooltipTrigger = forwardRef( + function TooltipTrigger({ children, asChild = false, ...props }, propRef) { + const context = useTooltipContext() + const childrenRef = isValidElement(children) + ? parseInt(version, 10) >= 19 + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + (children as { props: { ref?: React.Ref } }).props.ref + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + (children as any).ref + : undefined + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]) + + if (asChild && isValidElement(children)) { + const dataAttributes = { + "data-tooltip-state": context.open ? "open" : "closed", + } + + return cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...(typeof children.props === "object" ? children.props : {}), + ...dataAttributes, + }) + ) + } + + return ( + + ) + } +) + +export const TooltipContent = forwardRef( + function TooltipContent( + { style, children, portal = true, portalProps = {}, ...props }, + propRef + ) { + const context = useTooltipContext() + const ref = useMergeRefs([context.refs.setFloating, propRef]) + + if (!context.open) return null + + const content = ( +
+ {children} +
+ ) + + if (portal) { + return {content} + } + + return content + } +) + +Tooltip.displayName = "Tooltip" +TooltipTrigger.displayName = "TooltipTrigger" +TooltipContent.displayName = "TooltipContent" diff --git a/apps/block-editor/@/components/tiptap-ui/blockquote-button/blockquote-button.tsx b/apps/block-editor/@/components/tiptap-ui/blockquote-button/blockquote-button.tsx new file mode 100644 index 0000000..2485bb0 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/blockquote-button/blockquote-button.tsx @@ -0,0 +1,122 @@ +import { forwardRef, useCallback } from "react" + +// --- Tiptap UI --- +import type { UseBlockquoteConfig } from "@/components/tiptap-ui/blockquote-button" +import { + BLOCKQUOTE_SHORTCUT_KEY, + useBlockquote, +} from "@/components/tiptap-ui/blockquote-button" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +export interface BlockquoteButtonProps + extends Omit, UseBlockquoteConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function BlockquoteShortcutBadge({ + shortcutKeys = BLOCKQUOTE_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling blockquote in a Tiptap editor. + * + * For custom button implementations, use the `useBlockquote` hook instead. + */ +export const BlockquoteButton = forwardRef< + HTMLButtonElement, + BlockquoteButtonProps +>( + ( + { + editor: providedEditor, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + shortcutKeys, + Icon, + } = useBlockquote({ + editor, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +BlockquoteButton.displayName = "BlockquoteButton" diff --git a/apps/block-editor/@/components/tiptap-ui/blockquote-button/index.tsx b/apps/block-editor/@/components/tiptap-ui/blockquote-button/index.tsx new file mode 100644 index 0000000..0b46edf --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/blockquote-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./blockquote-button" +export * from "./use-blockquote" diff --git a/apps/block-editor/@/components/tiptap-ui/blockquote-button/use-blockquote.ts b/apps/block-editor/@/components/tiptap-ui/blockquote-button/use-blockquote.ts new file mode 100644 index 0000000..0b31147 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/blockquote-button/use-blockquote.ts @@ -0,0 +1,271 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon" + +// --- UI Utils --- +import { + findNodePosition, + getSelectedBlockNodes, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "@/lib/tiptap-utils" + +export const BLOCKQUOTE_SHORTCUT_KEY = "mod+shift+b" + +/** + * Configuration for the blockquote functionality + */ +export interface UseBlockquoteConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether the button should hide when blockquote is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful toggle. + */ + onToggled?: () => void +} + +/** + * Checks if blockquote can be toggled in the current editor state + */ +export function canToggleBlockquote( + editor: Editor | null, + turnInto: boolean = true +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isNodeInSchema("blockquote", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + if (!turnInto) { + return editor.can().toggleWrap("blockquote") + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can wrap in blockquote directly on the selection, + // or we can clear formatting/nodes to arrive at a blockquote. + return editor.can().toggleWrap("blockquote") || editor.can().clearNodes() +} + +/** + * Toggles blockquote formatting for a specific node or the current selection + */ +export function toggleBlockquote(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggleBlockquote(editor)) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + const blocks = getSelectedBlockNodes(editor) + + // In case a selection contains multiple blocks, we only allow + // toggling to nide if there's exactly one block selected + // we also dont block the canToggle since it will fall back to the bottom logic + const isPossibleToTurnInto = + selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) && blocks.length === 1 + + // No selection, find the the cursor position + if ( + (state.selection.empty || state.selection instanceof TextSelection) && + isPossibleToTurnInto + ) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + const toggle = editor.isActive("blockquote") + ? chain.lift("blockquote") + : chain.wrapIn("blockquote") + + toggle.run() + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the blockquote button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!isNodeInSchema("blockquote", editor)) return false + + if (!editor.isActive("code")) { + return canToggleBlockquote(editor) + } + + return true +} + +/** + * Custom hook that provides blockquote functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage - no params needed + * function MySimpleBlockquoteButton() { + * const { isVisible, handleToggle, isActive } = useBlockquote() + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedBlockquoteButton() { + * const { isVisible, handleToggle, label, isActive } = useBlockquote({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: () => console.log('Blockquote toggled!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Toggle Blockquote + * + * ) + * } + * ``` + */ +export function useBlockquote(config?: UseBlockquoteConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onToggled, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggle = canToggleBlockquote(editor) + const isActive = editor?.isActive("blockquote") || false + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleBlockquote(editor) + if (success) { + onToggled?.() + } + return success + }, [editor, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle, + label: "Blockquote", + shortcutKeys: BLOCKQUOTE_SHORTCUT_KEY, + Icon: BlockquoteIcon, + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/code-block-button/code-block-button.tsx b/apps/block-editor/@/components/tiptap-ui/code-block-button/code-block-button.tsx new file mode 100644 index 0000000..c1e9b79 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/code-block-button/code-block-button.tsx @@ -0,0 +1,122 @@ +import { forwardRef, useCallback } from "react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Tiptap UI --- +import type { UseCodeBlockConfig } from "@/components/tiptap-ui/code-block-button" +import { + CODE_BLOCK_SHORTCUT_KEY, + useCodeBlock, +} from "@/components/tiptap-ui/code-block-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +export interface CodeBlockButtonProps + extends Omit, UseCodeBlockConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function CodeBlockShortcutBadge({ + shortcutKeys = CODE_BLOCK_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling code block in a Tiptap editor. + * + * For custom button implementations, use the `useCodeBlock` hook instead. + */ +export const CodeBlockButton = forwardRef< + HTMLButtonElement, + CodeBlockButtonProps +>( + ( + { + editor: providedEditor, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + shortcutKeys, + Icon, + } = useCodeBlock({ + editor, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +CodeBlockButton.displayName = "CodeBlockButton" diff --git a/apps/block-editor/@/components/tiptap-ui/code-block-button/index.tsx b/apps/block-editor/@/components/tiptap-ui/code-block-button/index.tsx new file mode 100644 index 0000000..77d541f --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/code-block-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./code-block-button" +export * from "./use-code-block" diff --git a/apps/block-editor/@/components/tiptap-ui/code-block-button/use-code-block.ts b/apps/block-editor/@/components/tiptap-ui/code-block-button/use-code-block.ts new file mode 100644 index 0000000..424b502 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/code-block-button/use-code-block.ts @@ -0,0 +1,281 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { + findNodePosition, + getSelectedBlockNodes, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "@/lib/tiptap-utils" + +// --- Icons --- +import { CodeBlockIcon } from "@/components/tiptap-icons/code-block-icon" + +export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c" + +/** + * Configuration for the code block functionality + */ +export interface UseCodeBlockConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether the button should hide when code block is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful code block toggle. + */ + onToggled?: () => void +} + +/** + * Checks if code block can be toggled in the current editor state + */ +export function canToggle( + editor: Editor | null, + turnInto: boolean = true +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isNodeInSchema("codeBlock", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + if (!turnInto) { + return editor.can().toggleNode("codeBlock", "paragraph") + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can toggle code block directly on the selection, + // or we can clear formatting/nodes to arrive at a code block. + return ( + editor.can().toggleNode("codeBlock", "paragraph") || + editor.can().clearNodes() + ) +} + +/** + * Toggles code block in the editor + */ +export function toggleCodeBlock(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggle(editor)) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + const blocks = getSelectedBlockNodes(editor) + + // In case a selection contains multiple blocks, we only allow + // toggling to nide if there's exactly one block selected + // we also dont block the canToggle since it will fall back to the bottom logic + const isPossibleToTurnInto = + selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) && blocks.length === 1 + + // No selection, find the the cursor position + if ( + (state.selection.empty || state.selection instanceof TextSelection) && + isPossibleToTurnInto + ) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + const toggle = editor.isActive("codeBlock") + ? chain.setNode("paragraph") + : chain.toggleNode("codeBlock", "paragraph") + + toggle.run() + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the code block button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!isNodeInSchema("codeBlock", editor)) return false + + if (!editor.isActive("code")) { + return canToggle(editor) + } + + return true +} + +/** + * Custom hook that provides code block functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage - no params needed + * function MySimpleCodeBlockButton() { + * const { isVisible, isActive, handleToggle } = useCodeBlock() + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedCodeBlockButton() { + * const { isVisible, isActive, handleToggle, label } = useCodeBlock({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: (isActive) => console.log('Code block toggled:', isActive) + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Toggle Code Block + * + * ) + * } + * ``` + */ +export function useCodeBlock(config?: UseCodeBlockConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onToggled, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggleState = canToggle(editor) + const isActive = editor?.isActive("codeBlock") || false + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleCodeBlock(editor) + if (success) { + onToggled?.() + } + return success + }, [editor, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle: canToggleState, + label: "Code Block", + shortcutKeys: CODE_BLOCK_SHORTCUT_KEY, + Icon: CodeBlockIcon, + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss b/apps/block-editor/@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss new file mode 100644 index 0000000..2c6f387 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss @@ -0,0 +1,49 @@ +.tiptap-button-highlight { + position: relative; + width: 1.25rem; + height: 1.25rem; + margin: 0 -0.175rem; + border-radius: var(--tt-radius-xl); + background-color: var(--highlight-color); + transition: transform 0.2s ease; + + &::after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + border-radius: inherit; + box-sizing: border-box; + border: 1px solid var(--highlight-color); + filter: brightness(95%); + mix-blend-mode: multiply; + + .dark & { + filter: brightness(140%); + mix-blend-mode: lighten; + } + } +} + +.tiptap-button { + &[data-active-state="on"] { + .tiptap-button-highlight { + &::after { + filter: brightness(80%); + } + } + } + + .dark & { + &[data-active-state="on"] { + .tiptap-button-highlight { + &::after { + // Andere Eigenschaft für .dark Kontext + filter: brightness(180%); + } + } + } + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx b/apps/block-editor/@/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx new file mode 100644 index 0000000..76fbeb4 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx @@ -0,0 +1,170 @@ +import { forwardRef, useCallback, useMemo } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { UseColorHighlightConfig } from "@/components/tiptap-ui/color-highlight-button" +import { + COLOR_HIGHLIGHT_SHORTCUT_KEY, + useColorHighlight, +} from "@/components/tiptap-ui/color-highlight-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +// --- Styles --- +import "@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss" + +export interface ColorHighlightButtonProps + extends Omit, UseColorHighlightConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function ColorHighlightShortcutBadge({ + shortcutKeys = COLOR_HIGHLIGHT_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for applying color highlights in a Tiptap editor. + * + * Supports two highlighting modes: + * - "mark": Uses the highlight mark extension (default) + * - "node": Uses the node background extension + * + * For custom button implementations, use the `useColorHighlight` hook instead. + * + * @example + * ```tsx + * // Mark-based highlighting (default) + * + * + * // Node-based background coloring + * + * + * // With custom callback + * console.log(`Applied ${color} in ${mode} mode`)} + * /> + * ``` + */ +export const ColorHighlightButton = forwardRef< + HTMLButtonElement, + ColorHighlightButtonProps +>( + ( + { + editor: providedEditor, + highlightColor, + text, + hideWhenUnavailable = false, + mode = "mark", + onApplied, + showShortcut = false, + onClick, + children, + style, + useColorValue = false, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canColorHighlight, + isActive, + handleColorHighlight, + label, + shortcutKeys, + } = useColorHighlight({ + editor, + highlightColor, + useColorValue, + label: text || `Toggle highlight (${highlightColor})`, + hideWhenUnavailable, + mode, + onApplied, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleColorHighlight() + }, + [handleColorHighlight, onClick] + ) + + const buttonStyle = useMemo( + () => + ({ + ...style, + "--highlight-color": highlightColor, + }) as React.CSSProperties, + [highlightColor, style] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +ColorHighlightButton.displayName = "ColorHighlightButton" diff --git a/apps/block-editor/@/components/tiptap-ui/color-highlight-button/index.tsx b/apps/block-editor/@/components/tiptap-ui/color-highlight-button/index.tsx new file mode 100644 index 0000000..c517648 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/color-highlight-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./color-highlight-button" +export * from "./use-color-highlight" diff --git a/apps/block-editor/@/components/tiptap-ui/color-highlight-button/use-color-highlight.ts b/apps/block-editor/@/components/tiptap-ui/color-highlight-button/use-color-highlight.ts new file mode 100644 index 0000000..47ba72e --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/color-highlight-button/use-color-highlight.ts @@ -0,0 +1,378 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { useHotkeys } from "react-hotkeys-hook" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" + +// --- Lib --- +import { + isMarkInSchema, + isNodeTypeSelected, + isExtensionAvailable, +} from "@/lib/tiptap-utils" + +// --- Icons --- +import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon" + +export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h" +export const HIGHLIGHT_COLORS = [ + { + label: "Default background", + value: "var(--tt-bg-color)", + colorValue: "#ffffff", + border: "var(--tt-bg-color-contrast)", + }, + { + label: "Gray background", + value: "var(--tt-color-highlight-gray)", + colorValue: "#f8f8f7", + border: "var(--tt-color-highlight-gray-contrast)", + }, + { + label: "Brown background", + value: "var(--tt-color-highlight-brown)", + colorValue: "#f4eeee", + border: "var(--tt-color-highlight-brown-contrast)", + }, + { + label: "Orange background", + value: "var(--tt-color-highlight-orange)", + colorValue: "#fbecdd", + border: "var(--tt-color-highlight-orange-contrast)", + }, + { + label: "Yellow background", + value: "var(--tt-color-highlight-yellow)", + colorValue: "#fef9c3", + border: "var(--tt-color-highlight-yellow-contrast)", + }, + { + label: "Green background", + value: "var(--tt-color-highlight-green)", + colorValue: "#dcfce7", + border: "var(--tt-color-highlight-green-contrast)", + }, + { + label: "Blue background", + value: "var(--tt-color-highlight-blue)", + colorValue: "#e0f2fe", + border: "var(--tt-color-highlight-blue-contrast)", + }, + { + label: "Purple background", + value: "var(--tt-color-highlight-purple)", + colorValue: "#f3e8ff", + border: "var(--tt-color-highlight-purple-contrast)", + }, + { + label: "Pink background", + value: "var(--tt-color-highlight-pink)", + colorValue: "#fcf1f6", + border: "var(--tt-color-highlight-pink-contrast)", + }, + { + label: "Red background", + value: "var(--tt-color-highlight-red)", + colorValue: "#ffe4e6", + border: "var(--tt-color-highlight-red-contrast)", + }, +] +export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number] + +export type HighlightMode = "mark" | "node" + +/** + * Configuration for the color highlight functionality + */ +export interface UseColorHighlightConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The color to apply when toggling the highlight. + */ + highlightColor?: string + /** + * Optional label to display alongside the icon. + */ + label?: string + /** + * Whether the button should hide when the mark is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * The highlighting mode to use. + * - "mark": Uses the highlight mark extension (default) + * - "node": Uses the node background extension + * @default "mark" + */ + mode?: HighlightMode + /** + * When true, uses the actual color value (colorValue) instead of CSS variable (value). + * @default false + */ + useColorValue?: boolean + /** + * Called when the highlight is applied. + */ + onApplied?: ({ + color, + label, + mode, + }: { + color: string + label: string + mode: HighlightMode + }) => void +} + +export function pickHighlightColorsByValue(values: string[]) { + const colorMap = new Map( + HIGHLIGHT_COLORS.map((color) => [color.value, color]) + ) + return values + .map((value) => colorMap.get(value)) + .filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color) +} + +/** + * Gets the appropriate color value based on configuration + */ +export function getHighlightColorValue( + color: string, + useColorValue: boolean = false +): string { + if (!useColorValue) return color + + const colorItem = HIGHLIGHT_COLORS.find( + (c) => c.value === color || c.colorValue === color + ) + return colorItem?.colorValue || color +} + +/** + * Checks if highlight can be applied based on the mode and current editor state + */ +export function canColorHighlight( + editor: Editor | null, + mode: HighlightMode = "mark" +): boolean { + if (!editor || !editor.isEditable) return false + + if (mode === "mark") { + if ( + !isMarkInSchema("highlight", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + return editor.can().setMark("highlight") + } else { + if (!isExtensionAvailable(editor, ["nodeBackground"])) return false + + try { + return editor.can().toggleNodeBackgroundColor("test") + } catch { + return false + } + } +} + +/** + * Checks if highlight is currently active + */ +export function isColorHighlightActive( + editor: Editor | null, + highlightColor?: string, + mode: HighlightMode = "mark" +): boolean { + if (!editor || !editor.isEditable) return false + + if (mode === "mark") { + return highlightColor + ? editor.isActive("highlight", { color: highlightColor }) + : editor.isActive("highlight") + } else { + if (!highlightColor) return false + + try { + const { state } = editor + const { selection } = state + + const $pos = selection.$anchor + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth) + if (node && node.attrs?.backgroundColor === highlightColor) { + return true + } + } + return false + } catch { + return false + } + } +} + +/** + * Removes highlight based on the mode + */ +export function removeHighlight( + editor: Editor | null, + mode: HighlightMode = "mark" +): boolean { + if (!editor || !editor.isEditable) return false + if (!canColorHighlight(editor, mode)) return false + + if (mode === "mark") { + return editor.chain().focus().unsetMark("highlight").run() + } else { + return editor.chain().focus().unsetNodeBackgroundColor().run() + } +} + +/** + * Determines if the highlight button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean + mode: HighlightMode +}): boolean { + const { editor, hideWhenUnavailable, mode } = props + + if (!editor || !editor.isEditable) return false + + if (!hideWhenUnavailable) { + return true + } + + // hideWhenUnavailable=true: check schema/extension availability + if (mode === "mark") { + if (!isMarkInSchema("highlight", editor)) return false + } else { + if (!isExtensionAvailable(editor, ["nodeBackground"])) return false + } + + if (!editor.isActive("code")) { + return canColorHighlight(editor, mode) + } + + return true +} + +export function useColorHighlight(config: UseColorHighlightConfig) { + const { + editor: providedEditor, + label, + highlightColor, + hideWhenUnavailable = false, + mode = "mark", + useColorValue = false, + onApplied, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const isMobile = useIsBreakpoint() + const [isVisible, setIsVisible] = useState(true) + const canColorHighlightState = canColorHighlight(editor, mode) + const actualColor = highlightColor + ? getHighlightColorValue(highlightColor, useColorValue) + : highlightColor + const isActive = isColorHighlightActive(editor, actualColor, mode) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, mode })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable, mode]) + + const handleColorHighlight = useCallback(() => { + if (!editor || !canColorHighlightState || !actualColor || !label) + return false + + if (mode === "mark") { + if (editor.state.storedMarks) { + const highlightMarkType = editor.schema.marks.highlight + if (highlightMarkType) { + editor.view.dispatch( + editor.state.tr.removeStoredMark(highlightMarkType) + ) + } + } + + setTimeout(() => { + const success = editor + .chain() + .focus() + .toggleHighlight({ color: actualColor }) + .run() + if (success) { + onApplied?.({ color: actualColor, label, mode }) + } + return success + }, 0) + + return true + } else { + const success = editor + .chain() + .focus() + .toggleNodeBackgroundColor(actualColor) + .run() + + if (success) { + onApplied?.({ color: actualColor, label, mode }) + } + return success + } + }, [canColorHighlightState, actualColor, editor, label, onApplied, mode]) + + const handleRemoveHighlight = useCallback(() => { + const success = removeHighlight(editor, mode) + if (success) { + onApplied?.({ color: "", label: "Remove highlight", mode }) + } + return success + }, [editor, onApplied, mode]) + + useHotkeys( + COLOR_HIGHLIGHT_SHORTCUT_KEY, + (event) => { + event.preventDefault() + handleColorHighlight() + }, + { + enabled: isVisible && canColorHighlightState, + enableOnContentEditable: !isMobile, + enableOnFormTags: true, + } + ) + + return { + isVisible, + isActive, + handleColorHighlight, + handleRemoveHighlight, + canColorHighlight: canColorHighlightState, + label: label || `Highlight`, + shortcutKeys: COLOR_HIGHLIGHT_SHORTCUT_KEY, + Icon: HighlighterIcon, + mode, + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx b/apps/block-editor/@/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx new file mode 100644 index 0000000..126eb67 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx @@ -0,0 +1,227 @@ +import { forwardRef, useMemo, useRef, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useMenuNavigation } from "@/hooks/use-menu-navigation" +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { BanIcon } from "@/components/tiptap-icons/ban-icon" +import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/tiptap-ui-primitive/popover" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import { + Card, + CardBody, + CardItemGroup, +} from "@/components/tiptap-ui-primitive/card" + +// --- Tiptap UI --- +import type { + HighlightColor, + UseColorHighlightConfig, +} from "@/components/tiptap-ui/color-highlight-button" +import { + ColorHighlightButton, + pickHighlightColorsByValue, + useColorHighlight, +} from "@/components/tiptap-ui/color-highlight-button" + +export interface ColorHighlightPopoverContentProps { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Optional colors to use in the highlight popover. + * If not provided, defaults to a predefined set of colors. + */ + colors?: HighlightColor[] + /** + * When true, uses the actual color value (colorValue) instead of CSS variable (value). + * @default false + */ + useColorValue?: boolean +} + +export interface ColorHighlightPopoverProps + extends + Omit, + Pick< + UseColorHighlightConfig, + "editor" | "hideWhenUnavailable" | "onApplied" + > { + /** + * Optional colors to use in the highlight popover. + * If not provided, defaults to a predefined set of colors. + */ + colors?: HighlightColor[] + /** + * When true, uses the actual color value (colorValue) instead of CSS variable (value). + * @default false + */ + useColorValue?: boolean +} + +export const ColorHighlightPopoverButton = forwardRef< + HTMLButtonElement, + ButtonProps +>(({ className, children, ...props }, ref) => ( + +)) + +ColorHighlightPopoverButton.displayName = "ColorHighlightPopoverButton" + +export function ColorHighlightPopoverContent({ + editor, + colors = pickHighlightColorsByValue([ + "var(--tt-color-highlight-green)", + "var(--tt-color-highlight-blue)", + "var(--tt-color-highlight-red)", + "var(--tt-color-highlight-purple)", + "var(--tt-color-highlight-yellow)", + ]), + useColorValue = false, +}: ColorHighlightPopoverContentProps) { + const { handleRemoveHighlight } = useColorHighlight({ editor }) + const isMobile = useIsBreakpoint() + const containerRef = useRef(null) + + const menuItems = useMemo( + () => [...colors, { label: "Remove highlight", value: "none" }], + [colors] + ) + + const { selectedIndex } = useMenuNavigation({ + containerRef, + items: menuItems, + orientation: "both", + onSelect: (item) => { + if (!containerRef.current) return false + const highlightedElement = containerRef.current.querySelector( + '[data-highlighted="true"]' + ) as HTMLElement + if (highlightedElement) highlightedElement.click() + if (item.value === "none") handleRemoveHighlight() + return true + }, + autoSelectFirstItem: false, + }) + + return ( + + + + + {colors.map((color, index) => ( + + ))} + + + + + + + + + ) +} + +export function ColorHighlightPopover({ + editor: providedEditor, + colors = pickHighlightColorsByValue([ + "var(--tt-color-highlight-green)", + "var(--tt-color-highlight-blue)", + "var(--tt-color-highlight-red)", + "var(--tt-color-highlight-purple)", + "var(--tt-color-highlight-yellow)", + ]), + hideWhenUnavailable = false, + useColorValue = false, + onApplied, + ...props +}: ColorHighlightPopoverProps) { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + const { isVisible, canColorHighlight, isActive, label, Icon } = + useColorHighlight({ + editor, + hideWhenUnavailable, + onApplied, + }) + + if (!isVisible) return null + + return ( + + + + + + + + + + + ) +} + +export default ColorHighlightPopover diff --git a/apps/block-editor/@/components/tiptap-ui/color-highlight-popover/index.tsx b/apps/block-editor/@/components/tiptap-ui/color-highlight-popover/index.tsx new file mode 100644 index 0000000..626b81f --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/color-highlight-popover/index.tsx @@ -0,0 +1 @@ +export * from "./color-highlight-popover" diff --git a/apps/block-editor/@/components/tiptap-ui/heading-button/heading-button.tsx b/apps/block-editor/@/components/tiptap-ui/heading-button/heading-button.tsx new file mode 100644 index 0000000..b0b5394 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/heading-button/heading-button.tsx @@ -0,0 +1,124 @@ +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Tiptap UI --- +import type { + Level, + UseHeadingConfig, +} from "@/components/tiptap-ui/heading-button" +import { + HEADING_SHORTCUT_KEYS, + useHeading, +} from "@/components/tiptap-ui/heading-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +export interface HeadingButtonProps + extends Omit, UseHeadingConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function HeadingShortcutBadge({ + level, + shortcutKeys = HEADING_SHORTCUT_KEYS[level], +}: { + level: Level + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling heading in a Tiptap editor. + * + * For custom button implementations, use the `useHeading` hook instead. + */ +export const HeadingButton = forwardRef( + ( + { + editor: providedEditor, + level, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + Icon, + shortcutKeys, + } = useHeading({ + editor, + level, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +HeadingButton.displayName = "HeadingButton" diff --git a/apps/block-editor/@/components/tiptap-ui/heading-button/index.tsx b/apps/block-editor/@/components/tiptap-ui/heading-button/index.tsx new file mode 100644 index 0000000..009a700 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/heading-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./heading-button" +export * from "./use-heading" diff --git a/apps/block-editor/@/components/tiptap-ui/heading-button/use-heading.ts b/apps/block-editor/@/components/tiptap-ui/heading-button/use-heading.ts new file mode 100644 index 0000000..bbf07aa --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/heading-button/use-heading.ts @@ -0,0 +1,346 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { + findNodePosition, + getSelectedBlockNodes, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "@/lib/tiptap-utils" + +// --- Icons --- +import { HeadingOneIcon } from "@/components/tiptap-icons/heading-one-icon" +import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon" +import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon" +import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon" +import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon" +import { HeadingSixIcon } from "@/components/tiptap-icons/heading-six-icon" + +export type Level = 1 | 2 | 3 | 4 | 5 | 6 + +/** + * Configuration for the heading functionality + */ +export interface UseHeadingConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The heading level. + */ + level: Level + /** + * Whether the button should hide when heading is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful heading toggle. + */ + onToggled?: () => void +} + +export const headingIcons = { + 1: HeadingOneIcon, + 2: HeadingTwoIcon, + 3: HeadingThreeIcon, + 4: HeadingFourIcon, + 5: HeadingFiveIcon, + 6: HeadingSixIcon, +} + +export const HEADING_SHORTCUT_KEYS: Record = { + 1: "ctrl+alt+1", + 2: "ctrl+alt+2", + 3: "ctrl+alt+3", + 4: "ctrl+alt+4", + 5: "ctrl+alt+5", + 6: "ctrl+alt+6", +} + +/** + * Checks if heading can be toggled in the current editor state + */ +export function canToggle( + editor: Editor | null, + level?: Level, + turnInto: boolean = true +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isNodeInSchema("heading", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + if (!turnInto) { + return level + ? editor.can().setNode("heading", { level }) + : editor.can().setNode("heading") + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can set heading directly on the selection, + // or we can clear formatting/nodes to arrive at a heading. + return level + ? editor.can().setNode("heading", { level }) || editor.can().clearNodes() + : editor.can().setNode("heading") || editor.can().clearNodes() +} + +/** + * Checks if heading is currently active + */ +export function isHeadingActive( + editor: Editor | null, + level?: Level | Level[] +): boolean { + if (!editor || !editor.isEditable) return false + + if (Array.isArray(level)) { + return level.some((l) => editor.isActive("heading", { level: l })) + } + + return level + ? editor.isActive("heading", { level }) + : editor.isActive("heading") +} + +/** + * Toggles heading in the editor + */ +export function toggleHeading( + editor: Editor | null, + level: Level | Level[] +): boolean { + if (!editor || !editor.isEditable) return false + + const levels = Array.isArray(level) ? level : [level] + const toggleLevel = levels.find((l) => canToggle(editor, l)) + + if (!toggleLevel) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + const blocks = getSelectedBlockNodes(editor) + + // In case a selection contains multiple blocks, we only allow + // toggling to nide if there's exactly one block selected + // we also dont block the canToggle since it will fall back to the bottom logic + const isPossibleToTurnInto = + selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) && blocks.length === 1 + + // No selection, find the the cursor position + if ( + (state.selection.empty || state.selection instanceof TextSelection) && + isPossibleToTurnInto + ) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + const isActive = levels.some((l) => + editor.isActive("heading", { level: l }) + ) + + const toggle = isActive + ? chain.setNode("paragraph") + : chain.setNode("heading", { level: toggleLevel }) + + toggle.run() + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the heading button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + level?: Level | Level[] + hideWhenUnavailable: boolean +}): boolean { + const { editor, level, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!isNodeInSchema("heading", editor)) return false + + if (!editor.isActive("code")) { + if (Array.isArray(level)) { + return level.some((l) => canToggle(editor, l)) + } + return canToggle(editor, level) + } + + return true +} + +/** + * Custom hook that provides heading functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleHeadingButton() { + * const { isVisible, isActive, handleToggle, Icon } = useHeading({ level: 1 }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedHeadingButton() { + * const { isVisible, isActive, handleToggle, label, Icon } = useHeading({ + * level: 2, + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: (isActive) => console.log('Heading toggled:', isActive) + * }) + * + * if (!isVisible) return null + * + * return ( + * + * + * Toggle Heading 2 + * + * ) + * } + * ``` + */ +export function useHeading(config: UseHeadingConfig) { + const { + editor: providedEditor, + level, + hideWhenUnavailable = false, + onToggled, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggleState = canToggle(editor, level) + const isActive = isHeadingActive(editor, level) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, level, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleHeading(editor, level) + if (success) { + onToggled?.() + } + return success + }, [editor, level, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle: canToggleState, + label: `Heading ${level}`, + shortcutKeys: HEADING_SHORTCUT_KEYS[level], + Icon: headingIcons[level], + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx b/apps/block-editor/@/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx new file mode 100644 index 0000000..a3808e7 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx @@ -0,0 +1,133 @@ +import { forwardRef, useCallback, useState } from "react" + +// --- Icons --- +import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import { HeadingButton } from "@/components/tiptap-ui/heading-button" +import type { UseHeadingDropdownMenuConfig } from "@/components/tiptap-ui/heading-dropdown-menu" +import { useHeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/tiptap-ui-primitive/dropdown-menu" +import { Card, CardBody } from "@/components/tiptap-ui-primitive/card" + +export interface HeadingDropdownMenuProps + extends Omit, UseHeadingDropdownMenuConfig { + /** + * Whether to render the dropdown menu in a portal + * @default false + */ + portal?: boolean + /** + * Callback for when the dropdown opens or closes + */ + onOpenChange?: (isOpen: boolean) => void +} + +/** + * Dropdown menu component for selecting heading levels in a Tiptap editor. + * + * For custom dropdown implementations, use the `useHeadingDropdownMenu` hook instead. + */ +export const HeadingDropdownMenu = forwardRef< + HTMLButtonElement, + HeadingDropdownMenuProps +>( + ( + { + editor: providedEditor, + levels = [1, 2, 3, 4, 5, 6], + hideWhenUnavailable = false, + portal = false, + onOpenChange, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + const { isVisible, isActive, canToggle, Icon } = useHeadingDropdownMenu({ + editor, + levels, + hideWhenUnavailable, + }) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!editor || !canToggle) return + setIsOpen(open) + onOpenChange?.(open) + }, + [canToggle, editor, onOpenChange] + ) + + if (!isVisible) { + return null + } + + return ( + + + + + + + + + + {levels.map((level) => ( + + + + ))} + + + + + + ) + } +) + +HeadingDropdownMenu.displayName = "HeadingDropdownMenu" + +export default HeadingDropdownMenu diff --git a/apps/block-editor/@/components/tiptap-ui/heading-dropdown-menu/index.tsx b/apps/block-editor/@/components/tiptap-ui/heading-dropdown-menu/index.tsx new file mode 100644 index 0000000..33b9679 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/heading-dropdown-menu/index.tsx @@ -0,0 +1,2 @@ +export * from "./heading-dropdown-menu" +export * from "./use-heading-dropdown-menu" diff --git a/apps/block-editor/@/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts b/apps/block-editor/@/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts new file mode 100644 index 0000000..b25ce63 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts @@ -0,0 +1,132 @@ +"use client" + +import { useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { HeadingIcon } from "@/components/tiptap-icons/heading-icon" + +// --- Tiptap UI --- +import { + headingIcons, + type Level, + isHeadingActive, + canToggle, + shouldShowButton, +} from "@/components/tiptap-ui/heading-button" + +/** + * Configuration for the heading dropdown menu functionality + */ +export interface UseHeadingDropdownMenuConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Available heading levels to show in the dropdown + * @default [1, 2, 3, 4, 5, 6] + */ + levels?: Level[] + /** + * Whether the dropdown should hide when headings are not available. + * @default false + */ + hideWhenUnavailable?: boolean +} + +/** + * Gets the currently active heading level from the available levels + */ +export function getActiveHeadingLevel( + editor: Editor | null, + levels: Level[] = [1, 2, 3, 4, 5, 6] +): Level | undefined { + if (!editor || !editor.isEditable) return undefined + return levels.find((level) => isHeadingActive(editor, level)) +} + +/** + * Custom hook that provides heading dropdown menu functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MyHeadingDropdown() { + * const { + * isVisible, + * activeLevel, + * isAnyHeadingActive, + * canToggle, + * levels, + * } = useHeadingDropdownMenu() + * + * if (!isVisible) return null + * + * return ( + * + * // dropdown content + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedHeadingDropdown() { + * const { + * isVisible, + * activeLevel, + * } = useHeadingDropdownMenu({ + * editor: myEditor, + * levels: [1, 2, 3], + * hideWhenUnavailable: true, + * }) + * + * // component implementation + * } + * ``` + */ +export function useHeadingDropdownMenu(config?: UseHeadingDropdownMenuConfig) { + const { + editor: providedEditor, + levels = [1, 2, 3, 4, 5, 6], + hideWhenUnavailable = false, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + + const activeLevel = getActiveHeadingLevel(editor, levels) + const isActive = isHeadingActive(editor) + const canToggleState = canToggle(editor) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible( + shouldShowButton({ editor, hideWhenUnavailable, level: levels }) + ) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable, levels]) + + return { + isVisible, + activeLevel, + isActive, + canToggle: canToggleState, + levels, + label: "Heading", + Icon: activeLevel ? headingIcons[activeLevel] : HeadingIcon, + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/image-upload-button/image-upload-button.tsx b/apps/block-editor/@/components/tiptap-ui/image-upload-button/image-upload-button.tsx new file mode 100644 index 0000000..3ee2ef4 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/image-upload-button/image-upload-button.tsx @@ -0,0 +1,130 @@ +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { UseImageUploadConfig } from "@/components/tiptap-ui/image-upload-button" +import { + IMAGE_UPLOAD_SHORTCUT_KEY, + useImageUpload, +} from "@/components/tiptap-ui/image-upload-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +type IconProps = React.SVGProps +type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement + +export interface ImageUploadButtonProps + extends Omit, UseImageUploadConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean + /** + * Optional custom icon component to render instead of the default. + */ + icon?: React.MemoExoticComponent | React.FC +} + +export function ImageShortcutBadge({ + shortcutKeys = IMAGE_UPLOAD_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for uploading/inserting images in a Tiptap editor. + * + * For custom button implementations, use the `useImage` hook instead. + */ +export const ImageUploadButton = forwardRef< + HTMLButtonElement, + ImageUploadButtonProps +>( + ( + { + editor: providedEditor, + text, + hideWhenUnavailable = false, + onInserted, + showShortcut = false, + onClick, + icon: CustomIcon, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canInsert, + handleImage, + label, + isActive, + shortcutKeys, + Icon, + } = useImageUpload({ + editor, + hideWhenUnavailable, + onInserted, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleImage() + }, + [handleImage, onClick] + ) + + if (!isVisible) { + return null + } + + const RenderIcon = CustomIcon ?? Icon + + return ( + + ) + } +) + +ImageUploadButton.displayName = "ImageUploadButton" diff --git a/apps/block-editor/@/components/tiptap-ui/image-upload-button/index.tsx b/apps/block-editor/@/components/tiptap-ui/image-upload-button/index.tsx new file mode 100644 index 0000000..815d5bb --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/image-upload-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./image-upload-button" +export * from "./use-image-upload" diff --git a/apps/block-editor/@/components/tiptap-ui/image-upload-button/use-image-upload.ts b/apps/block-editor/@/components/tiptap-ui/image-upload-button/use-image-upload.ts new file mode 100644 index 0000000..f956ab1 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/image-upload-button/use-image-upload.ts @@ -0,0 +1,197 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useHotkeys } from "react-hotkeys-hook" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" + +// --- Lib --- +import { isExtensionAvailable } from "@/lib/tiptap-utils" + +// --- Icons --- +import { ImagePlusIcon } from "@/components/tiptap-icons/image-plus-icon" + +export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i" + +/** + * Configuration for the image upload functionality + */ +export interface UseImageUploadConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether the button should hide when insertion is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful image insertion. + */ + onInserted?: () => void +} + +/** + * Checks if image can be inserted in the current editor state + */ +export function canInsertImage(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!isExtensionAvailable(editor, "imageUpload")) return false + + return editor.can().insertContent({ type: "imageUpload" }) +} + +/** + * Checks if image is currently active + */ +export function isImageActive(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive("imageUpload") +} + +/** + * Inserts an image in the editor + */ +export function insertImage(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!canInsertImage(editor)) return false + + try { + return editor + .chain() + .focus() + .insertContent({ + type: "imageUpload", + }) + .run() + } catch { + return false + } +} + +/** + * Determines if the image button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!isExtensionAvailable(editor, "imageUpload")) return false + + if (!editor.isActive("code")) { + return canInsertImage(editor) + } + + return true +} + +/** + * Custom hook that provides image functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage - no params needed + * function MySimpleImageButton() { + * const { isVisible, handleImage } = useImage() + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedImageButton() { + * const { isVisible, handleImage, label, isActive } = useImage({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onInserted: () => console.log('Image inserted!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Add Image + * + * ) + * } + * ``` + */ +export function useImageUpload(config?: UseImageUploadConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onInserted, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const isMobile = useIsBreakpoint() + const [isVisible, setIsVisible] = useState(true) + const canInsert = canInsertImage(editor) + const isActive = isImageActive(editor) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + const handleImage = useCallback(() => { + if (!editor) return false + + const success = insertImage(editor) + if (success) { + onInserted?.() + } + return success + }, [editor, onInserted]) + + useHotkeys( + IMAGE_UPLOAD_SHORTCUT_KEY, + (event) => { + event.preventDefault() + handleImage() + }, + { + enabled: isVisible && canInsert, + enableOnContentEditable: !isMobile, + enableOnFormTags: true, + } + ) + + return { + isVisible, + isActive, + handleImage, + canInsert, + label: "Add image", + shortcutKeys: IMAGE_UPLOAD_SHORTCUT_KEY, + Icon: ImagePlusIcon, + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/link-popover/index.tsx b/apps/block-editor/@/components/tiptap-ui/link-popover/index.tsx new file mode 100644 index 0000000..e725ea8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/link-popover/index.tsx @@ -0,0 +1,2 @@ +export * from "./link-popover" +export * from "./use-link-popover" diff --git a/apps/block-editor/@/components/tiptap-ui/link-popover/link-popover.tsx b/apps/block-editor/@/components/tiptap-ui/link-popover/link-popover.tsx new file mode 100644 index 0000000..3225129 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/link-popover/link-popover.tsx @@ -0,0 +1,306 @@ +"use client" + +import { forwardRef, useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { CornerDownLeftIcon } from "@/components/tiptap-icons/corner-down-left-icon" +import { ExternalLinkIcon } from "@/components/tiptap-icons/external-link-icon" +import { LinkIcon } from "@/components/tiptap-icons/link-icon" +import { TrashIcon } from "@/components/tiptap-icons/trash-icon" + +// --- Tiptap UI --- +import type { UseLinkPopoverConfig } from "@/components/tiptap-ui/link-popover" +import { useLinkPopover } from "@/components/tiptap-ui/link-popover" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/tiptap-ui-primitive/popover" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import { + Card, + CardBody, + CardItemGroup, +} from "@/components/tiptap-ui-primitive/card" +import { Input, InputGroup } from "@/components/tiptap-ui-primitive/input" + +export interface LinkMainProps { + /** + * The URL to set for the link. + */ + url: string + /** + * Function to update the URL state. + */ + setUrl: React.Dispatch> + /** + * Function to set the link in the editor. + */ + setLink: () => void + /** + * Function to remove the link from the editor. + */ + removeLink: () => void + /** + * Function to open the link. + */ + openLink: () => void + /** + * Whether the link is currently active in the editor. + */ + isActive: boolean +} + +export interface LinkPopoverProps + extends Omit, UseLinkPopoverConfig { + /** + * Callback for when the popover opens or closes. + */ + onOpenChange?: (isOpen: boolean) => void + /** + * Whether to automatically open the popover when a link is active. + * @default true + */ + autoOpenOnLinkActive?: boolean +} + +/** + * Link button component for triggering the link popover + */ +export const LinkButton = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + ) + } +) + +LinkButton.displayName = "LinkButton" + +/** + * Main content component for the link popover + */ +const LinkMain: React.FC = ({ + url, + setUrl, + setLink, + removeLink, + openLink, + isActive, +}) => { + const isMobile = useIsBreakpoint() + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault() + setLink() + } + } + + return ( + + + + + setUrl(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + /> + + + + + + + + + + + + + + + + + ) +} + +/** + * Link content component for standalone use + */ +export const LinkContent: React.FC<{ + editor?: Editor | null +}> = ({ editor }) => { + const linkPopover = useLinkPopover({ + editor, + }) + + return +} + +/** + * Link popover component for Tiptap editors. + * + * For custom popover implementations, use the `useLinkPopover` hook instead. + */ +export const LinkPopover = forwardRef( + ( + { + editor: providedEditor, + hideWhenUnavailable = false, + onSetLink, + onOpenChange, + autoOpenOnLinkActive = true, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + + const { + isVisible, + canSet, + isActive, + url, + setUrl, + setLink, + removeLink, + openLink, + label, + Icon, + } = useLinkPopover({ + editor, + hideWhenUnavailable, + onSetLink, + }) + + const handleOnOpenChange = useCallback( + (nextIsOpen: boolean) => { + setIsOpen(nextIsOpen) + onOpenChange?.(nextIsOpen) + }, + [onOpenChange] + ) + + const handleSetLink = useCallback(() => { + setLink() + setIsOpen(false) + }, [setLink]) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + setIsOpen(!isOpen) + }, + [onClick, isOpen] + ) + + useEffect(() => { + if (autoOpenOnLinkActive && isActive) { + setIsOpen(true) + } + }, [autoOpenOnLinkActive, isActive]) + + if (!isVisible) { + return null + } + + return ( + + + + {children ?? } + + + + + + + + ) + } +) + +LinkPopover.displayName = "LinkPopover" + +export default LinkPopover diff --git a/apps/block-editor/@/components/tiptap-ui/link-popover/use-link-popover.ts b/apps/block-editor/@/components/tiptap-ui/link-popover/use-link-popover.ts new file mode 100644 index 0000000..31aef60 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/link-popover/use-link-popover.ts @@ -0,0 +1,297 @@ +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { LinkIcon } from "@/components/tiptap-icons/link-icon" + +// --- Lib --- +import { + isMarkInSchema, + isNodeTypeSelected, + sanitizeUrl, +} from "@/lib/tiptap-utils" + +/** + * Configuration for the link popover functionality + */ +export interface UseLinkPopoverConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether to hide the link popover when not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called when the link is set. + */ + onSetLink?: () => void +} + +/** + * Configuration for the link handler functionality + */ +export interface LinkHandlerProps { + /** + * The Tiptap editor instance. + */ + editor: Editor | null + /** + * Callback function called when the link is set. + */ + onSetLink?: () => void +} + +/** + * Checks if a link can be set in the current editor state + */ +export function canSetLink(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + + // The third argument 'true' checks whether the current selection is inside an image caption, and prevents setting a link there + // If the selection is inside an image caption, we can't set a link + if (isNodeTypeSelected(editor, ["image"], true)) return false + try { + return editor.can().setMark("link") + } catch { + return false + } +} + +/** + * Checks if a link is currently active in the editor + */ +export function isLinkActive(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive("link") +} + +/** + * Determines if the link button should be shown + */ +export function shouldShowLinkButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + + const linkInSchema = isMarkInSchema("link", editor) + + // If hideWhenUnavailable is false, always show the button (even if disabled) + if (!hideWhenUnavailable) { + return true + } + + // hideWhenUnavailable is true: hide if link is not in schema + if (!linkInSchema) { + return false + } + + // hideWhenUnavailable is true: hide if we can't set a link (unless in code block) + if (!editor.isActive("code")) { + return canSetLink(editor) + } + + return true +} + +/** + * Custom hook for handling link operations in a Tiptap editor + */ +export function useLinkHandler(props: LinkHandlerProps) { + const { editor, onSetLink } = props + const [url, setUrl] = useState(null) + + useEffect(() => { + if (!editor) return + + // Get URL immediately on mount + const { href } = editor.getAttributes("link") + + if (isLinkActive(editor) && url === null) { + setUrl(href || "") + } + }, [editor, url]) + + useEffect(() => { + if (!editor) return + + const updateLinkState = () => { + const { href } = editor.getAttributes("link") + setUrl(href || "") + } + + editor.on("selectionUpdate", updateLinkState) + return () => { + editor.off("selectionUpdate", updateLinkState) + } + }, [editor]) + + const setLink = useCallback(() => { + if (!url || !editor) return + + const { selection } = editor.state + const isEmpty = selection.empty + + let chain = editor.chain().focus() + + chain = chain.extendMarkRange("link").setLink({ href: url }) + + if (isEmpty) { + chain = chain.insertContent({ type: "text", text: url }) + } + + chain.run() + + setUrl(null) + + onSetLink?.() + }, [editor, onSetLink, url]) + + const removeLink = useCallback(() => { + if (!editor) return + editor + .chain() + .focus() + .extendMarkRange("link") + .unsetLink() + .setMeta("preventAutolink", true) + .run() + setUrl("") + }, [editor]) + + const openLink = useCallback( + (target: string = "_blank", features: string = "noopener,noreferrer") => { + if (!url) return + + const safeUrl = sanitizeUrl(url, window.location.href) + if (safeUrl !== "#") { + window.open(safeUrl, target, features) + } + }, + [url] + ) + + return { + url: url || "", + setUrl, + setLink, + removeLink, + openLink, + } +} + +/** + * Custom hook for link popover state management + */ +export function useLinkState(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}) { + const { editor, hideWhenUnavailable = false } = props + + const canSet = canSetLink(editor) + const isActive = isLinkActive(editor) + + const [isVisible, setIsVisible] = useState(true) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible( + shouldShowLinkButton({ + editor, + hideWhenUnavailable, + }) + ) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + return { + isVisible, + canSet, + isActive, + } +} + +/** + * Main hook that provides link popover functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MyLinkButton() { + * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover() + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedLinkButton() { + * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onSetLink: () => console.log('Link set!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * + * {label} + * + * ) + * } + * ``` + */ +export function useLinkPopover(config?: UseLinkPopoverConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onSetLink, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + + const { isVisible, canSet, isActive } = useLinkState({ + editor, + hideWhenUnavailable, + }) + + const linkHandler = useLinkHandler({ + editor, + onSetLink, + }) + + return { + isVisible, + canSet, + isActive, + label: "Link", + Icon: LinkIcon, + ...linkHandler, + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/list-button/index.tsx b/apps/block-editor/@/components/tiptap-ui/list-button/index.tsx new file mode 100644 index 0000000..9f3d066 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/list-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./list-button" +export * from "./use-list" diff --git a/apps/block-editor/@/components/tiptap-ui/list-button/list-button.tsx b/apps/block-editor/@/components/tiptap-ui/list-button/list-button.tsx new file mode 100644 index 0000000..13a3909 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/list-button/list-button.tsx @@ -0,0 +1,120 @@ +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +// --- Tiptap UI --- +import type { ListType, UseListConfig } from "@/components/tiptap-ui/list-button" +import { LIST_SHORTCUT_KEYS, useList } from "@/components/tiptap-ui/list-button" + +export interface ListButtonProps + extends Omit, UseListConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function ListShortcutBadge({ + type, + shortcutKeys = LIST_SHORTCUT_KEYS[type], +}: { + type: ListType + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling lists in a Tiptap editor. + * + * For custom button implementations, use the `useList` hook instead. + */ +export const ListButton = forwardRef( + ( + { + editor: providedEditor, + type, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + shortcutKeys, + Icon, + } = useList({ + editor, + type, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +ListButton.displayName = "ListButton" diff --git a/apps/block-editor/@/components/tiptap-ui/list-button/use-list.ts b/apps/block-editor/@/components/tiptap-ui/list-button/use-list.ts new file mode 100644 index 0000000..4a07947 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/list-button/use-list.ts @@ -0,0 +1,351 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { ListIcon } from "@/components/tiptap-icons/list-icon" +import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon" +import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon" + +// --- Lib --- +import { + findNodePosition, + getSelectedBlockNodes, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "@/lib/tiptap-utils" + +export type ListType = "bulletList" | "orderedList" | "taskList" + +/** + * Configuration for the list functionality + */ +export interface UseListConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The type of list to toggle. + */ + type: ListType + /** + * Whether the button should hide when list is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful toggle. + */ + onToggled?: () => void +} + +export const listIcons = { + bulletList: ListIcon, + orderedList: ListOrderedIcon, + taskList: ListTodoIcon, +} + +export const listLabels: Record = { + bulletList: "Bullet List", + orderedList: "Ordered List", + taskList: "Task List", +} + +export const LIST_SHORTCUT_KEYS: Record = { + bulletList: "mod+shift+8", + orderedList: "mod+shift+7", + taskList: "mod+shift+9", +} + +/** + * Checks if a list can be toggled in the current editor state + */ +export function canToggleList( + editor: Editor | null, + type: ListType, + turnInto: boolean = true +): boolean { + if (!editor || !editor.isEditable) return false + if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) + return false + + if (!turnInto) { + switch (type) { + case "bulletList": + return editor.can().toggleBulletList() + case "orderedList": + return editor.can().toggleOrderedList() + case "taskList": + return editor.can().toggleList("taskList", "taskItem") + default: + return false + } + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can set list directly on the selection, + // or we can clear formatting/nodes to arrive at a list. + switch (type) { + case "bulletList": + return editor.can().toggleBulletList() || editor.can().clearNodes() + case "orderedList": + return editor.can().toggleOrderedList() || editor.can().clearNodes() + case "taskList": + return ( + editor.can().toggleList("taskList", "taskItem") || + editor.can().clearNodes() + ) + default: + return false + } +} + +/** + * Checks if list is currently active + */ +export function isListActive(editor: Editor | null, type: ListType): boolean { + if (!editor || !editor.isEditable) return false + + switch (type) { + case "bulletList": + return editor.isActive("bulletList") + case "orderedList": + return editor.isActive("orderedList") + case "taskList": + return editor.isActive("taskList") + default: + return false + } +} + +/** + * Toggles list in the editor + */ +export function toggleList(editor: Editor | null, type: ListType): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggleList(editor, type)) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + const blocks = getSelectedBlockNodes(editor) + + // In case a selection contains multiple blocks, we only allow + // toggling to nide if there's exactly one block selected + // we also dont block the canToggle since it will fall back to the bottom logic + const isPossibleToTurnInto = + selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) && blocks.length === 1 + + // No selection, find the the cursor position + if ( + (state.selection.empty || state.selection instanceof TextSelection) && + isPossibleToTurnInto + ) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + if (editor.isActive(type)) { + // Unwrap list + chain + .liftListItem("listItem") + .lift("bulletList") + .lift("orderedList") + .lift("taskList") + .run() + } else { + // Wrap in specific list type + const toggleMap: Record typeof chain> = { + bulletList: () => chain.toggleBulletList(), + orderedList: () => chain.toggleOrderedList(), + taskList: () => chain.toggleList("taskList", "taskItem"), + } + + const toggle = toggleMap[type] + if (!toggle) return false + + toggle().run() + } + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the list button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + type: ListType + hideWhenUnavailable: boolean +}): boolean { + const { editor, type, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!isNodeInSchema(type, editor)) return false + + if (!editor.isActive("code")) { + return canToggleList(editor, type) + } + + return true +} + +/** + * Custom hook that provides list functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleListButton() { + * const { isVisible, handleToggle, isActive } = useList({ type: "bulletList" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedListButton() { + * const { isVisible, handleToggle, label, isActive } = useList({ + * type: "orderedList", + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: () => console.log('List toggled!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Toggle List + * + * ) + * } + * ``` + */ +export function useList(config: UseListConfig) { + const { + editor: providedEditor, + type, + hideWhenUnavailable = false, + onToggled, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggle = canToggleList(editor, type) + const isActive = isListActive(editor, type) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, type, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleList(editor, type) + if (success) { + onToggled?.() + } + return success + }, [editor, type, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle, + label: listLabels[type], + shortcutKeys: LIST_SHORTCUT_KEYS[type], + Icon: listIcons[type], + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/list-dropdown-menu/index.tsx b/apps/block-editor/@/components/tiptap-ui/list-dropdown-menu/index.tsx new file mode 100644 index 0000000..9a215b8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/list-dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from "./list-dropdown-menu" diff --git a/apps/block-editor/@/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx b/apps/block-editor/@/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx new file mode 100644 index 0000000..b225c64 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx @@ -0,0 +1,123 @@ +import { useCallback, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon" + +// --- Tiptap UI --- +import { ListButton, type ListType } from "@/components/tiptap-ui/list-button" + +import { useListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/tiptap-ui-primitive/dropdown-menu" +import { Card, CardBody } from "@/components/tiptap-ui-primitive/card" + +export interface ListDropdownMenuProps extends Omit { + /** + * The Tiptap editor instance. + */ + editor?: Editor + /** + * The list types to display in the dropdown. + */ + types?: ListType[] + /** + * Whether the dropdown should be hidden when no list types are available + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback for when the dropdown opens or closes + */ + onOpenChange?: (isOpen: boolean) => void + /** + * Whether to render the dropdown menu in a portal + * @default false + */ + portal?: boolean +} + +export function ListDropdownMenu({ + editor: providedEditor, + types = ["bulletList", "orderedList", "taskList"], + hideWhenUnavailable = false, + onOpenChange, + portal = false, + ...props +}: ListDropdownMenuProps) { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + + const { filteredLists, canToggle, isActive, isVisible, Icon } = + useListDropdownMenu({ + editor, + types, + hideWhenUnavailable, + }) + + const handleOnOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open) + onOpenChange?.(open) + }, + [onOpenChange] + ) + + if (!isVisible) { + return null + } + + return ( + + + + + + + + + + {filteredLists.map((option) => ( + + + + ))} + + + + + + ) +} + +export default ListDropdownMenu diff --git a/apps/block-editor/@/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts b/apps/block-editor/@/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts new file mode 100644 index 0000000..35299a0 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts @@ -0,0 +1,220 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { ListIcon } from "@/components/tiptap-icons/list-icon" +import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon" +import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon" + +// --- Lib --- +import { isNodeInSchema } from "@/lib/tiptap-utils" + +// --- Tiptap UI --- +import { + canToggleList, + isListActive, + listIcons, + type ListType, +} from "@/components/tiptap-ui/list-button" + +/** + * Configuration for the list dropdown menu functionality + */ +export interface UseListDropdownMenuConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The list types to display in the dropdown. + * @default ["bulletList", "orderedList", "taskList"] + */ + types?: ListType[] + /** + * Whether the dropdown should be hidden when no list types are available + * @default false + */ + hideWhenUnavailable?: boolean +} + +export interface ListOption { + label: string + type: ListType + icon: React.ElementType +} + +export const listOptions: ListOption[] = [ + { + label: "Bullet List", + type: "bulletList", + icon: ListIcon, + }, + { + label: "Ordered List", + type: "orderedList", + icon: ListOrderedIcon, + }, + { + label: "Task List", + type: "taskList", + icon: ListTodoIcon, + }, +] + +export function canToggleAnyList( + editor: Editor | null, + listTypes: ListType[] +): boolean { + if (!editor || !editor.isEditable) return false + return listTypes.some((type) => canToggleList(editor, type)) +} + +export function isAnyListActive( + editor: Editor | null, + listTypes: ListType[] +): boolean { + if (!editor || !editor.isEditable) return false + return listTypes.some((type) => isListActive(editor, type)) +} + +export function getFilteredListOptions( + availableTypes: ListType[] +): typeof listOptions { + return listOptions.filter( + (option) => !option.type || availableTypes.includes(option.type) + ) +} + +export function shouldShowListDropdown(params: { + editor: Editor | null + listTypes: ListType[] + hideWhenUnavailable: boolean + listInSchema: boolean + canToggleAny: boolean +}): boolean { + const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params + + if (!editor) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!listInSchema) return false + + if (!editor.isActive("code")) { + return canToggleAny + } + + return true +} + +/** + * Gets the currently active list type from the available types + */ +export function getActiveListType( + editor: Editor | null, + availableTypes: ListType[] +): ListType | undefined { + if (!editor || !editor.isEditable) return undefined + return availableTypes.find((type) => isListActive(editor, type)) +} + +/** + * Custom hook that provides list dropdown menu functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MyListDropdown() { + * const { + * isVisible, + * activeType, + * isAnyActive, + * canToggleAny, + * filteredLists, + * } = useListDropdownMenu() + * + * if (!isVisible) return null + * + * return ( + * + * // dropdown content + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedListDropdown() { + * const { + * isVisible, + * activeType, + * } = useListDropdownMenu({ + * editor: myEditor, + * types: ["bulletList", "orderedList"], + * hideWhenUnavailable: true, + * }) + * + * // component implementation + * } + * ``` + */ +export function useListDropdownMenu(config?: UseListDropdownMenuConfig) { + const { + editor: providedEditor, + types = ["bulletList", "orderedList", "taskList"], + hideWhenUnavailable = false, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + + const listInSchema = types.some((type) => isNodeInSchema(type, editor)) + + const filteredLists = useMemo(() => getFilteredListOptions(types), [types]) + + const canToggleAny = canToggleAnyList(editor, types) + const isAnyActive = isAnyListActive(editor, types) + const activeType = getActiveListType(editor, types) + const activeList = filteredLists.find((option) => option.type === activeType) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible( + shouldShowListDropdown({ + editor, + listTypes: types, + hideWhenUnavailable, + listInSchema, + canToggleAny, + }) + ) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [canToggleAny, editor, hideWhenUnavailable, listInSchema, types]) + + return { + isVisible, + activeType, + isActive: isAnyActive, + canToggle: canToggleAny, + types, + filteredLists, + label: "List", + Icon: activeList ? listIcons[activeList.type] : ListIcon, + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/mark-button/index.tsx b/apps/block-editor/@/components/tiptap-ui/mark-button/index.tsx new file mode 100644 index 0000000..32e85b9 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/mark-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./mark-button" +export * from "./use-mark" diff --git a/apps/block-editor/@/components/tiptap-ui/mark-button/mark-button.tsx b/apps/block-editor/@/components/tiptap-ui/mark-button/mark-button.tsx new file mode 100644 index 0000000..551b46d --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/mark-button/mark-button.tsx @@ -0,0 +1,122 @@ +"use client" + +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { Mark, UseMarkConfig } from "@/components/tiptap-ui/mark-button" +import { MARK_SHORTCUT_KEYS, useMark } from "@/components/tiptap-ui/mark-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +export interface MarkButtonProps + extends Omit, UseMarkConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function MarkShortcutBadge({ + type, + shortcutKeys = MARK_SHORTCUT_KEYS[type], +}: { + type: Mark + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling marks in a Tiptap editor. + * + * For custom button implementations, use the `useMark` hook instead. + */ +export const MarkButton = forwardRef( + ( + { + editor: providedEditor, + type, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + handleMark, + label, + canToggle, + isActive, + Icon, + shortcutKeys, + } = useMark({ + editor, + type, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleMark() + }, + [handleMark, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +MarkButton.displayName = "MarkButton" diff --git a/apps/block-editor/@/components/tiptap-ui/mark-button/use-mark.ts b/apps/block-editor/@/components/tiptap-ui/mark-button/use-mark.ts new file mode 100644 index 0000000..8d13ec8 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/mark-button/use-mark.ts @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { isMarkInSchema, isNodeTypeSelected } from "@/lib/tiptap-utils" + +// --- Icons --- +import { BoldIcon } from "@/components/tiptap-icons/bold-icon" +import { Code2Icon } from "@/components/tiptap-icons/code2-icon" +import { ItalicIcon } from "@/components/tiptap-icons/italic-icon" +import { StrikeIcon } from "@/components/tiptap-icons/strike-icon" +import { SubscriptIcon } from "@/components/tiptap-icons/subscript-icon" +import { SuperscriptIcon } from "@/components/tiptap-icons/superscript-icon" +import { UnderlineIcon } from "@/components/tiptap-icons/underline-icon" + +export type Mark = + | "bold" + | "italic" + | "strike" + | "code" + | "underline" + | "superscript" + | "subscript" + +/** + * Configuration for the mark functionality + */ +export interface UseMarkConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The type of mark to toggle + */ + type: Mark + /** + * Whether the button should hide when mark is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful mark toggle. + */ + onToggled?: () => void +} + +export const markIcons = { + bold: BoldIcon, + italic: ItalicIcon, + underline: UnderlineIcon, + strike: StrikeIcon, + code: Code2Icon, + superscript: SuperscriptIcon, + subscript: SubscriptIcon, +} + +export const MARK_SHORTCUT_KEYS: Record = { + bold: "mod+b", + italic: "mod+i", + underline: "mod+u", + strike: "mod+shift+s", + code: "mod+e", + superscript: "mod+.", + subscript: "mod+,", +} + +/** + * Checks if a mark can be toggled in the current editor state + */ +export function canToggleMark(editor: Editor | null, type: Mark): boolean { + if (!editor || !editor.isEditable) return false + if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) + return false + + return editor.can().toggleMark(type) +} + +/** + * Checks if a mark is currently active + */ +export function isMarkActive(editor: Editor | null, type: Mark): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive(type) +} + +/** + * Toggles a mark in the editor + */ +export function toggleMark(editor: Editor | null, type: Mark): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggleMark(editor, type)) return false + + return editor.chain().focus().toggleMark(type).run() +} + +/** + * Determines if the mark button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + type: Mark + hideWhenUnavailable: boolean +}): boolean { + const { editor, type, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!isMarkInSchema(type, editor)) return false + + if (!editor.isActive("code")) { + return canToggleMark(editor, type) + } + + return true +} + +/** + * Gets the formatted mark name + */ +export function getFormattedMarkName(type: Mark): string { + return type.charAt(0).toUpperCase() + type.slice(1) +} + +/** + * Custom hook that provides mark functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleBoldButton() { + * const { isVisible, handleMark } = useMark({ type: "bold" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedItalicButton() { + * const { isVisible, handleMark, label, isActive } = useMark({ + * editor: myEditor, + * type: "italic", + * hideWhenUnavailable: true, + * onToggled: () => console.log('Mark toggled!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Italic + * + * ) + * } + * ``` + */ +export function useMark(config: UseMarkConfig) { + const { + editor: providedEditor, + type, + hideWhenUnavailable = false, + onToggled, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggle = canToggleMark(editor, type) + const isActive = isMarkActive(editor, type) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, type, hideWhenUnavailable]) + + const handleMark = useCallback(() => { + if (!editor) return false + + const success = toggleMark(editor, type) + if (success) { + onToggled?.() + } + return success + }, [editor, type, onToggled]) + + return { + isVisible, + isActive, + handleMark, + canToggle, + label: getFormattedMarkName(type), + shortcutKeys: MARK_SHORTCUT_KEYS[type], + Icon: markIcons[type], + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/text-align-button/index.tsx b/apps/block-editor/@/components/tiptap-ui/text-align-button/index.tsx new file mode 100644 index 0000000..d19f95c --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/text-align-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./text-align-button" +export * from "./use-text-align" diff --git a/apps/block-editor/@/components/tiptap-ui/text-align-button/text-align-button.tsx b/apps/block-editor/@/components/tiptap-ui/text-align-button/text-align-button.tsx new file mode 100644 index 0000000..6e362c5 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/text-align-button/text-align-button.tsx @@ -0,0 +1,144 @@ +"use client" + +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { + TextAlign, + UseTextAlignConfig, +} from "@/components/tiptap-ui/text-align-button" +import { + TEXT_ALIGN_SHORTCUT_KEYS, + useTextAlign, +} from "@/components/tiptap-ui/text-align-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +type IconProps = React.SVGProps +type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement + +export interface TextAlignButtonProps + extends Omit, UseTextAlignConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean + /** + * Optional custom icon component to render instead of the default. + */ + icon?: React.MemoExoticComponent | React.FC +} + +export function TextAlignShortcutBadge({ + align, + shortcutKeys = TEXT_ALIGN_SHORTCUT_KEYS[align], +}: { + align: TextAlign + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for setting text alignment in a Tiptap editor. + * + * For custom button implementations, use the `useTextAlign` hook instead. + */ +export const TextAlignButton = forwardRef< + HTMLButtonElement, + TextAlignButtonProps +>( + ( + { + editor: providedEditor, + align, + text, + hideWhenUnavailable = false, + onAligned, + showShortcut = false, + onClick, + icon: CustomIcon, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + handleTextAlign, + label, + canAlign, + isActive, + Icon, + shortcutKeys, + } = useTextAlign({ + editor, + align, + hideWhenUnavailable, + onAligned, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleTextAlign() + }, + [handleTextAlign, onClick] + ) + + if (!isVisible) { + return null + } + + const RenderIcon = CustomIcon ?? Icon + + return ( + + ) + } +) + +TextAlignButton.displayName = "TextAlignButton" diff --git a/apps/block-editor/@/components/tiptap-ui/text-align-button/use-text-align.ts b/apps/block-editor/@/components/tiptap-ui/text-align-button/use-text-align.ts new file mode 100644 index 0000000..cd95d93 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/text-align-button/use-text-align.ts @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useState } from "react" +import type { ChainedCommands } from "@tiptap/react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { + isExtensionAvailable, + isNodeTypeSelected, +} from "@/lib/tiptap-utils" + +// --- Icons --- +import { AlignCenterIcon } from "@/components/tiptap-icons/align-center-icon" +import { AlignJustifyIcon } from "@/components/tiptap-icons/align-justify-icon" +import { AlignLeftIcon } from "@/components/tiptap-icons/align-left-icon" +import { AlignRightIcon } from "@/components/tiptap-icons/align-right-icon" + +export type TextAlign = "left" | "center" | "right" | "justify" + +/** + * Configuration for the text align functionality + */ +export interface UseTextAlignConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The text alignment to apply. + */ + align: TextAlign + /** + * Whether the button should hide when alignment is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful alignment change. + */ + onAligned?: () => void +} + +export const TEXT_ALIGN_SHORTCUT_KEYS: Record = { + left: "mod+shift+l", + center: "mod+shift+e", + right: "mod+shift+r", + justify: "mod+shift+j", +} + +export const textAlignIcons = { + left: AlignLeftIcon, + center: AlignCenterIcon, + right: AlignRightIcon, + justify: AlignJustifyIcon, +} + +export const textAlignLabels: Record = { + left: "Align left", + center: "Align center", + right: "Align right", + justify: "Align justify", +} + +/** + * Checks if text alignment can be performed in the current editor state + */ +export function canSetTextAlign( + editor: Editor | null, + align: TextAlign +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isExtensionAvailable(editor, "textAlign") || + isNodeTypeSelected(editor, ["image", "horizontalRule"]) + ) + return false + + return editor.can().setTextAlign(align) +} + +export function hasSetTextAlign( + commands: ChainedCommands +): commands is ChainedCommands & { + setTextAlign: (align: TextAlign) => ChainedCommands +} { + return "setTextAlign" in commands +} + +/** + * Checks if the text alignment is currently active + */ +export function isTextAlignActive( + editor: Editor | null, + align: TextAlign +): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive({ textAlign: align }) +} + +/** + * Sets text alignment in the editor + */ +export function setTextAlign(editor: Editor | null, align: TextAlign): boolean { + if (!editor || !editor.isEditable) return false + if (!canSetTextAlign(editor, align)) return false + + const chain = editor.chain().focus() + if (hasSetTextAlign(chain)) { + return chain.setTextAlign(align).run() + } + + return false +} + +/** + * Determines if the text align button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean + align: TextAlign +}): boolean { + const { editor, hideWhenUnavailable, align } = props + + if (!editor || !editor.isEditable) return false + + if (!hideWhenUnavailable) { + return true + } + + if (!isExtensionAvailable(editor, "textAlign")) return false + + if (!editor.isActive("code")) { + return canSetTextAlign(editor, align) + } + + return true +} + +/** + * Custom hook that provides text align functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleAlignButton() { + * const { isVisible, handleTextAlign } = useTextAlign({ align: "center" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedAlignButton() { + * const { isVisible, handleTextAlign, label, isActive } = useTextAlign({ + * editor: myEditor, + * align: "right", + * hideWhenUnavailable: true, + * onAligned: () => console.log('Text aligned!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Align Right + * + * ) + * } + * ``` + */ +export function useTextAlign(config: UseTextAlignConfig) { + const { + editor: providedEditor, + align, + hideWhenUnavailable = false, + onAligned, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canAlign = canSetTextAlign(editor, align) + const isActive = isTextAlignActive(editor, align) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable, align]) + + const handleTextAlign = useCallback(() => { + if (!editor) return false + + const success = setTextAlign(editor, align) + if (success) { + onAligned?.() + } + return success + }, [editor, align, onAligned]) + + return { + isVisible, + isActive, + handleTextAlign, + canAlign, + label: textAlignLabels[align], + shortcutKeys: TEXT_ALIGN_SHORTCUT_KEYS[align], + Icon: textAlignIcons[align], + } +} diff --git a/apps/block-editor/@/components/tiptap-ui/undo-redo-button/index.tsx b/apps/block-editor/@/components/tiptap-ui/undo-redo-button/index.tsx new file mode 100644 index 0000000..fa0fdbe --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/undo-redo-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./undo-redo-button" +export * from "./use-undo-redo" diff --git a/apps/block-editor/@/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx b/apps/block-editor/@/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx new file mode 100644 index 0000000..84c2506 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx @@ -0,0 +1,125 @@ +"use client" + +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { + UndoRedoAction, + UseUndoRedoConfig, +} from "@/components/tiptap-ui/undo-redo-button" +import { + UNDO_REDO_SHORTCUT_KEYS, + useUndoRedo, +} from "@/components/tiptap-ui/undo-redo-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +export interface UndoRedoButtonProps + extends Omit, UseUndoRedoConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function HistoryShortcutBadge({ + action, + shortcutKeys = UNDO_REDO_SHORTCUT_KEYS[action], +}: { + action: UndoRedoAction + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for triggering undo/redo actions in a Tiptap editor. + * + * For custom button implementations, use the `useHistory` hook instead. + */ +export const UndoRedoButton = forwardRef< + HTMLButtonElement, + UndoRedoButtonProps +>( + ( + { + editor: providedEditor, + action, + text, + hideWhenUnavailable = false, + onExecuted, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { isVisible, handleAction, label, canExecute, Icon, shortcutKeys } = + useUndoRedo({ + editor, + action, + hideWhenUnavailable, + onExecuted, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleAction() + }, + [handleAction, onClick] + ) + + if (!isVisible) { + return null + } + + return ( + + ) + } +) + +UndoRedoButton.displayName = "UndoRedoButton" diff --git a/apps/block-editor/@/components/tiptap-ui/undo-redo-button/use-undo-redo.ts b/apps/block-editor/@/components/tiptap-ui/undo-redo-button/use-undo-redo.ts new file mode 100644 index 0000000..ea4d845 --- /dev/null +++ b/apps/block-editor/@/components/tiptap-ui/undo-redo-button/use-undo-redo.ts @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { isNodeTypeSelected } from "@/lib/tiptap-utils" + +// --- Icons --- +import { Redo2Icon } from "@/components/tiptap-icons/redo2-icon" +import { Undo2Icon } from "@/components/tiptap-icons/undo2-icon" + +export type UndoRedoAction = "undo" | "redo" + +/** + * Configuration for the history functionality + */ +export interface UseUndoRedoConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The history action to perform (undo or redo). + */ + action: UndoRedoAction + /** + * Whether the button should hide when action is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful action execution. + */ + onExecuted?: () => void +} + +export const UNDO_REDO_SHORTCUT_KEYS: Record = { + undo: "mod+z", + redo: "mod+shift+z", +} + +export const historyActionLabels: Record = { + undo: "Undo", + redo: "Redo", +} + +export const historyIcons = { + undo: Undo2Icon, + redo: Redo2Icon, +} + +/** + * Checks if a history action can be executed + */ +export function canExecuteUndoRedoAction( + editor: Editor | null, + action: UndoRedoAction +): boolean { + if (!editor || !editor.isEditable) return false + if (isNodeTypeSelected(editor, ["image"])) return false + + return action === "undo" ? editor.can().undo() : editor.can().redo() +} + +/** + * Executes a history action on the editor + */ +export function executeUndoRedoAction( + editor: Editor | null, + action: UndoRedoAction +): boolean { + if (!editor || !editor.isEditable) return false + if (!canExecuteUndoRedoAction(editor, action)) return false + + const chain = editor.chain().focus() + return action === "undo" ? chain.undo().run() : chain.redo().run() +} + +/** + * Determines if the history button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean + action: UndoRedoAction +}): boolean { + const { editor, hideWhenUnavailable, action } = props + + if (!editor || !editor.isEditable) return false + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canExecuteUndoRedoAction(editor, action) + } + + return true +} + +/** + * Custom hook that provides history functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleUndoButton() { + * const { isVisible, handleAction } = useHistory({ action: "undo" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedRedoButton() { + * const { isVisible, handleAction, label } = useHistory({ + * editor: myEditor, + * action: "redo", + * hideWhenUnavailable: true, + * onExecuted: () => console.log('Action executed!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Redo + * + * ) + * } + * ``` + */ +export function useUndoRedo(config: UseUndoRedoConfig) { + const { + editor: providedEditor, + action, + hideWhenUnavailable = false, + onExecuted, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canExecute = canExecuteUndoRedoAction(editor, action) + + useEffect(() => { + if (!editor) return + + const handleUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action })) + } + + handleUpdate() + + editor.on("transaction", handleUpdate) + + return () => { + editor.off("transaction", handleUpdate) + } + }, [editor, hideWhenUnavailable, action]) + + const handleAction = useCallback(() => { + if (!editor) return false + + const success = executeUndoRedoAction(editor, action) + if (success) { + onExecuted?.() + } + return success + }, [editor, action, onExecuted]) + + return { + isVisible, + handleAction, + canExecute, + label: historyActionLabels[action], + shortcutKeys: UNDO_REDO_SHORTCUT_KEYS[action], + Icon: historyIcons[action], + } +} diff --git a/apps/block-editor/@/hooks/use-composed-ref.ts b/apps/block-editor/@/hooks/use-composed-ref.ts new file mode 100644 index 0000000..30745b8 --- /dev/null +++ b/apps/block-editor/@/hooks/use-composed-ref.ts @@ -0,0 +1,47 @@ +"use client" + +import { useCallback, useRef } from "react" + +// basically Exclude["ref"], string> +type UserRef = + | ((instance: T | null) => void) + | React.RefObject + | null + | undefined + +const updateRef = (ref: NonNullable>, value: T | null) => { + if (typeof ref === "function") { + ref(value) + } else if (ref && typeof ref === "object" && "current" in ref) { + // Safe assignment without MutableRefObject + ;(ref as { current: T | null }).current = value + } +} + +export const useComposedRef = ( + libRef: React.RefObject, + userRef: UserRef +) => { + const prevUserRef = useRef>(null) + + return useCallback( + (instance: T | null) => { + if (libRef && "current" in libRef) { + ;(libRef as { current: T | null }).current = instance + } + + if (prevUserRef.current) { + updateRef(prevUserRef.current, null) + } + + prevUserRef.current = userRef + + if (userRef) { + updateRef(userRef, instance) + } + }, + [libRef, userRef] + ) +} + +export default useComposedRef diff --git a/apps/block-editor/@/hooks/use-cursor-visibility.ts b/apps/block-editor/@/hooks/use-cursor-visibility.ts new file mode 100644 index 0000000..19668d8 --- /dev/null +++ b/apps/block-editor/@/hooks/use-cursor-visibility.ts @@ -0,0 +1,69 @@ +import type { Editor } from "@tiptap/react" +import { useWindowSize } from "@/hooks/use-window-size" +import { useBodyRect } from "@/hooks/use-element-rect" +import { useEffect } from "react" + +export interface CursorVisibilityOptions { + /** + * The Tiptap editor instance + */ + editor?: Editor | null + /** + * Reference to the toolbar element that may obscure the cursor + */ + overlayHeight?: number +} + +/** + * Custom hook that ensures the cursor remains visible when typing in a Tiptap editor. + * Automatically scrolls the window when the cursor would be hidden by the toolbar. + * + * @param options.editor The Tiptap editor instance + * @param options.overlayHeight Toolbar height to account for + * @returns The bounding rect of the body + */ +export function useCursorVisibility({ + editor, + overlayHeight = 0, +}: CursorVisibilityOptions) { + const { height: windowHeight } = useWindowSize() + const rect = useBodyRect({ + enabled: true, + throttleMs: 100, + useResizeObserver: true, + }) + + useEffect(() => { + const ensureCursorVisibility = () => { + if (!editor) return + + const { state, view } = editor + if (!view.hasFocus()) return + + // Get current cursor position coordinates + const { from } = state.selection + const cursorCoords = view.coordsAtPos(from) + + if (windowHeight < rect.height && cursorCoords) { + const availableSpace = windowHeight - cursorCoords.top + + // If the cursor is hidden behind the overlay or offscreen, scroll it into view + if (availableSpace < overlayHeight) { + const targetCursorY = Math.max(windowHeight / 2, overlayHeight) + const currentScrollY = window.scrollY + const cursorAbsoluteY = cursorCoords.top + currentScrollY + const newScrollY = cursorAbsoluteY - targetCursorY + + window.scrollTo({ + top: Math.max(0, newScrollY), + behavior: "smooth", + }) + } + } + } + + ensureCursorVisibility() + }, [editor, overlayHeight, windowHeight, rect.height]) + + return rect +} diff --git a/apps/block-editor/@/hooks/use-element-rect.ts b/apps/block-editor/@/hooks/use-element-rect.ts new file mode 100644 index 0000000..55ba9bd --- /dev/null +++ b/apps/block-editor/@/hooks/use-element-rect.ts @@ -0,0 +1,166 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useThrottledCallback } from "@/hooks/use-throttled-callback" + +export type RectState = Omit + +export interface ElementRectOptions { + /** + * The element to track. Can be an Element, ref, or selector string. + * Defaults to document.body if not provided. + */ + element?: Element | React.RefObject | string | null + /** + * Whether to enable rect tracking + */ + enabled?: boolean + /** + * Throttle delay in milliseconds for rect updates + */ + throttleMs?: number + /** + * Whether to use ResizeObserver for more accurate tracking + */ + useResizeObserver?: boolean +} + +const initialRect: RectState = { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, +} + +const isSSR = typeof window === "undefined" +const hasResizeObserver = !isSSR && typeof ResizeObserver !== "undefined" + +/** + * Helper function to check if code is running on client side + */ +const isClientSide = (): boolean => !isSSR + +/** + * Custom hook that tracks an element's bounding rectangle and updates on resize, scroll, etc. + * + * @param options Configuration options for element rect tracking + * @returns The current bounding rectangle of the element + */ +export function useElementRect({ + element, + enabled = true, + throttleMs = 100, + useResizeObserver = true, +}: ElementRectOptions = {}): RectState { + const [rect, setRect] = useState(initialRect) + + const getTargetElement = useCallback((): Element | null => { + if (!enabled || !isClientSide()) return null + + if (!element) { + return document.body + } + + if (typeof element === "string") { + return document.querySelector(element) + } + + if ("current" in element) { + return element.current + } + + return element + }, [element, enabled]) + + const updateRect = useThrottledCallback( + () => { + if (!enabled || !isClientSide()) return + + const targetElement = getTargetElement() + if (!targetElement) { + setRect(initialRect) + return + } + + const newRect = targetElement.getBoundingClientRect() + setRect({ + x: newRect.x, + y: newRect.y, + width: newRect.width, + height: newRect.height, + top: newRect.top, + right: newRect.right, + bottom: newRect.bottom, + left: newRect.left, + }) + }, + throttleMs, + [enabled, getTargetElement], + { leading: true, trailing: true } + ) + + useEffect(() => { + if (!enabled || !isClientSide()) { + setRect(initialRect) + return + } + + const targetElement = getTargetElement() + if (!targetElement) return + + updateRect() + + const cleanup: (() => void)[] = [] + + if (useResizeObserver && hasResizeObserver) { + const resizeObserver = new ResizeObserver(() => { + window.requestAnimationFrame(updateRect) + }) + resizeObserver.observe(targetElement) + cleanup.push(() => resizeObserver.disconnect()) + } + + const handleUpdate = () => updateRect() + + window.addEventListener("scroll", handleUpdate, true) + window.addEventListener("resize", handleUpdate, true) + + cleanup.push(() => { + window.removeEventListener("scroll", handleUpdate) + window.removeEventListener("resize", handleUpdate) + }) + + return () => { + cleanup.forEach((fn) => fn()) + setRect(initialRect) + } + }, [enabled, getTargetElement, updateRect, useResizeObserver]) + + return rect +} + +/** + * Convenience hook for tracking document.body rect + */ +export function useBodyRect( + options: Omit = {} +): RectState { + return useElementRect({ + ...options, + element: isClientSide() ? document.body : null, + }) +} + +/** + * Convenience hook for tracking a ref element's rect + */ +export function useRefRect( + ref: React.RefObject, + options: Omit = {} +): RectState { + return useElementRect({ ...options, element: ref }) +} diff --git a/apps/block-editor/@/hooks/use-is-breakpoint.ts b/apps/block-editor/@/hooks/use-is-breakpoint.ts new file mode 100644 index 0000000..50f1874 --- /dev/null +++ b/apps/block-editor/@/hooks/use-is-breakpoint.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react" + +type BreakpointMode = "min" | "max" + +/** + * Hook to detect whether the current viewport matches a given breakpoint rule. + * Example: + * useIsBreakpoint("max", 768) // true when width < 768 + * useIsBreakpoint("min", 1024) // true when width >= 1024 + */ +export function useIsBreakpoint( + mode: BreakpointMode = "max", + breakpoint = 768 +) { + const [matches, setMatches] = useState(undefined) + + useEffect(() => { + const query = + mode === "min" + ? `(min-width: ${breakpoint}px)` + : `(max-width: ${breakpoint - 1}px)` + + const mql = window.matchMedia(query) + const onChange = (e: MediaQueryListEvent) => setMatches(e.matches) + + // Set initial value + setMatches(mql.matches) + + // Add listener + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) + }, [mode, breakpoint]) + + return !!matches +} diff --git a/apps/block-editor/@/hooks/use-menu-navigation.ts b/apps/block-editor/@/hooks/use-menu-navigation.ts new file mode 100644 index 0000000..58dfd4b --- /dev/null +++ b/apps/block-editor/@/hooks/use-menu-navigation.ts @@ -0,0 +1,194 @@ +import type { Editor } from "@tiptap/react" +import { useEffect, useState } from "react" + +type Orientation = "horizontal" | "vertical" | "both" + +interface MenuNavigationOptions { + /** + * The Tiptap editor instance, if using with a Tiptap editor. + */ + editor?: Editor | null + /** + * Reference to the container element for handling keyboard events. + */ + containerRef?: React.RefObject + /** + * Search query that affects the selected item. + */ + query?: string + /** + * Array of items to navigate through. + */ + items: T[] + /** + * Callback fired when an item is selected. + */ + onSelect?: (item: T) => void + /** + * Callback fired when the menu should close. + */ + onClose?: () => void + /** + * The navigation orientation of the menu. + * @default "vertical" + */ + orientation?: Orientation + /** + * Whether to automatically select the first item when the menu opens. + * @default true + */ + autoSelectFirstItem?: boolean +} + +/** + * Hook that implements keyboard navigation for dropdown menus and command palettes. + * + * Handles arrow keys, tab, home/end, enter for selection, and escape to close. + * Works with both Tiptap editors and regular DOM elements. + * + * @param options - Configuration options for the menu navigation + * @returns Object containing the selected index and a setter function + */ +export function useMenuNavigation({ + editor, + containerRef, + query, + items, + onSelect, + onClose, + orientation = "vertical", + autoSelectFirstItem = true, +}: MenuNavigationOptions) { + const [selectedIndex, setSelectedIndex] = useState( + autoSelectFirstItem ? 0 : -1 + ) + + useEffect(() => { + const handleKeyboardNavigation = (event: KeyboardEvent) => { + if (!items.length) return false + + const moveNext = () => + setSelectedIndex((currentIndex) => { + if (currentIndex === -1) return 0 + return (currentIndex + 1) % items.length + }) + + const movePrev = () => + setSelectedIndex((currentIndex) => { + if (currentIndex === -1) return items.length - 1 + return (currentIndex - 1 + items.length) % items.length + }) + + switch (event.key) { + case "ArrowUp": { + if (orientation === "horizontal") return false + event.preventDefault() + movePrev() + return true + } + + case "ArrowDown": { + if (orientation === "horizontal") return false + event.preventDefault() + moveNext() + return true + } + + case "ArrowLeft": { + if (orientation === "vertical") return false + event.preventDefault() + movePrev() + return true + } + + case "ArrowRight": { + if (orientation === "vertical") return false + event.preventDefault() + moveNext() + return true + } + + case "Tab": { + event.preventDefault() + if (event.shiftKey) { + movePrev() + } else { + moveNext() + } + return true + } + + case "Home": { + event.preventDefault() + setSelectedIndex(0) + return true + } + + case "End": { + event.preventDefault() + setSelectedIndex(items.length - 1) + return true + } + + case "Enter": { + if (event.isComposing) return false + event.preventDefault() + if (selectedIndex !== -1 && items[selectedIndex]) { + onSelect?.(items[selectedIndex]) + } + return true + } + + case "Escape": { + event.preventDefault() + onClose?.() + return true + } + + default: + return false + } + } + + let targetElement: HTMLElement | null = null + + if (editor) { + targetElement = editor.view.dom + } else if (containerRef?.current) { + targetElement = containerRef.current + } + + if (targetElement) { + targetElement.addEventListener("keydown", handleKeyboardNavigation, true) + + return () => { + targetElement?.removeEventListener( + "keydown", + handleKeyboardNavigation, + true + ) + } + } + + return undefined + }, [ + editor, + containerRef, + items, + selectedIndex, + onSelect, + onClose, + orientation, + ]) + + useEffect(() => { + if (query) { + setSelectedIndex(autoSelectFirstItem ? 0 : -1) + } + }, [query, autoSelectFirstItem]) + + return { + selectedIndex: items.length ? selectedIndex : undefined, + setSelectedIndex, + } +} diff --git a/apps/block-editor/@/hooks/use-scrolling.ts b/apps/block-editor/@/hooks/use-scrolling.ts new file mode 100644 index 0000000..8a5fb35 --- /dev/null +++ b/apps/block-editor/@/hooks/use-scrolling.ts @@ -0,0 +1,75 @@ +import type { RefObject } from "react" +import { useEffect, useState } from "react" + +type ScrollTarget = RefObject | Window | null | undefined +type EventTargetWithScroll = Window | HTMLElement | Document + +interface UseScrollingOptions { + debounce?: number + fallbackToDocument?: boolean +} + +export function useScrolling( + target?: ScrollTarget, + options: UseScrollingOptions = {} +): boolean { + const { debounce = 150, fallbackToDocument = true } = options + const [isScrolling, setIsScrolling] = useState(false) + + useEffect(() => { + // Resolve element or window + const element: EventTargetWithScroll = + target && typeof Window !== "undefined" && target instanceof Window + ? target + : ((target as RefObject)?.current ?? window) + + // Mobile: fallback to document when using window + const eventTarget: EventTargetWithScroll = + fallbackToDocument && + element === window && + typeof document !== "undefined" + ? document + : element + + const on = ( + el: EventTargetWithScroll, + event: string, + handler: EventListener + ) => el.addEventListener(event, handler, true) + + const off = ( + el: EventTargetWithScroll, + event: string, + handler: EventListener + ) => el.removeEventListener(event, handler) + + let timeout: ReturnType + const supportsScrollEnd = element === window && "onscrollend" in window + + const handleScroll: EventListener = () => { + if (!isScrolling) setIsScrolling(true) + + if (!supportsScrollEnd) { + clearTimeout(timeout) + timeout = setTimeout(() => setIsScrolling(false), debounce) + } + } + + const handleScrollEnd: EventListener = () => setIsScrolling(false) + + on(eventTarget, "scroll", handleScroll) + if (supportsScrollEnd) { + on(eventTarget, "scrollend", handleScrollEnd) + } + + return () => { + off(eventTarget, "scroll", handleScroll) + if (supportsScrollEnd) { + off(eventTarget, "scrollend", handleScrollEnd) + } + clearTimeout(timeout) + } + }, [target, debounce, fallbackToDocument, isScrolling]) + + return isScrolling +} diff --git a/apps/block-editor/@/hooks/use-throttled-callback.ts b/apps/block-editor/@/hooks/use-throttled-callback.ts new file mode 100644 index 0000000..54894cf --- /dev/null +++ b/apps/block-editor/@/hooks/use-throttled-callback.ts @@ -0,0 +1,48 @@ +import throttle from "lodash.throttle" + +import { useUnmount } from "@/hooks/use-unmount" +import { useMemo } from "react" + +interface ThrottleSettings { + leading?: boolean | undefined + trailing?: boolean | undefined +} + +const defaultOptions: ThrottleSettings = { + leading: false, + trailing: true, +} + +/** + * A hook that returns a throttled callback function. + * + * @param fn The function to throttle + * @param wait The time in ms to wait before calling the function + * @param dependencies The dependencies to watch for changes + * @param options The throttle options + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useThrottledCallback any>( + fn: T, + wait = 250, + dependencies: React.DependencyList = [], + options: ThrottleSettings = defaultOptions +): { + (this: ThisParameterType, ...args: Parameters): ReturnType + cancel: () => void + flush: () => void +} { + const handler = useMemo( + () => throttle(fn, wait, options), + // eslint-disable-next-line react-hooks/exhaustive-deps + dependencies + ) + + useUnmount(() => { + handler.cancel() + }) + + return handler +} + +export default useThrottledCallback diff --git a/apps/block-editor/@/hooks/use-tiptap-editor.ts b/apps/block-editor/@/hooks/use-tiptap-editor.ts new file mode 100644 index 0000000..44044cc --- /dev/null +++ b/apps/block-editor/@/hooks/use-tiptap-editor.ts @@ -0,0 +1,47 @@ +import type { Editor } from "@tiptap/react" +import { useCurrentEditor, useEditorState } from "@tiptap/react" +import { useMemo } from "react" + +/** + * Hook that provides access to a Tiptap editor instance. + * + * Accepts an optional editor instance directly, or falls back to retrieving + * the editor from the Tiptap context if available. This allows components + * to work both when given an editor directly and when used within a Tiptap + * editor context. + * + * @param providedEditor - Optional editor instance to use instead of the context editor + * @returns The provided editor or the editor from context, whichever is available + */ +export function useTiptapEditor(providedEditor?: Editor | null): { + editor: Editor | null + editorState?: Editor["state"] + canCommand?: Editor["can"] +} { + const { editor: coreEditor } = useCurrentEditor() + const mainEditor = useMemo( + () => providedEditor || coreEditor, + [providedEditor, coreEditor] + ) + + const editorState = useEditorState({ + editor: mainEditor, + selector(context) { + if (!context.editor) { + return { + editor: null, + editorState: undefined, + canCommand: undefined, + } + } + + return { + editor: context.editor, + editorState: context.editor.state, + canCommand: context.editor.can, + } + }, + }) + + return editorState || { editor: null } +} diff --git a/apps/block-editor/@/hooks/use-unmount.ts b/apps/block-editor/@/hooks/use-unmount.ts new file mode 100644 index 0000000..91a22e7 --- /dev/null +++ b/apps/block-editor/@/hooks/use-unmount.ts @@ -0,0 +1,21 @@ +import { useRef, useEffect } from "react" + +/** + * Hook that executes a callback when the component unmounts. + * + * @param callback Function to be called on component unmount + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const useUnmount = (callback: (...args: Array) => any) => { + const ref = useRef(callback) + ref.current = callback + + useEffect( + () => () => { + ref.current() + }, + [] + ) +} + +export default useUnmount diff --git a/apps/block-editor/@/hooks/use-window-size.ts b/apps/block-editor/@/hooks/use-window-size.ts new file mode 100644 index 0000000..dd211d9 --- /dev/null +++ b/apps/block-editor/@/hooks/use-window-size.ts @@ -0,0 +1,93 @@ +"use client" + +import { useEffect, useState } from "react" +import { useThrottledCallback } from "@/hooks/use-throttled-callback" + +export interface WindowSizeState { + /** + * The width of the window's visual viewport in pixels. + */ + width: number + /** + * The height of the window's visual viewport in pixels. + */ + height: number + /** + * The distance from the top of the visual viewport to the top of the layout viewport. + * Particularly useful for handling mobile keyboard appearance. + */ + offsetTop: number + /** + * The distance from the left of the visual viewport to the left of the layout viewport. + */ + offsetLeft: number + /** + * The scale factor of the visual viewport. + * This is useful for scaling elements based on the current zoom level. + */ + scale: number +} + +/** + * Hook that tracks the window's visual viewport dimensions, position, and provides + * a CSS transform for positioning elements. + * + * Uses the Visual Viewport API to get accurate measurements, especially important + * for mobile devices where virtual keyboards can change the visible area. + * Only updates state when values actually change to optimize performance. + * + * @returns An object containing viewport properties and a CSS transform string + */ +export function useWindowSize(): WindowSizeState { + const [windowSize, setWindowSize] = useState({ + width: 0, + height: 0, + offsetTop: 0, + offsetLeft: 0, + scale: 0, + }) + + const handleViewportChange = useThrottledCallback(() => { + if (typeof window === "undefined") return + + const vp = window.visualViewport + if (!vp) return + + const { + width = 0, + height = 0, + offsetTop = 0, + offsetLeft = 0, + scale = 0, + } = vp + + setWindowSize((prevState) => { + if ( + width === prevState.width && + height === prevState.height && + offsetTop === prevState.offsetTop && + offsetLeft === prevState.offsetLeft && + scale === prevState.scale + ) { + return prevState + } + + return { width, height, offsetTop, offsetLeft, scale } + }) + }, 200) + + useEffect(() => { + const visualViewport = window.visualViewport + if (!visualViewport) return + + visualViewport.addEventListener("resize", handleViewportChange) + + handleViewportChange() + + return () => { + visualViewport.removeEventListener("resize", handleViewportChange) + } + }, [handleViewportChange]) + + return windowSize +} diff --git a/apps/block-editor/@/lib/tiptap-utils.ts b/apps/block-editor/@/lib/tiptap-utils.ts new file mode 100644 index 0000000..7c9916a --- /dev/null +++ b/apps/block-editor/@/lib/tiptap-utils.ts @@ -0,0 +1,633 @@ +import type { Node as PMNode } from "@tiptap/pm/model" +import type { Transaction } from "@tiptap/pm/state" +import { + AllSelection, + NodeSelection, + Selection, + TextSelection, +} from "@tiptap/pm/state" +import { cellAround, CellSelection } from "@tiptap/pm/tables" +import { + findParentNodeClosestToPos, + type Editor, + type NodeWithPos, +} from "@tiptap/react" + +export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB + +export const MAC_SYMBOLS: Record = { + mod: "⌘", + command: "⌘", + meta: "⌘", + ctrl: "⌃", + control: "⌃", + alt: "⌥", + option: "⌥", + shift: "⇧", + backspace: "Del", + delete: "⌦", + enter: "⏎", + escape: "⎋", + capslock: "⇪", +} as const + +export const SR_ONLY = { + position: "absolute", + width: "1px", + height: "1px", + padding: 0, + margin: "-1px", + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + whiteSpace: "nowrap", + borderWidth: 0, +} as const + +export function cn( + ...classes: (string | boolean | undefined | null)[] +): string { + return classes.filter(Boolean).join(" ") +} + +/** + * Determines if the current platform is macOS + * @returns boolean indicating if the current platform is Mac + */ +export function isMac(): boolean { + return ( + typeof navigator !== "undefined" && + navigator.platform.toLowerCase().includes("mac") + ) +} + +/** + * Formats a shortcut key based on the platform (Mac or non-Mac) + * @param key - The key to format (e.g., "ctrl", "alt", "shift") + * @param isMac - Boolean indicating if the platform is Mac + * @param capitalize - Whether to capitalize the key (default: true) + * @returns Formatted shortcut key symbol + */ +export const formatShortcutKey = ( + key: string, + isMac: boolean, + capitalize: boolean = true +) => { + if (isMac) { + const lowerKey = key.toLowerCase() + return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key) + } + + return capitalize ? key.charAt(0).toUpperCase() + key.slice(1) : key +} + +/** + * Parses a shortcut key string into an array of formatted key symbols + * @param shortcutKeys - The string of shortcut keys (e.g., "ctrl-alt-shift") + * @param delimiter - The delimiter used to split the keys (default: "-") + * @param capitalize - Whether to capitalize the keys (default: true) + * @returns Array of formatted shortcut key symbols + */ +export const parseShortcutKeys = (props: { + shortcutKeys: string | undefined + delimiter?: string + capitalize?: boolean +}) => { + const { shortcutKeys, delimiter = "+", capitalize = true } = props + + if (!shortcutKeys) return [] + + return shortcutKeys + .split(delimiter) + .map((key) => key.trim()) + .map((key) => formatShortcutKey(key, isMac(), capitalize)) +} + +/** + * Checks if a mark exists in the editor schema + * @param markName - The name of the mark to check + * @param editor - The editor instance + * @returns boolean indicating if the mark exists in the schema + */ +export const isMarkInSchema = ( + markName: string, + editor: Editor | null +): boolean => { + if (!editor?.schema) return false + return editor.schema.spec.marks.get(markName) !== undefined +} + +/** + * Checks if a node exists in the editor schema + * @param nodeName - The name of the node to check + * @param editor - The editor instance + * @returns boolean indicating if the node exists in the schema + */ +export const isNodeInSchema = ( + nodeName: string, + editor: Editor | null +): boolean => { + if (!editor?.schema) return false + return editor.schema.spec.nodes.get(nodeName) !== undefined +} + +/** + * Moves the focus to the next node in the editor + * @param editor - The editor instance + * @returns boolean indicating if the focus was moved + */ +export function focusNextNode(editor: Editor) { + const { state, view } = editor + const { doc, selection } = state + + const nextSel = Selection.findFrom(selection.$to, 1, true) + if (nextSel) { + view.dispatch(state.tr.setSelection(nextSel).scrollIntoView()) + return true + } + + const paragraphType = state.schema.nodes.paragraph + if (!paragraphType) { + console.warn("No paragraph node type found in schema.") + return false + } + + const end = doc.content.size + const para = paragraphType.create() + let tr = state.tr.insert(end, para) + + // Place the selection inside the new paragraph + const $inside = tr.doc.resolve(end + 1) + tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView() + view.dispatch(tr) + return true +} + +/** + * Checks if a value is a valid number (not null, undefined, or NaN) + * @param value - The value to check + * @returns boolean indicating if the value is a valid number + */ +export function isValidPosition(pos: number | null | undefined): pos is number { + return typeof pos === "number" && pos >= 0 +} + +/** + * Checks if one or more extensions are registered in the Tiptap editor. + * @param editor - The Tiptap editor instance + * @param extensionNames - A single extension name or an array of names to check + * @returns True if at least one of the extensions is available, false otherwise + */ +export function isExtensionAvailable( + editor: Editor | null, + extensionNames: string | string[] +): boolean { + if (!editor) return false + + const names = Array.isArray(extensionNames) + ? extensionNames + : [extensionNames] + + const found = names.some((name) => + editor.extensionManager.extensions.some((ext) => ext.name === name) + ) + + if (!found) { + console.warn( + `None of the extensions [${names.join(", ")}] were found in the editor schema. Ensure they are included in the editor configuration.` + ) + } + + return found +} + +/** + * Finds a node at the specified position with error handling + * @param editor The Tiptap editor instance + * @param position The position in the document to find the node + * @returns The node at the specified position, or null if not found + */ +export function findNodeAtPosition(editor: Editor, position: number) { + try { + const node = editor.state.doc.nodeAt(position) + if (!node) { + console.warn(`No node found at position ${position}`) + return null + } + return node + } catch (error) { + console.error(`Error getting node at position ${position}:`, error) + return null + } +} + +/** + * Finds the position and instance of a node in the document + * @param props Object containing editor, node (optional), and nodePos (optional) + * @param props.editor The Tiptap editor instance + * @param props.node The node to find (optional if nodePos is provided) + * @param props.nodePos The position of the node to find (optional if node is provided) + * @returns An object with the position and node, or null if not found + */ +export function findNodePosition(props: { + editor: Editor | null + node?: PMNode | null + nodePos?: number | null +}): { pos: number; node: PMNode } | null { + const { editor, node, nodePos } = props + + if (!editor || !editor.state?.doc) return null + + // Zero is valid position + const hasValidNode = node !== undefined && node !== null + const hasValidPos = isValidPosition(nodePos) + + if (!hasValidNode && !hasValidPos) { + return null + } + + // First search for the node in the document if we have a node + if (hasValidNode) { + let foundPos = -1 + let foundNode: PMNode | null = null + + editor.state.doc.descendants((currentNode, pos) => { + // TODO: Needed? + // if (currentNode.type && currentNode.type.name === node!.type.name) { + if (currentNode === node) { + foundPos = pos + foundNode = currentNode + return false + } + return true + }) + + if (foundPos !== -1 && foundNode !== null) { + return { pos: foundPos, node: foundNode } + } + } + + // If we have a valid position, use findNodeAtPosition + if (hasValidPos) { + const nodeAtPos = findNodeAtPosition(editor, nodePos!) + if (nodeAtPos) { + return { pos: nodePos!, node: nodeAtPos } + } + } + + return null +} + +/** + * Determines whether the current selection contains a node whose type matches + * any of the provided node type names. + * @param editor Tiptap editor instance + * @param nodeTypeNames List of node type names to match against + * @param checkAncestorNodes Whether to check ancestor node types up the depth chain + */ +export function isNodeTypeSelected( + editor: Editor | null, + nodeTypeNames: string[] = [], + checkAncestorNodes: boolean = false +): boolean { + if (!editor || !editor.state.selection) return false + + const { selection } = editor.state + if (selection.empty) return false + + // Direct node selection check + if (selection instanceof NodeSelection) { + const selectedNode = selection.node + return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false + } + + // Depth-based ancestor node check + if (checkAncestorNodes) { + const { $from } = selection + for (let depth = $from.depth; depth > 0; depth--) { + const ancestorNode = $from.node(depth) + if (nodeTypeNames.includes(ancestorNode.type.name)) { + return true + } + } + } + + return false +} + +/** + * Check whether the current selection is fully within nodes + * whose type names are in the provided `types` list. + * + * - NodeSelection → checks the selected node. + * - Text/AllSelection → ensures all textblocks within [from, to) are allowed. + */ +export function selectionWithinConvertibleTypes( + editor: Editor, + types: string[] = [] +): boolean { + if (!editor || types.length === 0) return false + + const { state } = editor + const { selection } = state + const allowed = new Set(types) + + if (selection instanceof NodeSelection) { + const nodeType = selection.node?.type?.name + return !!nodeType && allowed.has(nodeType) + } + + if (selection instanceof TextSelection || selection instanceof AllSelection) { + let valid = true + state.doc.nodesBetween(selection.from, selection.to, (node) => { + if (node.isTextblock && !allowed.has(node.type.name)) { + valid = false + return false // stop early + } + return valid + }) + return valid + } + + return false +} + +/** + * Handles image upload with progress tracking and abort capability + * @param file The file to upload + * @param onProgress Optional callback for tracking upload progress + * @param abortSignal Optional AbortSignal for cancelling the upload + * @returns Promise resolving to the URL of the uploaded image + */ +export const handleImageUpload = async ( + file: File, + onProgress?: (event: { progress: number }) => void, + abortSignal?: AbortSignal +): Promise => { + // Validate file + if (!file) { + throw new Error("No file provided") + } + + if (file.size > MAX_FILE_SIZE) { + throw new Error( + `File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)` + ) + } + + // For demo/testing: Simulate upload progress. In production, replace the following code + // with your own upload implementation. + for (let progress = 0; progress <= 100; progress += 10) { + if (abortSignal?.aborted) { + throw new Error("Upload cancelled") + } + await new Promise((resolve) => setTimeout(resolve, 500)) + onProgress?.({ progress }) + } + + return "/images/tiptap-ui-placeholder-image.jpg" +} + +type ProtocolOptions = { + /** + * The protocol scheme to be registered. + * @default ''' + * @example 'ftp' + * @example 'git' + */ + scheme: string + + /** + * If enabled, it allows optional slashes after the protocol. + * @default false + * @example true + */ + optionalSlashes?: boolean +} + +type ProtocolConfig = Array + +const ATTR_WHITESPACE = + // eslint-disable-next-line no-control-regex + /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g + +export function isAllowedUri( + uri: string | undefined, + protocols?: ProtocolConfig +) { + const allowedProtocols: string[] = [ + "http", + "https", + "ftp", + "ftps", + "mailto", + "tel", + "callto", + "sms", + "cid", + "xmpp", + ] + + if (protocols) { + protocols.forEach((protocol) => { + const nextProtocol = + typeof protocol === "string" ? protocol : protocol.scheme + + if (nextProtocol) { + allowedProtocols.push(nextProtocol) + } + }) + } + + return ( + !uri || + uri.replace(ATTR_WHITESPACE, "").match( + new RegExp( + // eslint-disable-next-line no-useless-escape + `^(?:(?:${allowedProtocols.join("|")}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`, + "i" + ) + ) + ) +} + +export function sanitizeUrl( + inputUrl: string, + baseUrl: string, + protocols?: ProtocolConfig +): string { + try { + const url = new URL(inputUrl, baseUrl) + + if (isAllowedUri(url.href, protocols)) { + return url.href + } + } catch { + // If URL creation fails, it's considered invalid + } + return "#" +} + +/** + * Update a single attribute on multiple nodes. + * + * @param tr - The transaction to mutate + * @param targets - Array of { node, pos } + * @param attrName - Attribute key to update + * @param next - New value OR updater function receiving previous value + * Pass `undefined` to remove the attribute. + * @returns true if at least one node was updated, false otherwise + */ +export function updateNodesAttr( + tr: Transaction, + targets: readonly NodeWithPos[], + attrName: A, + next: V | ((prev: V | undefined) => V | undefined) +): boolean { + if (!targets.length) return false + + let changed = false + + for (const { pos } of targets) { + // Always re-read from the transaction's current doc + const currentNode = tr.doc.nodeAt(pos) + if (!currentNode) continue + + const prevValue = (currentNode.attrs as Record)[ + attrName + ] as V | undefined + const resolvedNext = + typeof next === "function" + ? (next as (p: V | undefined) => V | undefined)(prevValue) + : next + + if (prevValue === resolvedNext) continue + + const nextAttrs: Record = { ...currentNode.attrs } + if (resolvedNext === undefined) { + // Remove the key entirely instead of setting null + delete nextAttrs[attrName] + } else { + nextAttrs[attrName] = resolvedNext + } + + tr.setNodeMarkup(pos, undefined, nextAttrs) + changed = true + } + + return changed +} + +/** + * Selects the entire content of the current block node if the selection is empty. + * If the selection is not empty, it does nothing. + * @param editor The Tiptap editor instance + */ +export function selectCurrentBlockContent(editor: Editor) { + const { selection, doc } = editor.state + + if (!selection.empty) return + + const $pos = selection.$from + let blockNode = null + let blockPos = -1 + + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth) + const pos = $pos.start(depth) + + if (node.isBlock && node.textContent.trim()) { + blockNode = node + blockPos = pos + break + } + } + + if (blockNode && blockPos >= 0) { + const from = blockPos + const to = blockPos + blockNode.nodeSize - 2 // -2 to exclude the closing tag + + if (from < to) { + const $from = doc.resolve(from) + const $to = doc.resolve(to) + const newSelection = TextSelection.between($from, $to, 1) + + if (newSelection && !selection.eq(newSelection)) { + editor.view.dispatch(editor.state.tr.setSelection(newSelection)) + } + } + } +} + +/** + * Retrieves all nodes of specified types from the current selection. + * @param selection The current editor selection + * @param allowedNodeTypes An array of node type names to look for (e.g., ["image", "table"]) + * @returns An array of objects containing the node and its position + */ +export function getSelectedNodesOfType( + selection: Selection, + allowedNodeTypes: string[] +): NodeWithPos[] { + const results: NodeWithPos[] = [] + const allowed = new Set(allowedNodeTypes) + + if (selection instanceof CellSelection) { + selection.forEachCell((node: PMNode, pos: number) => { + if (allowed.has(node.type.name)) { + results.push({ node, pos }) + } + }) + return results + } + + if (selection instanceof NodeSelection) { + const { node, from: pos } = selection + if (node && allowed.has(node.type.name)) { + results.push({ node, pos }) + } + return results + } + + const { $anchor } = selection + const cell = cellAround($anchor) + + if (cell) { + const cellNode = selection.$anchor.doc.nodeAt(cell.pos) + if (cellNode && allowed.has(cellNode.type.name)) { + results.push({ node: cellNode, pos: cell.pos }) + return results + } + } + + // Fallback: find parent nodes of allowed types + const parentNode = findParentNodeClosestToPos($anchor, (node) => + allowed.has(node.type.name) + ) + + if (parentNode) { + results.push({ node: parentNode.node, pos: parentNode.pos }) + } + + return results +} + +export function getSelectedBlockNodes(editor: Editor): PMNode[] { + const { doc } = editor.state + const { from, to } = editor.state.selection + + const blocks: PMNode[] = [] + const seen = new Set() + + doc.nodesBetween(from, to, (node, pos) => { + if (!node.isBlock) return + + if (!seen.has(pos)) { + seen.add(pos) + blocks.push(node) + } + + return false + }) + + return blocks +} diff --git a/apps/block-editor/@/styles/_keyframe-animations.scss b/apps/block-editor/@/styles/_keyframe-animations.scss new file mode 100644 index 0000000..dd98b7c --- /dev/null +++ b/apps/block-editor/@/styles/_keyframe-animations.scss @@ -0,0 +1,91 @@ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes zoomIn { + from { + transform: scale(0.95); + } + to { + transform: scale(1); + } +} + +@keyframes zoomOut { + from { + transform: scale(1); + } + to { + transform: scale(0.95); + } +} + +@keyframes zoom { + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideFromTop { + from { + transform: translateY(-0.5rem); + } + to { + transform: translateY(0); + } +} + +@keyframes slideFromRight { + from { + transform: translateX(0.5rem); + } + to { + transform: translateX(0); + } +} + +@keyframes slideFromLeft { + from { + transform: translateX(-0.5rem); + } + to { + transform: translateX(0); + } +} + +@keyframes slideFromBottom { + from { + transform: translateY(0.5rem); + } + to { + transform: translateY(0); + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/apps/block-editor/@/styles/_variables.scss b/apps/block-editor/@/styles/_variables.scss new file mode 100644 index 0000000..113a16b --- /dev/null +++ b/apps/block-editor/@/styles/_variables.scss @@ -0,0 +1,296 @@ +:root { + /****************** + Basics + ******************/ + + overflow-wrap: break-word; + text-size-adjust: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /****************** + Colors variables + ******************/ + + /* Gray alpha (light mode) */ + --tt-gray-light-a-50: rgba(56, 56, 56, 0.04); + --tt-gray-light-a-100: rgba(15, 22, 36, 0.05); + --tt-gray-light-a-200: rgba(37, 39, 45, 0.1); + --tt-gray-light-a-300: rgba(47, 50, 55, 0.2); + --tt-gray-light-a-400: rgba(40, 44, 51, 0.42); + --tt-gray-light-a-500: rgba(52, 55, 60, 0.64); + --tt-gray-light-a-600: rgba(36, 39, 46, 0.78); + --tt-gray-light-a-700: rgba(35, 37, 42, 0.87); + --tt-gray-light-a-800: rgba(30, 32, 36, 0.95); + --tt-gray-light-a-900: rgba(29, 30, 32, 0.98); + + /* Gray (light mode) */ + --tt-gray-light-50: rgba(250, 250, 250, 1); + --tt-gray-light-100: rgba(244, 244, 245, 1); + --tt-gray-light-200: rgba(234, 234, 235, 1); + --tt-gray-light-300: rgba(213, 214, 215, 1); + --tt-gray-light-400: rgba(166, 167, 171, 1); + --tt-gray-light-500: rgba(125, 127, 130, 1); + --tt-gray-light-600: rgba(83, 86, 90, 1); + --tt-gray-light-700: rgba(64, 65, 69, 1); + --tt-gray-light-800: rgba(44, 45, 48, 1); + --tt-gray-light-900: rgba(34, 35, 37, 1); + + /* Gray alpha (dark mode) */ + --tt-gray-dark-a-50: rgba(232, 232, 253, 0.05); + --tt-gray-dark-a-100: rgba(231, 231, 243, 0.07); + --tt-gray-dark-a-200: rgba(238, 238, 246, 0.11); + --tt-gray-dark-a-300: rgba(239, 239, 245, 0.22); + --tt-gray-dark-a-400: rgba(244, 244, 255, 0.37); + --tt-gray-dark-a-500: rgba(236, 238, 253, 0.5); + --tt-gray-dark-a-600: rgba(247, 247, 253, 0.64); + --tt-gray-dark-a-700: rgba(251, 251, 254, 0.75); + --tt-gray-dark-a-800: rgba(253, 253, 253, 0.88); + --tt-gray-dark-a-900: rgba(255, 255, 255, 0.96); + + /* Gray (dark mode) */ + --tt-gray-dark-50: rgba(25, 25, 26, 1); + --tt-gray-dark-100: rgba(32, 32, 34, 1); + --tt-gray-dark-200: rgba(45, 45, 47, 1); + --tt-gray-dark-300: rgba(70, 70, 73, 1); + --tt-gray-dark-400: rgba(99, 99, 105, 1); + --tt-gray-dark-500: rgba(124, 124, 131, 1); + --tt-gray-dark-600: rgba(163, 163, 168, 1); + --tt-gray-dark-700: rgba(192, 192, 195, 1); + --tt-gray-dark-800: rgba(224, 224, 225, 1); + --tt-gray-dark-900: rgba(245, 245, 245, 1); + + /* Brand colors */ + --tt-brand-color-50: rgba(239, 238, 255, 1); + --tt-brand-color-100: rgba(222, 219, 255, 1); + --tt-brand-color-200: rgba(195, 189, 255, 1); + --tt-brand-color-300: rgba(157, 138, 255, 1); + --tt-brand-color-400: rgba(122, 82, 255, 1); + --tt-brand-color-500: rgba(98, 41, 255, 1); + --tt-brand-color-600: rgba(84, 0, 229, 1); + --tt-brand-color-700: rgba(75, 0, 204, 1); + --tt-brand-color-800: rgba(56, 0, 153, 1); + --tt-brand-color-900: rgba(43, 25, 102, 1); + --tt-brand-color-950: hsla(257, 100%, 9%, 1); + + /* Green */ + --tt-color-green-inc-5: hsla(129, 100%, 97%, 1); + --tt-color-green-inc-4: hsla(129, 100%, 92%, 1); + --tt-color-green-inc-3: hsla(131, 100%, 86%, 1); + --tt-color-green-inc-2: hsla(133, 98%, 78%, 1); + --tt-color-green-inc-1: hsla(137, 99%, 70%, 1); + --tt-color-green-base: hsla(147, 99%, 50%, 1); + --tt-color-green-dec-1: hsla(147, 97%, 41%, 1); + --tt-color-green-dec-2: hsla(146, 98%, 32%, 1); + --tt-color-green-dec-3: hsla(146, 100%, 24%, 1); + --tt-color-green-dec-4: hsla(144, 100%, 16%, 1); + --tt-color-green-dec-5: hsla(140, 100%, 9%, 1); + + /* Yellow */ + --tt-color-yellow-inc-5: hsla(50, 100%, 97%, 1); + --tt-color-yellow-inc-4: hsla(50, 100%, 91%, 1); + --tt-color-yellow-inc-3: hsla(50, 100%, 84%, 1); + --tt-color-yellow-inc-2: hsla(50, 100%, 77%, 1); + --tt-color-yellow-inc-1: hsla(50, 100%, 68%, 1); + --tt-color-yellow-base: hsla(52, 100%, 50%, 1); + --tt-color-yellow-dec-1: hsla(52, 100%, 41%, 1); + --tt-color-yellow-dec-2: hsla(52, 100%, 32%, 1); + --tt-color-yellow-dec-3: hsla(52, 100%, 24%, 1); + --tt-color-yellow-dec-4: hsla(51, 100%, 16%, 1); + --tt-color-yellow-dec-5: hsla(50, 100%, 9%, 1); + + /* Red */ + --tt-color-red-inc-5: hsla(11, 100%, 96%, 1); + --tt-color-red-inc-4: hsla(11, 100%, 88%, 1); + --tt-color-red-inc-3: hsla(10, 100%, 80%, 1); + --tt-color-red-inc-2: hsla(9, 100%, 73%, 1); + --tt-color-red-inc-1: hsla(7, 100%, 64%, 1); + --tt-color-red-base: hsla(7, 100%, 54%, 1); + --tt-color-red-dec-1: hsla(7, 100%, 41%, 1); + --tt-color-red-dec-2: hsla(5, 100%, 32%, 1); + --tt-color-red-dec-3: hsla(4, 100%, 24%, 1); + --tt-color-red-dec-4: hsla(3, 100%, 16%, 1); + --tt-color-red-dec-5: hsla(1, 100%, 9%, 1); + + /* Basic colors */ + --white: rgba(255, 255, 255, 1); + --black: rgba(14, 14, 17, 1); + --transparent: rgba(255, 255, 255, 0); + + /****************** + Shadow variables + ******************/ + + /* Shadows Light */ + --tt-shadow-elevated-md: + 0px 16px 48px 0px rgba(17, 24, 39, 0.04), + 0px 12px 24px 0px rgba(17, 24, 39, 0.04), + 0px 6px 8px 0px rgba(17, 24, 39, 0.02), + 0px 2px 3px 0px rgba(17, 24, 39, 0.02); + + /************************************************** + Radius variables + **************************************************/ + + --tt-radius-xxs: 0.125rem; /* 2px */ + --tt-radius-xs: 0.25rem; /* 4px */ + --tt-radius-sm: 0.375rem; /* 6px */ + --tt-radius-md: 0.5rem; /* 8px */ + --tt-radius-lg: 0.75rem; /* 12px */ + --tt-radius-xl: 1rem; /* 16px */ + + /************************************************** + Transition variables + **************************************************/ + + --tt-transition-duration-short: 0.1s; + --tt-transition-duration-default: 0.2s; + --tt-transition-duration-long: 0.64s; + --tt-transition-easing-default: cubic-bezier(0.46, 0.03, 0.52, 0.96); + --tt-transition-easing-cubic: cubic-bezier(0.65, 0.05, 0.36, 1); + --tt-transition-easing-quart: cubic-bezier(0.77, 0, 0.18, 1); + --tt-transition-easing-circ: cubic-bezier(0.79, 0.14, 0.15, 0.86); + --tt-transition-easing-back: cubic-bezier(0.68, -0.55, 0.27, 1.55); + + /****************** + Contrast variables + ******************/ + + --tt-accent-contrast: 8%; + --tt-destructive-contrast: 8%; + --tt-foreground-contrast: 8%; + + &, + *, + ::before, + ::after { + box-sizing: border-box; + transition: none var(--tt-transition-duration-default) + var(--tt-transition-easing-default); + } +} + +:root { + /************************************************** + Global colors + **************************************************/ + + /* Global colors - Light mode */ + --tt-bg-color: var(--white); + --tt-border-color: var(--tt-gray-light-a-200); + --tt-border-color-tint: var(--tt-gray-light-a-100); + --tt-sidebar-bg-color: var(--tt-gray-light-100); + --tt-scrollbar-color: var(--tt-gray-light-a-200); + --tt-cursor-color: var(--tt-brand-color-500); + --tt-selection-color: rgba(157, 138, 255, 0.2); + --tt-card-bg-color: var(--white); + --tt-card-border-color: var(--tt-gray-light-a-100); +} + +/* Global colors - Dark mode */ +.dark { + --tt-bg-color: var(--black); + --tt-border-color: var(--tt-gray-dark-a-200); + --tt-border-color-tint: var(--tt-gray-dark-a-100); + --tt-sidebar-bg-color: var(--tt-gray-dark-100); + --tt-scrollbar-color: var(--tt-gray-dark-a-200); + --tt-cursor-color: var(--tt-brand-color-400); + --tt-selection-color: rgba(122, 82, 255, 0.2); + --tt-card-bg-color: var(--tt-gray-dark-50); + --tt-card-border-color: var(--tt-gray-dark-a-50); + + --tt-shadow-elevated-md: + 0px 16px 48px 0px rgba(0, 0, 0, 0.5), 0px 12px 24px 0px rgba(0, 0, 0, 0.24), + 0px 6px 8px 0px rgba(0, 0, 0, 0.22), 0px 2px 3px 0px rgba(0, 0, 0, 0.12); +} + +/* Text colors */ +:root { + --tt-color-text-gray: hsl(45, 2%, 46%); + --tt-color-text-brown: hsl(19, 31%, 47%); + --tt-color-text-orange: hsl(30, 89%, 45%); + --tt-color-text-yellow: hsl(38, 62%, 49%); + --tt-color-text-green: hsl(148, 32%, 39%); + --tt-color-text-blue: hsl(202, 54%, 43%); + --tt-color-text-purple: hsl(274, 32%, 54%); + --tt-color-text-pink: hsl(328, 49%, 53%); + --tt-color-text-red: hsl(2, 62%, 55%); + + --tt-color-text-gray-contrast: hsla(39, 26%, 26%, 0.15); + --tt-color-text-brown-contrast: hsla(18, 43%, 69%, 0.35); + --tt-color-text-orange-contrast: hsla(24, 73%, 55%, 0.27); + --tt-color-text-yellow-contrast: hsla(44, 82%, 59%, 0.39); + --tt-color-text-green-contrast: hsla(126, 29%, 60%, 0.27); + --tt-color-text-blue-contrast: hsla(202, 54%, 59%, 0.27); + --tt-color-text-purple-contrast: hsla(274, 37%, 64%, 0.27); + --tt-color-text-pink-contrast: hsla(331, 60%, 71%, 0.27); + --tt-color-text-red-contrast: hsla(8, 79%, 79%, 0.4); +} + +.dark { + --tt-color-text-gray: hsl(0, 0%, 61%); + --tt-color-text-brown: hsl(18, 35%, 58%); + --tt-color-text-orange: hsl(25, 53%, 53%); + --tt-color-text-yellow: hsl(36, 54%, 55%); + --tt-color-text-green: hsl(145, 32%, 47%); + --tt-color-text-blue: hsl(202, 64%, 52%); + --tt-color-text-purple: hsl(270, 55%, 62%); + --tt-color-text-pink: hsl(329, 57%, 58%); + --tt-color-text-red: hsl(1, 69%, 60%); + + --tt-color-text-gray-contrast: hsla(0, 0%, 100%, 0.09); + --tt-color-text-brown-contrast: hsla(17, 45%, 50%, 0.25); + --tt-color-text-orange-contrast: hsla(27, 82%, 53%, 0.2); + --tt-color-text-yellow-contrast: hsla(35, 49%, 47%, 0.2); + --tt-color-text-green-contrast: hsla(151, 55%, 39%, 0.2); + --tt-color-text-blue-contrast: hsla(202, 54%, 43%, 0.2); + --tt-color-text-purple-contrast: hsla(271, 56%, 60%, 0.18); + --tt-color-text-pink-contrast: hsla(331, 67%, 58%, 0.22); + --tt-color-text-red-contrast: hsla(0, 67%, 60%, 0.25); +} + +/* Highlight colors */ +:root { + --tt-color-highlight-yellow: #fef9c3; + --tt-color-highlight-green: #dcfce7; + --tt-color-highlight-blue: #e0f2fe; + --tt-color-highlight-purple: #f3e8ff; + --tt-color-highlight-red: #ffe4e6; + --tt-color-highlight-gray: rgb(248, 248, 247); + --tt-color-highlight-brown: rgb(244, 238, 238); + --tt-color-highlight-orange: rgb(251, 236, 221); + --tt-color-highlight-pink: rgb(252, 241, 246); + + --tt-color-highlight-yellow-contrast: #fbe604; + --tt-color-highlight-green-contrast: #c7fad8; + --tt-color-highlight-blue-contrast: #ceeafd; + --tt-color-highlight-purple-contrast: #e4ccff; + --tt-color-highlight-red-contrast: #ffccd0; + --tt-color-highlight-gray-contrast: rgba(84, 72, 49, 0.15); + --tt-color-highlight-brown-contrast: rgba(210, 162, 141, 0.35); + --tt-color-highlight-orange-contrast: rgba(224, 124, 57, 0.27); + --tt-color-highlight-pink-contrast: rgba(225, 136, 179, 0.27); +} + +.dark { + --tt-color-highlight-yellow: #6b6524; + --tt-color-highlight-green: #509568; + --tt-color-highlight-blue: #6e92aa; + --tt-color-highlight-purple: #583e74; + --tt-color-highlight-red: #743e42; + --tt-color-highlight-gray: rgb(47, 47, 47); + --tt-color-highlight-brown: rgb(74, 50, 40); + --tt-color-highlight-orange: rgb(92, 59, 35); + --tt-color-highlight-pink: rgb(78, 44, 60); + + --tt-color-highlight-yellow-contrast: #58531e; + --tt-color-highlight-green-contrast: #47855d; + --tt-color-highlight-blue-contrast: #5e86a1; + --tt-color-highlight-purple-contrast: #4c3564; + --tt-color-highlight-red-contrast: #643539; + --tt-color-highlight-gray-contrast: rgba(255, 255, 255, 0.094); + --tt-color-highlight-brown-contrast: rgba(184, 101, 69, 0.25); + --tt-color-highlight-orange-contrast: rgba(233, 126, 37, 0.2); + --tt-color-highlight-pink-contrast: rgba(220, 76, 145, 0.22); +} diff --git a/apps/block-editor/README.md b/apps/block-editor/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/apps/block-editor/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/apps/block-editor/eslint.config.js b/apps/block-editor/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/apps/block-editor/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/apps/block-editor/index.html b/apps/block-editor/index.html new file mode 100644 index 0000000..89367c3 --- /dev/null +++ b/apps/block-editor/index.html @@ -0,0 +1,13 @@ + + + + + + + block-editor + + +
+ + + diff --git a/apps/block-editor/package.json b/apps/block-editor/package.json new file mode 100644 index 0000000..eaa7df4 --- /dev/null +++ b/apps/block-editor/package.json @@ -0,0 +1,77 @@ +{ + "name": "block-editor", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/core": "^0.47.0", + "@blocknote/mantine": "^0.47.0", + "@blocknote/react": "^0.47.0", + "@floating-ui/dom": "^1.7.5", + "@floating-ui/react": "^0.27.18", + "@mantine/core": "^8.3.15", + "@mantine/hooks": "^8.3.15", + "@mantine/utils": "^6.0.22", + "@pubwave/editor": "^0.5.7", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@tiptap/core": "^3.20.0", + "@tiptap/extension-code-block-lowlight": "^3.20.0", + "@tiptap/extension-color": "^3.20.0", + "@tiptap/extension-drag-handle-react": "^3.20.0", + "@tiptap/extension-highlight": "^3.20.0", + "@tiptap/extension-horizontal-rule": "^3.20.0", + "@tiptap/extension-image": "^3.20.0", + "@tiptap/extension-link": "^3.20.0", + "@tiptap/extension-list": "^3.20.0", + "@tiptap/extension-placeholder": "^3.20.0", + "@tiptap/extension-subscript": "^3.20.0", + "@tiptap/extension-superscript": "^3.20.0", + "@tiptap/extension-table": "^3.20.0", + "@tiptap/extension-table-cell": "^3.20.0", + "@tiptap/extension-table-header": "^3.20.0", + "@tiptap/extension-table-row": "^3.20.0", + "@tiptap/extension-task-item": "^3.20.0", + "@tiptap/extension-task-list": "^3.20.0", + "@tiptap/extension-text-align": "^3.20.0", + "@tiptap/extension-text-style": "^3.20.0", + "@tiptap/extension-typography": "^3.20.0", + "@tiptap/extension-underline": "^3.20.0", + "@tiptap/extensions": "^3.20.0", + "@tiptap/markdown": "^3.20.0", + "@tiptap/pm": "^3.20.0", + "@tiptap/react": "^3.20.0", + "@tiptap/starter-kit": "^3.20.0", + "@tiptap/suggestion": "^3.20.0", + "chart.js": "^4.5.1", + "lodash.throttle": "^4.1.1", + "lowlight": "^3.3.0", + "lucide-react": "^0.575.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hotkeys-hook": "^5.2.4", + "sass": "^1.97.3" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/lodash.throttle": "^4.1.9", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "sass-embedded": "^1.97.3", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/apps/block-editor/public/vite.svg b/apps/block-editor/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/block-editor/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/block-editor/src/App.css b/apps/block-editor/src/App.css new file mode 100644 index 0000000..f45a72d --- /dev/null +++ b/apps/block-editor/src/App.css @@ -0,0 +1,235 @@ +/* ── Reset ────────────────────────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── App root ─────────────────────────────────────────────────────────── */ +.app-root { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + overflow: hidden; + font-family: Inter, system-ui, sans-serif; + transition: background 0.2s, color 0.2s; +} + +.app-root.dark { + background: #1f1f1f; + color: #cfcfcf; +} + +.app-root.light { + background: #ffffff; + color: #222222; +} + +/* ── Top bar ──────────────────────────────────────────────────────────── */ +.topbar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + height: 48px; + border-bottom: 1px solid; + z-index: 100; +} + +.dark .topbar { + background: #161616; + border-color: #2a2a2a; +} + +.light .topbar { + background: #f5f5f5; + border-color: #e0e0e0; +} + +/* ── Dropdown button ──────────────────────────────────────────────────── */ +.topbar-left { + position: relative; +} + +.dropdown-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid; + cursor: pointer; + font-size: 13px; + font-weight: 500; + font-family: inherit; + line-height: 1; + transition: background 0.15s; +} + +.dark .dropdown-btn { + background: #2a2a2a; + border-color: #3a3a3a; + color: #cfcfcf; +} + +.dark .dropdown-btn:hover { + background: #333; +} + +.light .dropdown-btn { + background: #fff; + border-color: #d0d0d0; + color: #222; +} + +.light .dropdown-btn:hover { + background: #f0f0f0; +} + +.dropdown-icon { + font-size: 15px; + opacity: 0.7; +} + +.chevron { + font-size: 10px; + opacity: 0.5; + margin-left: 2px; +} + +/* ── Dropdown menu ────────────────────────────────────────────────────── */ +.dropdown-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 180px; + border-radius: 8px; + border: 1px solid; + list-style: none; + padding: 4px; + z-index: 200; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.dark .dropdown-menu { + background: #1f1f1f; + border-color: #2a2a2a; +} + +.light .dropdown-menu { + background: #fff; + border-color: #e0e0e0; +} + +.dropdown-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-radius: 5px; + cursor: pointer; + font-size: 13px; + transition: background 0.1s; +} + +.dark .dropdown-item:hover { + background: #2a2a2a; +} + +.light .dropdown-item:hover { + background: #f0f0f0; +} + +.dropdown-item.active { + font-weight: 600; +} + +.check { + color: #6366f1; + font-size: 12px; +} + +/* ── Theme toggle ─────────────────────────────────────────────────────── */ +.theme-toggle { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid; + cursor: pointer; + font-size: 13px; + font-weight: 500; + font-family: inherit; + line-height: 1; + transition: background 0.15s; +} + +.dark .theme-toggle { + background: #2a2a2a; + border-color: #3a3a3a; + color: #cfcfcf; +} + +.dark .theme-toggle:hover { + background: #333; +} + +.light .theme-toggle { + background: #fff; + border-color: #d0d0d0; + color: #222; +} + +.light .theme-toggle:hover { + background: #f0f0f0; +} + +/* ── Editor area ──────────────────────────────────────────────────────── */ +.editor-area { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Make BlockNoteView fill the full editor area */ +.editor-area .bn-container, +.editor-area .bn-editor { + flex: 1; + height: 100%; +} + +.editor-area .bn-full { + width: 100%; + height: 100%; +} + +/* ── Heading spacing fix (same as apps/editor) ────────────────────────── */ +.bn-editor .bn-block-content[data-content-type="heading"] { + line-height: 1.2; + padding: 4px 0; +} + +.bn-editor .bn-block-content[data-content-type="heading"][data-level="1"] { + padding-top: 8px; + padding-bottom: 4px; +} + +.bn-editor .bn-block-content[data-content-type="heading"][data-level="2"] { + padding-top: 6px; + padding-bottom: 3px; +} + +.bn-editor .bn-block-content[data-content-type="heading"][data-level="3"] { + padding-top: 4px; + padding-bottom: 2px; +} + +.bn-editor .bn-block-content[data-content-type="heading"] .bn-inline-content { + line-height: 1.2; +} diff --git a/apps/block-editor/src/App.tsx b/apps/block-editor/src/App.tsx new file mode 100644 index 0000000..b7a06a2 --- /dev/null +++ b/apps/block-editor/src/App.tsx @@ -0,0 +1,85 @@ +import { useState, useRef, useEffect } from "react"; +import BlockNoteEditor from "./editors/BlockNoteEditor"; +import "./App.css"; + +const EDITORS = [ + { id: "blocknote", label: "Notion-style" }, + // { id: "simple", label: "Simple (Tiptap)" }, +] as const; + +type EditorId = (typeof EDITORS)[number]["id"]; + +export default function App() { + const [isDark, setIsDark] = useState(true); + const [activeEditor, setActiveEditor] = useState("blocknote"); + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClick(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + const currentEditorLabel = EDITORS.find((e) => e.id === activeEditor)?.label; + + return ( +
+ {/* ── Top bar ─────────────────────────────────────── */} +
+ {/* Left: editor picker */} +
+ + + {dropdownOpen && ( +
    + {EDITORS.map((ed) => ( +
  • { + setActiveEditor(ed.id); + setDropdownOpen(false); + }} + > + {ed.label} + {ed.id === activeEditor && } +
  • + ))} +
+ )} +
+ + {/* Right: dark / light toggle */} + +
+ + {/* ── Editor ──────────────────────────────────────── */} +
+ {activeEditor === "blocknote" && } +
+
+ ); +} diff --git a/apps/block-editor/src/assets/react.svg b/apps/block-editor/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/block-editor/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/block-editor/src/editors/BlockNoteEditor.tsx b/apps/block-editor/src/editors/BlockNoteEditor.tsx new file mode 100644 index 0000000..8996d87 --- /dev/null +++ b/apps/block-editor/src/editors/BlockNoteEditor.tsx @@ -0,0 +1,141 @@ +import "@blocknote/core/fonts/inter.css"; +import { + BlockNoteView, + darkDefaultTheme, + lightDefaultTheme, + type Theme, +} from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; + +const lightTheme = { + colors: { + editor: { + text: "#222222", + background: "#ffffff", + }, + menu: { + text: "#3f3f3f", + background: "#ffffff", + }, + tooltip: { + text: "#3f3f3f", + background: "#efefef", + }, + hovered: { + text: "#3f3f3f", + background: "#efefef", + }, + selected: { + text: "#ffffff", + background: "#3f3f3f", + }, + disabled: { + text: "#afafaf", + background: "#efefef", + }, + shadow: "#cfcfcf", + border: "#efefef", + sideMenu: "#cfcfcf", + highlights: lightDefaultTheme.colors!.highlights, + }, + borderRadius: 6, + fontFamily: "Inter, system-ui, sans-serif", +} satisfies Theme; + +const darkTheme = { + colors: { + editor: { + text: "#cfcfcf", + background: "#1f1f1f", + }, + menu: { + text: "#cfcfcf", + background: "#1f1f1f", + }, + tooltip: { + text: "#cfcfcf", + background: "#161616", + }, + hovered: { + text: "#cfcfcf", + background: "#161616", + }, + selected: { + text: "#cfcfcf", + background: "#0f0f0f", + }, + disabled: { + text: "#3f3f3f", + background: "#161616", + }, + shadow: "#0f0f0f", + border: "#2a2a2a", + sideMenu: "#7f7f7f", + highlights: darkDefaultTheme.colors!.highlights, + }, + + fontFamily: "Inter, system-ui, sans-serif", +} satisfies Theme; + +const editorTheme = { + light: lightTheme, + dark: darkTheme, +}; + +interface BlockNoteEditorProps { + isDark: boolean; +} + +export default function BlockNoteEditor({ isDark }: BlockNoteEditorProps) { + const editor = useCreateBlockNote({ + initialContent: [ + { + type: "heading", + props: { level: 1 }, + content: "Welcome to the Editor", + }, + { + type: "paragraph", + content: 'Type "/" to open the slash menu and insert blocks.', + }, + { + type: "heading", + props: { level: 2 }, + content: "Features", + }, + { + type: "bulletListItem", + content: "Drag & drop blocks with the handle on the left", + }, + { + type: "bulletListItem", + content: "Slash commands — press / to see all block types", + }, + { + type: "bulletListItem", + content: "Rich text formatting: bold, italic, underline, code…", + }, + { + type: "bulletListItem", + content: "Tables, images, code blocks, and more", + }, + { + type: "heading", + props: { level: 2 }, + content: "Getting started", + }, + { + type: "paragraph", + content: "Click anywhere to start editing. Enjoy! 🎉", + }, + ], + }); + + return ( + + ); +} diff --git a/apps/block-editor/src/index.css b/apps/block-editor/src/index.css new file mode 100644 index 0000000..b112d6a --- /dev/null +++ b/apps/block-editor/src/index.css @@ -0,0 +1,57 @@ +@import '../@/styles/_variables.scss'; +@import '../@/styles/_keyframe-animations.scss'; + +:root { + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body, +#root { + height: 100%; + width: 100%; + overflow: hidden; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/apps/block-editor/src/main.tsx b/apps/block-editor/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/apps/block-editor/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/apps/block-editor/src/scss.d.ts b/apps/block-editor/src/scss.d.ts new file mode 100644 index 0000000..0292a33 --- /dev/null +++ b/apps/block-editor/src/scss.d.ts @@ -0,0 +1 @@ +declare module "*.scss"; diff --git a/apps/block-editor/tsconfig.app.json b/apps/block-editor/tsconfig.app.json new file mode 100644 index 0000000..4b77024 --- /dev/null +++ b/apps/block-editor/tsconfig.app.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Path aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["./@/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src", "@"] +} diff --git a/apps/block-editor/tsconfig.json b/apps/block-editor/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/apps/block-editor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/block-editor/tsconfig.node.json b/apps/block-editor/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/apps/block-editor/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/block-editor/vite.config.ts b/apps/block-editor/vite.config.ts new file mode 100644 index 0000000..afdde71 --- /dev/null +++ b/apps/block-editor/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './@'), + }, + }, +})