shadimage
preskok
uieditorsource: ok · hit

Editor

A minimal rich text editor built on Tiptap

npx shadcn add "https://ui-three-mu.vercel.app/r/editor.json"
free

Visual style

minimal

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
rich-text@tiptap/react@tiptap/extension-text-style@tiptap/extension-typography@tiptap/extensions@tiptap/starter-kit@tiptap/extension-color@tiptap/extension-horizontal-rule@tiptap/pm
iconslucide-react

Registry dependencies

https://ui-three-mu.vercel.app/r/button.jsonhttps://ui-three-mu.vercel.app/r/field.jsonhttps://ui-three-mu.vercel.app/r/input.jsonhttps://ui-three-mu.vercel.app/r/menu.jsonhttps://ui-three-mu.vercel.app/r/popover.jsonhttps://ui-three-mu.vercel.app/r/separator.jsonhttps://ui-three-mu.vercel.app/r/switch.jsonhttps://ui-three-mu.vercel.app/r/text-field.jsonhttps://ui-three-mu.vercel.app/r/toggle.jsonhttps://ui-three-mu.vercel.app/r/toggle-group.jsonhttps://ui-three-mu.vercel.app/r/tooltip.jsonhttps://ui-three-mu.vercel.app/r/utils.json

Files29 · source cached 10/8/2026, 1:42:34 PM

registry/preskok/ui/preskok-ui/editor/editor.tsx
import "./styles/index.css"
import type { Content, Editor as TiptapEditor } from "@tiptap/react"
import { EditorContent, EditorContext } from "@tiptap/react"

import { cn } from "@/lib/utils"
import { useEditorContext } from "@/components/ui/preskok-ui/editor/hooks/use-editor-context"
import { Separator } from "@/components/ui/preskok-ui/separator"

import { LinkBubbleMenu } from "./components/bubble-menu/link-bubble-menu"
import { MeasuredContainer } from "./components/measured-container"
import { SectionFive } from "./components/section/five"
import { SectionFour } from "./components/section/four"
import { SectionOne } from "./components/section/one"
import { SectionThree } from "./components/section/three"
import { SectionTwo } from "./components/section/two"
import type { UseEditorProps } from "./hooks/use-editor"
import { useEditor } from "./hooks/use-editor"

const DEFAULT_ACTIVE_LEVELS: Array<1 | 2 | 3 | 4 | 5 | 6> = [1, 2, 3, 4, 5, 6]
const DEFAULT_TEXT_STYLE_ACTIONS: Array<
  "bold" | "italic" | "underline" | "strikethrough" | "clearFormatting"
> = ["bold", "italic", "underline", "strikethrough", "clearFormatting"]
const DEFAULT_LIST_ACTIONS: Array<"orderedList" | "bulletList"> = [
  "orderedList",
  "bulletList",
]
const DEFAULT_INSERT_ACTIONS: Array<"blockquote" | "horizontalRule"> = [
  "blockquote",
  "horizontalRule",
]

export interface MinimalTiptapProps extends Omit<UseEditorProps, "onUpdate"> {
  value?: Content
  onChange?: (value: Content) => void
  className?: string
  editorContentClassName?: string
}

const Toolbar = ({ editor }: { editor: TiptapEditor }) => (
  <div className="flex shrink-0 overflow-x-auto border-b border-border px-2 py-1.5">
    <div className="flex w-max items-center gap-px">
      <SectionOne editor={editor} activeLevels={DEFAULT_ACTIVE_LEVELS} />

      <Separator
        orientation="vertical"
        className="mx-1 data-[orientation=vertical]:h-3/4!"
      />

      <SectionTwo
        editor={editor}
        activeActions={DEFAULT_TEXT_STYLE_ACTIONS}
        mainActionCount={3}
      />

      <Separator
        orientation="vertical"
        className="mx-1 data-[orientation=vertical]:h-3/4!"
      />

      <SectionThree editor={editor} />

      <Separator
        orientation="vertical"
        className="mx-1 data-[orientation=vertical]:h-3/4!"
      />

      <SectionFour
        editor={editor}
        activeActions={DEFAULT_LIST_ACTIONS}
        mainActionCount={0}
      />

      <Separator
        orientation="vertical"
        className="mx-1 data-[orientation=vertical]:h-3/4!"
      />

      <SectionFive
        editor={editor}
        activeActions={DEFAULT_INSERT_ACTIONS}
        mainActionCount={0}
      />
    </div>
  </div>
)

export const Editor = ({
  value,
  onChange,
  className,
  editorContentClassName,
  ...props
}: MinimalTiptapProps) => {
  const editor = useEditor({
    value,
    onUpdate: onChange,
    ...props,
  })

  if (!editor) {
    return null
  }

  return (
    <EditorContext value={{ editor }}>
      <MainEditor
        editor={editor}
        className={className}
        editorContentClassName={editorContentClassName}
      />
    </EditorContext>
  )
}

export const MainEditor = ({
  editor: providedEditor,
  className,
  editorContentClassName,
}: MinimalTiptapProps & { editor: TiptapEditor }) => {
  const { editor } = useEditorContext(providedEditor)

  if (!editor) {
    return null
  }

  return (
    <MeasuredContainer
      as="div"
      name="editor"
      className={cn(
        "min-data-[orientation=vertical]:h-72 flex h-auto w-full flex-col rounded-md border border-border",
        className
      )}
    >
      <Toolbar editor={editor} />
      <EditorContent
        editor={editor}
        className={cn(
          "rich-text-editor h-full px-5 py-4 focus:outline-hidden",
          editorContentClassName
        )}
      />
      <LinkBubbleMenu editor={editor} />
    </MeasuredContainer>
  )
}
registry/preskok/ui/preskok-ui/editor/utils.ts
import type { Editor } from "@tiptap/react"

import type { MinimalTiptapProps } from "./editor"

type ShortcutKeyResult = {
  symbol: string
  readable: string
}

export const isClient = (): boolean => typeof window !== "undefined"

export const isMacOS = (): boolean =>
  isClient() && window.navigator.userAgent.includes("Macintosh")

const shortcutKeyMap = {
  mod: isMacOS()
    ? { symbol: "⌘", readable: "Command" }
    : { symbol: "Ctrl", readable: "Control" },
  alt: isMacOS()
    ? { symbol: "⌥", readable: "Option" }
    : { symbol: "Alt", readable: "Alt" },
  shift: { symbol: "⇧", readable: "Shift" },
} as const satisfies Record<string, ShortcutKeyResult>

type ShortcutKey = string

export const getShortcutKey = (key: ShortcutKey): ShortcutKeyResult => {
  const lowerKey = key.toLowerCase() as Lowercase<keyof typeof shortcutKeyMap>
  const result = shortcutKeyMap[lowerKey]
  return result || { symbol: key, readable: key }
}

export const getShortcutKeys = (
  keys: Array<ShortcutKey>
): Array<ShortcutKeyResult> => keys.map(getShortcutKey)

export const getOutput = (
  editor: Editor,
  format: MinimalTiptapProps["output"]
): object | string => {
  switch (format) {
    case "json":
      return editor.getJSON()
    case "html":
      return editor.isEmpty ? "" : editor.getHTML()
    default:
      return editor.getText()
  }
}
registry/preskok/ui/preskok-ui/editor/extensions/horizontal-rule.ts
import { HorizontalRule as TiptapHorizontalRule } from "@tiptap/extension-horizontal-rule"

export const HorizontalRule = TiptapHorizontalRule.extend({
  addKeyboardShortcuts() {
    return {
      "Mod-Alt--": () =>
        this.editor.commands.insertContent({
          type: this.name,
        }),
    }
  },
})
registry/preskok/ui/preskok-ui/editor/extensions/reset-marks-on-enter.ts
import { Extension } from "@tiptap/react"

export const ResetMarksOnEnter = Extension.create({
  name: "resetMarksOnEnter",

  addKeyboardShortcuts() {
    return {
      Enter: ({ editor }) => {
        if (
          editor.isActive("bold") ||
          editor.isActive("italic") ||
          editor.isActive("strike") ||
          editor.isActive("underline") ||
          editor.isActive("code")
        ) {
          editor.commands.splitBlock({ keepMarks: false })

          return true
        }

        return false
      },
    }
  },
})
registry/preskok/ui/preskok-ui/editor/extensions/unset-all-marks.ts
import { Extension } from "@tiptap/react"

export const UnsetAllMarks = Extension.create({
  addKeyboardShortcuts() {
    return {
      "Mod-\\": () => this.editor.commands.unsetAllMarks(),
    }
  },
})
registry/preskok/ui/preskok-ui/editor/extensions/color.ts
import { Color as TiptapColor } from "@tiptap/extension-color"
import { Plugin } from "@tiptap/pm/state"

export const Color = TiptapColor.extend({
  addProseMirrorPlugins() {
    return [
      ...(this.parent?.() || []),
      new Plugin({
        props: {
          handleKeyDown: (_, event) => {
            if (event.key === "Enter") {
              this.editor.commands.unsetColor()
            }
            return false
          },
        },
      }),
    ]
  },
})
registry/preskok/ui/preskok-ui/editor/types.ts
import type { EditorState } from "@tiptap/pm/state"
import type { EditorView } from "@tiptap/pm/view"
import type { Editor } from "@tiptap/react"

export interface LinkProps {
  url: string
  text?: string
  openInNewTab?: boolean
}

export interface ShouldShowProps {
  editor: Editor
  view: EditorView
  state: EditorState
  oldState?: EditorState
  from: number
  to: number
}

export interface FormatAction {
  label: string
  icon?: React.ReactNode
  action: (editor: Editor) => void
  isActive: (editor: Editor) => boolean
  canExecute: (editor: Editor) => boolean
  shortcuts: Array<string>
  value: string
}
registry/preskok/ui/preskok-ui/editor/styles/index.css
@import "./partials/placeholder.css";
@import "./partials/lists.css";
@import "./partials/typography.css";

@reference "../../../../../../styles/globals.css";

:root {
  --mt-overlay: rgba(251, 251, 251, 0.75);
  --mt-transparent-foreground: rgba(0, 0, 0, 0.373);
  --mt-bg-secondary: rgba(251, 251, 251, 0.8);
  --mt-secondary: #9d9d9f;
  --mt-hr: #dcdcdc;
  --mt-drag-handle-hover: #5c5c5e;

  --mt-accent-bold-blue: #05c;
  --mt-accent-bold-teal: #206a83;
  --mt-accent-bold-green: #216e4e;
  --mt-accent-bold-orange: #a54800;
  --mt-accent-bold-red: #ae2e24;
  --mt-accent-bold-purple: #5e4db2;

  --mt-accent-gray: #758195;
  --mt-accent-blue: #1d7afc;
  --mt-accent-teal: #2898bd;
  --mt-accent-green: #22a06b;
  --mt-accent-orange: #fea362;
  --mt-accent-red: #c9372c;
  --mt-accent-purple: #8270db;

  --mt-accent-blue-subtler: #cce0ff;
  --mt-accent-teal-subtler: #c6edfb;
  --mt-accent-green-subtler: #baf3db;
  --mt-accent-yellow-subtler: #f8e6a0;
  --mt-accent-red-subtler: #ffd5d2;
  --mt-accent-purple-subtler: #dfd8fd;
}

.dark {
  --mt-overlay: rgba(31, 32, 35, 0.75);
  --mt-transparent-foreground: rgba(255, 255, 255, 0.4);
  --mt-bg-secondary: rgba(31, 32, 35, 0.8);
  --mt-secondary: #595a5c;
  --mt-hr: #26282d;
  --mt-drag-handle-hover: #969799;

  --mt-accent-bold-blue: #85b8ff;
  --mt-accent-bold-teal: #9dd9ee;
  --mt-accent-bold-green: #7ee2b8;
  --mt-accent-bold-orange: #fec195;
  --mt-accent-bold-red: #fd9891;
  --mt-accent-bold-purple: #b8acf6;

  --mt-accent-gray: #738496;
  --mt-accent-blue: #388bff;
  --mt-accent-teal: #42b2d7;
  --mt-accent-green: #2abb7f;
  --mt-accent-orange: #a54800;
  --mt-accent-red: #e2483d;
  --mt-accent-purple: #8f7ee7;

  --mt-accent-blue-subtler: #09326c;
  --mt-accent-teal-subtler: #164555;
  --mt-accent-green-subtler: #164b35;
  --mt-accent-yellow-subtler: #533f04;
  --mt-accent-red-subtler: #5d1f1a;
  --mt-accent-purple-subtler: #352c63;
}

.rich-text-editor .ProseMirror {
  @apply flex max-w-full cursor-text flex-col;
  @apply z-0 outline-0;
}

.rich-text-editor .ProseMirror > div.editor {
  @apply block flex-1 whitespace-pre-wrap;
}

.rich-text-editor .ProseMirror .block-node:not(:last-child),
.rich-text-editor .ProseMirror .list-node:not(:last-child),
.rich-text-editor .ProseMirror .text-node:not(:last-child) {
  @apply mb-2.5;
}

.rich-text-editor .ProseMirror ol,
.rich-text-editor .ProseMirror ul {
  @apply pl-6;
}

.rich-text-editor .ProseMirror blockquote,
.rich-text-editor .ProseMirror dl,
.rich-text-editor .ProseMirror ol,
.rich-text-editor .ProseMirror p,
.rich-text-editor .ProseMirror pre,
.rich-text-editor .ProseMirror ul {
  @apply m-0;
}

.rich-text-editor .ProseMirror li {
  @apply leading-7;
}

.rich-text-editor .ProseMirror p {
  @apply break-words;
}

.rich-text-editor .ProseMirror li .text-node:has(+ .list-node),
.rich-text-editor .ProseMirror li > .list-node,
.rich-text-editor .ProseMirror li > .text-node,
.rich-text-editor .ProseMirror li p {
  @apply mb-0;
}

.rich-text-editor .ProseMirror blockquote {
  @apply relative pl-3.5;
}

.rich-text-editor .ProseMirror blockquote::before,
.rich-text-editor .ProseMirror blockquote.is-empty::before {
  @apply bg-accent-foreground/15 absolute top-0 bottom-0 left-0 h-full w-1 rounded-sm content-[''];
}

.rich-text-editor .ProseMirror hr {
  @apply my-3 h-0.5 w-full border-none bg-[var(--mt-hr)];
}

.rich-text-editor .ProseMirror-focused hr.ProseMirror-selectednode {
  @apply outline-muted-foreground rounded-full outline-2 outline-offset-1;
}

.rich-text-editor .ProseMirror .ProseMirror-gapcursor {
  @apply pointer-events-none absolute hidden;
}

.rich-text-editor .ProseMirror .ProseMirror-hideselection {
  @apply caret-transparent;
}

.rich-text-editor .ProseMirror.resize-cursor {
  @apply cursor-col-resize;
}

.rich-text-editor .ProseMirror .selection {
  @apply inline-block;
}

.rich-text-editor .ProseMirror s span {
  @apply line-through;
}

.rich-text-editor .ProseMirror .selection,
.rich-text-editor .ProseMirror *::selection {
  @apply bg-primary/25;
}

/* Override native selection when custom selection is present */
.rich-text-editor .ProseMirror .selection::selection {
  background: transparent;
}
registry/preskok/ui/preskok-ui/editor/styles/partials/typography.css
.rich-text-editor .ProseMirror .heading-node {
  @apply relative font-semibold;
}

.rich-text-editor .ProseMirror .heading-node:first-child {
  @apply mt-0;
}

.rich-text-editor .ProseMirror h1 {
  @apply mt-[46px] mb-4 text-[1.375rem] leading-7 tracking-[-0.004375rem];
}

.rich-text-editor .ProseMirror h2 {
  @apply mt-8 mb-3.5 text-[1.1875rem] leading-7 tracking-[0.003125rem];
}

.rich-text-editor .ProseMirror h3 {
  @apply mt-6 mb-3 text-[1.0625rem] leading-6 tracking-[0.00625rem];
}

.rich-text-editor .ProseMirror h4 {
  @apply mt-4 mb-2 text-[0.9375rem] leading-6;
}

.rich-text-editor .ProseMirror h5 {
  @apply mt-4 mb-2 text-sm;
}

.rich-text-editor .ProseMirror h5 {
  @apply mt-4 mb-2 text-sm;
}

.rich-text-editor .ProseMirror a.link {
  @apply text-primary underline;
}

.rich-text-editor .ProseMirror a.link:hover {
  @apply underline;
}
registry/preskok/ui/preskok-ui/editor/styles/partials/placeholder.css
.rich-text-editor .ProseMirror > p.is-editor-empty::before {
  content: attr(data-placeholder);
  @apply pointer-events-none float-left h-0 text-[var(--mt-secondary)];
}
registry/preskok/ui/preskok-ui/editor/styles/partials/lists.css
.rich-text-editor .ProseMirror ol {
  @apply list-decimal;
}

.rich-text-editor .ProseMirror ol ol {
  list-style: lower-alpha;
}

.rich-text-editor .ProseMirror ol ol ol {
  list-style: lower-roman;
}

.rich-text-editor .ProseMirror ul {
  list-style: disc;
}

.rich-text-editor .ProseMirror ul ul {
  list-style: circle;
}

.rich-text-editor .ProseMirror ul ul ul {
  list-style: square;
}
registry/preskok/ui/preskok-ui/editor/components/section/three.tsx
import * as React from "react"
import type { Editor } from "@tiptap/react"
import { CheckIcon, ChevronDownIcon } from "lucide-react"

import {
  Popover,
  PopoverBody,
  PopoverContent,
} from "@/components/ui/preskok-ui/popover"
import type { ToggleProps } from "@/components/ui/preskok-ui/toggle"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/components/ui/preskok-ui/toggle-group"
import {
  Tooltip,
  TooltipContent,
} from "@/components/ui/preskok-ui/tooltip"

import { useTheme } from "../../hooks/use-theme"
import { ToolbarButton } from "../toolbar-button"

interface ColorItem {
  cssVar: string
  label: string
  darkLabel?: string
}

interface ColorPalette {
  label: string
  colors: Array<ColorItem>
  inverse: string
}

const COLORS: Array<ColorPalette> = [
  {
    label: "Palette 1",
    inverse: "var(--background)",
    colors: [
      { cssVar: "var(--foreground)", label: "Default" },
      { cssVar: "var(--mt-accent-bold-blue)", label: "Bold blue" },
      { cssVar: "var(--mt-accent-bold-teal)", label: "Bold teal" },
      { cssVar: "var(--mt-accent-bold-green)", label: "Bold green" },
      { cssVar: "var(--mt-accent-bold-orange)", label: "Bold orange" },
      { cssVar: "var(--mt-accent-bold-red)", label: "Bold red" },
      { cssVar: "var(--mt-accent-bold-purple)", label: "Bold purple" },
    ],
  },
  {
    label: "Palette 2",
    inverse: "var(--background)",
    colors: [
      { cssVar: "var(--mt-accent-gray)", label: "Gray" },
      { cssVar: "var(--mt-accent-blue)", label: "Blue" },
      { cssVar: "var(--mt-accent-teal)", label: "Teal" },
      { cssVar: "var(--mt-accent-green)", label: "Green" },
      { cssVar: "var(--mt-accent-orange)", label: "Orange" },
      { cssVar: "var(--mt-accent-red)", label: "Red" },
      { cssVar: "var(--mt-accent-purple)", label: "Purple" },
    ],
  },
  {
    label: "Palette 3",
    inverse: "var(--foreground)",
    colors: [
      { cssVar: "hsl(var(--background))", label: "White", darkLabel: "Black" },
      { cssVar: "var(--mt-accent-blue-subtler)", label: "Blue subtle" },
      { cssVar: "var(--mt-accent-teal-subtler)", label: "Teal subtle" },
      { cssVar: "var(--mt-accent-green-subtler)", label: "Green subtle" },
      { cssVar: "var(--mt-accent-yellow-subtler)", label: "Yellow subtle" },
      { cssVar: "var(--mt-accent-red-subtler)", label: "Red subtle" },
      { cssVar: "var(--mt-accent-purple-subtler)", label: "Purple subtle" },
    ],
  },
]

const ColorButton = ({
  color,
  isSelected,
  inverse,
}: {
  color: ColorItem
  isSelected: boolean
  inverse: string
}) => {
  const isDarkMode = useTheme()
  const label = isDarkMode && color.darkLabel ? color.darkLabel : color.label

  return (
    <Tooltip>
      <ToggleGroupItem
        className="relative size-7 rounded-md p-0"
        id={color.cssVar}
        aria-label={label}
        style={{ backgroundColor: color.cssVar }}
      >
        {isSelected ? (
          <CheckIcon
            className="absolute inset-0 m-auto size-4"
            style={{ color: inverse }}
          />
        ) : null}
      </ToggleGroupItem>
      <TooltipContent placement="bottom">
        <p>{label}</p>
      </TooltipContent>
    </Tooltip>
  )
}

ColorButton.displayName = "ColorButton"

interface ColorPickerProps {
  palette: ColorPalette
  selectedColor: string
  inverse: string
  onColorChange: (value: string) => void
}

const ColorPicker = ({
  palette,
  selectedColor,
  inverse,
  onColorChange,
}: ColorPickerProps) => (
  <ToggleGroup
    selectionMode="single"
    selectedKeys={selectedColor ? new Set([selectedColor]) : new Set()}
    onSelectionChange={(keys) => {
      const first = Array.from(keys)[0]
      if (typeof first === "string") {
        onColorChange(first)
      }
    }}
    className="gap-2 [box-shadow:none]"
  >
    {palette.colors.map((color) => (
      <ColorButton
        key={color.cssVar}
        inverse={inverse}
        color={color}
        isSelected={selectedColor === color.cssVar}
      />
    ))}
  </ToggleGroup>
)

ColorPicker.displayName = "ColorPicker"

interface SectionThreeProps extends Pick<ToggleProps, "size"> {
  editor: Editor
}

interface TextStyleAttrs {
  color?: string
}

export const SectionThree: React.FC<SectionThreeProps> = ({ editor, size }) => {
  const textStyleAttrs = editor.getAttributes("textStyle") as TextStyleAttrs
  const color =
    typeof textStyleAttrs.color === "string"
      ? textStyleAttrs.color
      : "var(--foreground)"
  const [localColor, setLocalColor] = React.useState<string | null>(null)
  const selectedColor = localColor ?? color

  const handleColorChange = (value: string) => {
    setLocalColor(value)
    const storedMarks = editor.state.storedMarks
    if (storedMarks) {
      const textStyleMarkType = editor.schema.marks.textStyle
      editor.view.dispatch(editor.state.tr.removeStoredMark(textStyleMarkType))
    }

    setTimeout(() => {
      editor.chain().setColor(value).run()
      setLocalColor(null)
    }, 0)
  }

  return (
    <Popover>
      <ToolbarButton
        tooltip="Text color"
        aria-label="Text color"
        className="gap-0"
        size={size}
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="24"
          height="24"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          className="size-5"
          style={{ color: selectedColor }}
        >
          <path d="M4 20h16" />
          <path d="m6 16 6-12 6 12" />
          <path d="M8 12h8" />
        </svg>
        <ChevronDownIcon className="size-4" />
      </ToolbarButton>

      <PopoverContent placement="bottom start" className={"max-w-max"}>
        <PopoverBody className="py-4">
          {COLORS.map((palette) => (
            <ColorPicker
              key={palette.label}
              palette={palette}
              inverse={palette.inverse}
              selectedColor={selectedColor}
              onColorChange={handleColorChange}
            />
          ))}
        </PopoverBody>
      </PopoverContent>
    </Popover>
  )
}

SectionThree.displayName = "SectionThree"
registry/preskok/ui/preskok-ui/editor/components/section/two.tsx
import type { Editor } from "@tiptap/react"
import {
  BoldIcon,
  ItalicIcon,
  MoreHorizontalIcon,
  RemoveFormattingIcon,
  StrikethroughIcon,
  UnderlineIcon,
} from "lucide-react"

import type { ToggleProps } from "@/components/ui/preskok-ui/toggle"

import type { FormatAction } from "../../types"
import { ToolbarSection } from "../toolbar-section"

type TextStyleAction =
  | "bold"
  | "italic"
  | "underline"
  | "strikethrough"
  | "clearFormatting"

interface TextStyle extends FormatAction {
  value: TextStyleAction
}

const formatActions: Array<TextStyle> = [
  {
    value: "bold",
    label: "Bold",
    icon: <BoldIcon data-slot="icon" />,
    action: (editor) => editor.chain().focus().toggleBold().run(),
    isActive: (editor) => editor.isActive("bold"),
    canExecute: (editor) =>
      editor.can().chain().focus().toggleBold().run() &&
      !editor.isActive("codeBlock"),
    shortcuts: ["mod", "B"],
  },
  {
    value: "italic",
    label: "Italic",
    icon: <ItalicIcon data-slot="icon" />,
    action: (editor) => editor.chain().focus().toggleItalic().run(),
    isActive: (editor) => editor.isActive("italic"),
    canExecute: (editor) =>
      editor.can().chain().focus().toggleItalic().run() &&
      !editor.isActive("codeBlock"),
    shortcuts: ["mod", "I"],
  },
  {
    value: "underline",
    label: "Underline",
    icon: <UnderlineIcon data-slot="icon" />,
    action: (editor) => editor.chain().focus().toggleUnderline().run(),
    isActive: (editor) => editor.isActive("underline"),
    canExecute: (editor) =>
      editor.can().chain().focus().toggleUnderline().run() &&
      !editor.isActive("codeBlock"),
    shortcuts: ["mod", "U"],
  },
  {
    value: "strikethrough",
    label: "Strikethrough",
    icon: <StrikethroughIcon data-slot="icon" />,
    action: (editor) => editor.chain().focus().toggleStrike().run(),
    isActive: (editor) => editor.isActive("strike"),
    canExecute: (editor) =>
      editor.can().chain().focus().toggleStrike().run() &&
      !editor.isActive("codeBlock"),
    shortcuts: ["mod", "shift", "S"],
  },
  {
    value: "clearFormatting",
    label: "Clear formatting",
    icon: <RemoveFormattingIcon data-slot="icon" />,
    action: (editor) => editor.chain().focus().unsetAllMarks().run(),
    isActive: () => false,
    canExecute: (editor) =>
      editor.can().chain().focus().unsetAllMarks().run() &&
      !editor.isActive("codeBlock"),
    shortcuts: ["mod", "\\"],
  },
]

interface SectionTwoProps extends Pick<ToggleProps, "size"> {
  editor: Editor
  activeActions?: Array<TextStyleAction>
  mainActionCount?: number
}

const defaultActiveActions = formatActions.map((action) => action.value)

export const SectionTwo = ({
  editor,
  activeActions = defaultActiveActions,
  mainActionCount = 2,
  size = "lg",
}: SectionTwoProps) => {
  return (
    <ToolbarSection
      editor={editor}
      actions={formatActions}
      activeActions={activeActions}
      mainActionCount={mainActionCount}
      dropdownIcon={<MoreHorizontalIcon data-slot="icon" />}
      dropdownTooltip="More formatting"
      dropdownClassName="w-8"
      size={size}
    />
  )
}
registry/preskok/ui/preskok-ui/editor/components/section/four.tsx
import * as React from "react"
import type { Editor } from "@tiptap/react"
import { ChevronDownIcon, ListIcon } from "lucide-react"

import type { ToggleProps } from "@/components/ui/preskok-ui/toggle"

import type { FormatAction } from "../../types"
import { ToolbarSection } from "../toolbar-section"

type ListItemAction = "orderedList" | "bulletList"
interface ListItem extends FormatAction {
  value: ListItemAction
}

const formatActions: Array<ListItem> = [
  {
    value: "orderedList",
    label: "Numbered list",
    icon: (
      <svg
        xmlns="http://www.w3.org/2000/svg"
        height="20px"
        viewBox="0 -960 960 960"
        width="20px"
        fill="currentColor"
      >
        <path d="M144-144v-48h96v-24h-48v-48h48v-24h-96v-48h120q10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v48q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9 10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v48q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9H144Zm0-240v-96q0-10.2 6.9-17.1 6.9-6.9 17.1-6.9h72v-24h-96v-48h120q10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v72q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9h-72v24h96v48H144Zm48-240v-144h-48v-48h96v192h-48Zm168 384v-72h456v72H360Zm0-204v-72h456v72H360Zm0-204v-72h456v72H360Z" />
      </svg>
    ),
    isActive: (editor) => editor.isActive("orderedList"),
    action: (editor) => editor.chain().focus().toggleOrderedList().run(),
    canExecute: (editor) =>
      editor.can().chain().focus().toggleOrderedList().run(),
    shortcuts: ["mod", "shift", "7"],
  },
  {
    value: "bulletList",
    label: "Bullet list",
    icon: <ListIcon className="size-5" />,
    isActive: (editor) => editor.isActive("bulletList"),
    action: (editor) => editor.chain().focus().toggleBulletList().run(),
    canExecute: (editor) =>
      editor.can().chain().focus().toggleBulletList().run(),
    shortcuts: ["mod", "shift", "8"],
  },
]

interface SectionFourProps extends Pick<ToggleProps, "size"> {
  editor: Editor
  activeActions?: Array<ListItemAction>
  mainActionCount?: number
}

const defaultActiveActions = formatActions.map((action) => action.value)

export const SectionFour: React.FC<SectionFourProps> = ({
  editor,
  activeActions = defaultActiveActions,
  mainActionCount = 0,
  size,
}) => {
  return (
    <ToolbarSection
      editor={editor}
      actions={formatActions}
      activeActions={activeActions}
      mainActionCount={mainActionCount}
      dropdownIcon={
        <>
          <ListIcon className="size-5" />
          <ChevronDownIcon className="size-4" />
        </>
      }
      dropdownTooltip="Lists"
      size={size}
    />
  )
}

SectionFour.displayName = "SectionFour"
registry/preskok/ui/preskok-ui/editor/components/section/one.tsx
import * as React from "react"
import type { Editor } from "@tiptap/react"
import { CaseSensitiveIcon, ChevronDownIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  Menu,
  MenuContent,
  MenuItem,
} from "@/components/ui/preskok-ui/menu"
import type { ToggleProps } from "@/components/ui/preskok-ui/toggle"

import type { FormatAction } from "../../types"
import { ShortcutKey } from "../shortcut-key"
import { ToolbarButton } from "../toolbar-button"

type Level = 1 | 2 | 3 | 4 | 5 | 6
interface TextStyle extends Omit<
  FormatAction,
  "value" | "icon" | "action" | "isActive" | "canExecute"
> {
  element: keyof React.JSX.IntrinsicElements
  level?: Level
  className: string
}

const formatActions: Array<TextStyle> = [
  {
    label: "Normal Text",
    element: "span",
    className: "grow",
    shortcuts: ["mod", "alt", "0"],
  },
  {
    label: "Heading 1",
    element: "h1",
    level: 1,
    className: "m-0 grow text-3xl font-extrabold",
    shortcuts: ["mod", "alt", "1"],
  },
  {
    label: "Heading 2",
    element: "h2",
    level: 2,
    className: "m-0 grow text-xl font-bold",
    shortcuts: ["mod", "alt", "2"],
  },
  {
    label: "Heading 3",
    element: "h3",
    level: 3,
    className: "m-0 grow text-lg font-semibold",
    shortcuts: ["mod", "alt", "3"],
  },
  {
    label: "Heading 4",
    element: "h4",
    level: 4,
    className: "m-0 grow text-base font-semibold",
    shortcuts: ["mod", "alt", "4"],
  },
  {
    label: "Heading 5",
    element: "h5",
    level: 5,
    className: "m-0 grow text-sm font-normal",
    shortcuts: ["mod", "alt", "5"],
  },
  {
    label: "Heading 6",
    element: "h6",
    level: 6,
    className: "m-0 grow text-sm font-normal",
    shortcuts: ["mod", "alt", "6"],
  },
]

interface SectionOneProps extends Pick<ToggleProps, "size"> {
  editor: Editor
  activeLevels?: Array<Level>
}

const defaultActiveLevels = [1, 2, 3, 4, 5, 6]

export const SectionOne: React.FC<SectionOneProps> = ({
  editor,
  activeLevels = defaultActiveLevels,
  size,
}) => {
  const filteredActions = React.useMemo(
    () =>
      formatActions.filter(
        (action) => !action.level || activeLevels.includes(action.level)
      ),
    [activeLevels]
  )

  const handleStyleChange = React.useCallback(
    (level?: Level) => {
      if (level) {
        editor.chain().focus().toggleHeading({ level }).run()
      } else {
        editor.chain().focus().setParagraph().run()
      }
    },
    [editor]
  )

  const renderMenuItem = React.useCallback(
    ({ label, element: Element, level, className, shortcuts }: TextStyle) => (
      <MenuItem
        key={label}
        onAction={() => {
          handleStyleChange(level)
        }}
        className={cn("flex flex-row items-center justify-between gap-4", {
          "bg-accent": level
            ? editor.isActive("heading", { level })
            : editor.isActive("paragraph"),
        })}
        aria-label={label}
      >
        <Element className={className}>{label}</Element>
        <ShortcutKey keys={shortcuts} />
      </MenuItem>
    ),
    [editor, handleStyleChange]
  )

  return (
    <Menu>
      <ToolbarButton
        tooltip="Text styles"
        aria-label="Text styles"
        isDisabled={editor.isActive("codeBlock")}
        size={size}
        className="gap-0"
      >
        <CaseSensitiveIcon className="size-5" />
        <ChevronDownIcon className="size-5" />
      </ToolbarButton>

      <MenuContent popover={{ placement: "bottom start" }} className="w-full">
        {filteredActions.map(renderMenuItem)}
      </MenuContent>
    </Menu>
  )
}

SectionOne.displayName = "SectionOne"
registry/preskok/ui/preskok-ui/editor/components/section/five.tsx
import type { Editor } from "@tiptap/react"
import { ChevronDownIcon, MinusIcon, PlusIcon, QuoteIcon } from "lucide-react"

import { LinkEditPopover } from "@/components/ui/preskok-ui/editor/components/link/link-edit-popover"
import type { ToggleProps } from "@/components/ui/preskok-ui/toggle"

import type { FormatAction } from "../../types"
import { ToolbarSection } from "../toolbar-section"

type InsertElementAction = "blockquote" | "horizontalRule"
interface InsertElement extends FormatAction {
  value: InsertElementAction
}

const formatActions: Array<InsertElement> = [
  {
    value: "blockquote",
    label: "Blockquote",
    icon: <QuoteIcon className="size-5" />,
    action: (editor) => editor.chain().focus().toggleBlockquote().run(),
    isActive: (editor) => editor.isActive("blockquote"),
    canExecute: (editor) =>
      editor.can().chain().focus().toggleBlockquote().run(),
    shortcuts: ["mod", "shift", "B"],
  },
  {
    value: "horizontalRule",
    label: "Divider",
    icon: <MinusIcon className="size-5" />,
    action: (editor) => editor.chain().focus().setHorizontalRule().run(),
    isActive: () => false,
    canExecute: (editor) =>
      editor.can().chain().focus().setHorizontalRule().run(),
    shortcuts: ["mod", "alt", "-"],
  },
]

interface SectionFiveProps extends Pick<ToggleProps, "size"> {
  editor: Editor
  activeActions?: Array<InsertElementAction>
  mainActionCount?: number
}

const defaultActiveActions = formatActions.map((action) => action.value)

export const SectionFive = ({
  editor,
  activeActions = defaultActiveActions,
  mainActionCount = 0,
  size,
}: SectionFiveProps) => {
  return (
    <>
      <LinkEditPopover editor={editor} size={size} />
      <ToolbarSection
        editor={editor}
        actions={formatActions}
        activeActions={activeActions}
        mainActionCount={mainActionCount}
        dropdownIcon={
          <>
            <PlusIcon className="size-5" />
            <ChevronDownIcon className="size-4" />
          </>
        }
        dropdownTooltip="Insert elements"
        size={size}
      />
    </>
  )
}
registry/preskok/ui/preskok-ui/editor/components/toolbar-button.tsx
import * as React from "react"

import { cn } from "@/lib/utils"
import { Toggle } from "@/components/ui/preskok-ui/toggle"
import {
  Tooltip,
  TooltipContent,
  type TooltipContentProps,
} from "@/components/ui/preskok-ui/tooltip"

interface ToolbarButtonProps extends React.ComponentProps<typeof Toggle> {
  tooltip?: string
  tooltipOptions?: TooltipContentProps
  isActive?: boolean
}

export const ToolbarButton = ({
  children,
  tooltip,
  className,
  tooltipOptions,
  isActive,
  ...props
}: ToolbarButtonProps) => {
  const toggleRef = React.useRef<HTMLButtonElement>(null)

  const toggleButton = (
    <Toggle
      ref={toggleRef}
      className={cn({ "bg-muted/90": isActive }, className, "*:flex-1")}
      {...props}
    >
      {children}
    </Toggle>
  )

  if (!tooltip) {
    return toggleButton
  }

  return (
    <Tooltip>
      {toggleButton}
      <TooltipContent {...tooltipOptions}>
        <div className="flex flex-col items-center text-center">{tooltip}</div>
      </TooltipContent>
    </Tooltip>
  )
}

ToolbarButton.displayName = "ToolbarButton"
registry/preskok/ui/preskok-ui/editor/components/measured-container.tsx
import * as React from "react"

import { useContainerSize } from "../hooks/use-container-size"

interface MeasuredContainerProps<T extends React.ElementType> {
  as: T
  name: string
  children?: React.ReactNode
}

export const MeasuredContainer = <T extends React.ElementType>({
  as: Component,
  name,
  children,
  style,
  ...props
}: MeasuredContainerProps<T> & React.ComponentProps<T>) => {
  const [element, setElement] = React.useState<HTMLElement | null>(null)
  const rect = useContainerSize(element)
  const setRef = React.useCallback((node: HTMLElement | null) => {
    setElement(node)
  }, [])

  const customStyle = {
    [`--${name}-width`]: `${rect.width}px`,
    [`--${name}-height`]: `${rect.height}px`,
  }

  return (
    <Component {...props} ref={setRef} style={{ ...customStyle, ...style }}>
      {children}
    </Component>
  )
}

MeasuredContainer.displayName = "MeasuredContainer"
registry/preskok/ui/preskok-ui/editor/components/link/link-edit-block.tsx
import * as React from "react"

import { cn } from "@/lib/utils"
import type { ButtonProps } from "@/components/ui/preskok-ui/button"
import { Button } from "@/components/ui/preskok-ui/button"
import { Fieldset, Label } from "@/components/ui/preskok-ui/field"
import { Input } from "@/components/ui/preskok-ui/input"
import { Switch } from "@/components/ui/preskok-ui/switch"
import { TextField } from "@/components/ui/preskok-ui/text-field"

export interface LinkEditorProps extends React.ComponentProps<"div"> {
  defaultUrl?: string
  defaultText?: string
  defaultIsNewTab?: boolean
  onSave: (url: string, text?: string, isNewTab?: boolean) => void
}

export const LinkEditBlock = ({
  onSave,
  defaultIsNewTab,
  defaultUrl,
  defaultText,
  className,
}: LinkEditorProps) => {
  const formRef = React.useRef<HTMLDivElement>(null)
  const [url, setUrl] = React.useState(defaultUrl || "")
  const [text, setText] = React.useState(defaultText || "")
  const [isNewTab, setIsNewTab] = React.useState(defaultIsNewTab ?? false)

  const handleSave: ButtonProps["onClick"] = (e) => {
    e.preventDefault()
    if (formRef.current) {
      const isValid = Array.from(
        formRef.current.querySelectorAll("input")
      ).every((input) => input.checkValidity())

      if (isValid) {
        onSave(url, text, isNewTab)
      } else {
        formRef.current.querySelectorAll("input").forEach((input) => {
          if (!input.checkValidity()) {
            input.reportValidity()
          }
        })
      }
    }
  }

  return (
    <div ref={formRef}>
      <Fieldset className={cn("w-full space-y-4 p-4", className)}>
        <TextField>
          <Label>URL</Label>
          <Input
            type="url"
            required
            placeholder="Enter URL"
            value={url}
            onChange={(e) => {
              setUrl(e.target.value)
            }}
          />
        </TextField>

        <TextField>
          <Label>Display Text (optional)</Label>
          <Input
            type="text"
            placeholder="Enter display text"
            value={text}
            onChange={(e) => {
              setText(e.target.value)
            }}
          />
        </TextField>

        <TextField>
          <Switch isSelected={isNewTab} onChange={setIsNewTab}>
            <Label>Open in New Tab</Label>
          </Switch>
        </TextField>

        <div className="flex justify-end space-x-2">
          <Button type="submit" onClick={handleSave}>
            Save
          </Button>
        </div>
      </Fieldset>
    </div>
  )
}
registry/preskok/ui/preskok-ui/editor/components/link/link-popover-block.tsx
import * as React from "react"
import { CopyIcon, ExternalLinkIcon, Unlink2Icon } from "lucide-react"

import { Button } from "@/components/ui/preskok-ui/button"
import {
  Tooltip,
  TooltipContent,
} from "@/components/ui/preskok-ui/tooltip"

interface LinkPopoverBlockProps {
  url: string
  onClear: () => void
  onEdit: () => void
}

export const LinkPopoverBlock: React.FC<LinkPopoverBlockProps> = ({
  url,
  onClear,
  onEdit,
}) => {
  const [copyTitle, setCopyTitle] = React.useState<string>("Copy")

  const handleCopy = () => {
    navigator.clipboard
      .writeText(url)
      .then(() => {
        setCopyTitle("Copied!")
        setTimeout(() => {
          setCopyTitle("Copy")
        }, 1000)
      })
      .catch(console.error)
  }

  const handleOpenLink = React.useCallback(() => {
    window.open(url, "_blank", "noopener,noreferrer")
  }, [url])

  return (
    <div className="flex p-0.5">
      <div className="inline-flex items-center gap-1">
        <Tooltip>
          <Button intent="plain" onClick={onEdit}>
            Edit link
          </Button>
          <TooltipContent>Edit link</TooltipContent>
        </Tooltip>

        <Tooltip>
          <Button intent="plain" onClick={handleOpenLink}>
            <ExternalLinkIcon data-slot="icon" />
          </Button>
          <TooltipContent>Open link in a new tab</TooltipContent>
        </Tooltip>

        <Tooltip>
          <Button intent="plain" onClick={onClear}>
            <Unlink2Icon data-slot="icon" />
          </Button>
          <TooltipContent>Clear link</TooltipContent>
        </Tooltip>

        <Tooltip>
          <Button intent="plain" onClick={handleCopy}>
            <CopyIcon data-slot="icon" />
          </Button>
          <TooltipContent>{copyTitle}</TooltipContent>
        </Tooltip>
      </div>
    </div>
  )
}
registry/preskok/ui/preskok-ui/editor/components/link/link-edit-popover.tsx
import * as React from "react"
import type { Editor } from "@tiptap/react"
import { Link2Icon } from "lucide-react"

import {
  Popover,
  PopoverContent,
} from "@/components/ui/preskok-ui/popover"
import type { ToggleProps } from "@/components/ui/preskok-ui/toggle"

import { ToolbarButton } from "../toolbar-button"
import { LinkEditBlock } from "./link-edit-block"

interface LinkEditPopoverProps extends Pick<ToggleProps, "size"> {
  editor: Editor
}

const LinkEditPopover = ({ editor, size }: LinkEditPopoverProps) => {
  const [open, setOpen] = React.useState(false)

  const { from, to } = editor.state.selection
  const text = editor.state.doc.textBetween(from, to, " ")

  const onSetLink = React.useCallback(
    (url: string, text?: string, openInNewTab?: boolean) => {
      editor
        .chain()
        .focus()
        .extendMarkRange("link")
        .insertContent({
          type: "text",
          text: text || url,
          marks: [
            {
              type: "link",
              attrs: {
                href: url,
                target: openInNewTab ? "_blank" : "",
              },
            },
          ],
        })
        .setLink({ href: url })
        .run()

      editor.commands.enter()
      setOpen(false)
    },
    [editor]
  )

  return (
    <Popover isOpen={open} onOpenChange={setOpen}>
      <ToolbarButton
        tooltip="Link"
        aria-label="Insert link"
        isDisabled={editor.isActive("codeBlock")}
        size={size}
      >
        <Link2Icon className="size-5" />
      </ToolbarButton>
      <PopoverContent placement="bottom end">
        <LinkEditBlock onSave={onSetLink} defaultText={text} />
      </PopoverContent>
    </Popover>
  )
}

export { LinkEditPopover }
registry/preskok/ui/preskok-ui/editor/components/toolbar-section.tsx
import * as React from "react"
import type { Editor } from "@tiptap/react"
import { ChevronDownIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  Menu,
  MenuContent,
  MenuItem,
} from "@/components/ui/preskok-ui/menu"
import type { ToggleProps } from "@/components/ui/preskok-ui/toggle"

import type { FormatAction } from "../types"
import { getShortcutKey } from "../utils"
import { ShortcutKey } from "./shortcut-key"
import { ToolbarButton } from "./toolbar-button"

interface ToolbarSectionProps extends ToggleProps {
  editor: Editor
  actions: Array<FormatAction>
  activeActions?: Array<string>
  mainActionCount?: number
  dropdownIcon?: React.ReactNode
  dropdownTooltip?: string
  dropdownClassName?: string
}

export const ToolbarSection: React.FC<ToolbarSectionProps> = ({
  editor,
  actions,
  activeActions: _activeActions,
  mainActionCount = 0,
  dropdownIcon,
  dropdownTooltip = "More options",
  dropdownClassName = "w-12",
  size,
}) => {
  const activeActions = _activeActions ?? actions.map((action) => action.value)

  const { mainActions, dropdownActions } = React.useMemo(() => {
    const sortedActions = actions
      .filter((action) => activeActions.includes(action.value))
      .sort(
        (a, b) =>
          activeActions.indexOf(a.value) - activeActions.indexOf(b.value)
      )

    return {
      mainActions: sortedActions.slice(0, mainActionCount),
      dropdownActions: sortedActions.slice(mainActionCount),
    }
  }, [actions, activeActions, mainActionCount])

  const renderToolbarButton = React.useCallback(
    (action: FormatAction) => (
      <ToolbarButton
        key={action.label}
        onClick={() => {
          action.action(editor)
        }}
        isActive={action.isActive(editor)}
        isDisabled={!action.canExecute(editor)}
        tooltip={`${action.label} ${action.shortcuts.map((s) => getShortcutKey(s).symbol).join(" ")}`}
        aria-label={action.label}
        size={size}
      >
        {action.icon}
      </ToolbarButton>
    ),
    [editor, size]
  )

  const renderDropdownMenuItem = React.useCallback(
    (action: FormatAction) => (
      <MenuItem
        key={action.label}
        onAction={() => {
          action.action(editor)
        }}
        isDisabled={!action.canExecute(editor)}
        className={cn("flex flex-row items-center justify-between gap-4", {
          "bg-accent": action.isActive(editor),
        })}
        aria-label={action.label}
      >
        <span className="grow">{action.label}</span>
        <ShortcutKey keys={action.shortcuts} />
      </MenuItem>
    ),
    [editor]
  )

  const isDropdownActive = dropdownActions.some((action) =>
    action.isActive(editor)
  )

  return (
    <>
      {mainActions.map(renderToolbarButton)}
      {dropdownActions.length > 0 && (
        <Menu>
          <ToolbarButton
            isActive={isDropdownActive}
            tooltip={dropdownTooltip}
            aria-label={dropdownTooltip}
            className={cn("gap-0", dropdownClassName)}
          >
            {dropdownIcon || <ChevronDownIcon className="size-4" />}
          </ToolbarButton>

          <MenuContent
            popover={{ placement: "bottom start" }}
            className="w-full"
          >
            {dropdownActions.map(renderDropdownMenuItem)}
          </MenuContent>
        </Menu>
      )}
    </>
  )
}
registry/preskok/ui/preskok-ui/editor/components/shortcut-key.tsx
import * as React from "react"

import { cn } from "@/lib/utils"

import { getShortcutKey } from "../utils"

export interface ShortcutKeyProps extends React.ComponentProps<"span"> {
  keys: Array<string>
}

export const ShortcutKey = ({
  ref,
  className,
  keys,
  ...props
}: ShortcutKeyProps) => {
  const modifiedKeys = keys.map((key) => getShortcutKey(key))
  const ariaLabel = modifiedKeys
    .map((shortcut) => shortcut.readable)
    .join(" + ")

  return (
    <span
      aria-label={ariaLabel}
      className={cn("inline-flex items-center gap-0.5", className)}
      {...props}
      ref={ref}
    >
      {modifiedKeys.map((shortcut) => (
        <kbd
          key={shortcut.symbol}
          className={cn(
            "inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium text-[rgb(156,157,160)] capitalize",

            className
          )}
          {...props}
          ref={ref}
        >
          {shortcut.symbol}
        </kbd>
      ))}
    </span>
  )
}

ShortcutKey.displayName = "ShortcutKey"
registry/preskok/ui/preskok-ui/editor/components/bubble-menu/link-bubble-menu.tsx
import * as React from "react"
import type { Editor } from "@tiptap/react"
import { BubbleMenu } from "@tiptap/react/menus"

import type { ShouldShowProps } from "../../types"
import { LinkEditBlock } from "../link/link-edit-block"
import { LinkPopoverBlock } from "../link/link-popover-block"

interface LinkBubbleMenuProps {
  editor: Editor
}

interface LinkAttributes {
  href: string
  target: string
}

export const LinkBubbleMenu: React.FC<LinkBubbleMenuProps> = ({ editor }) => {
  const [showEdit, setShowEdit] = React.useState(false)
  const [linkAttrs, setLinkAttrs] = React.useState<LinkAttributes>({
    href: "",
    target: "",
  })
  const [selectedText, setSelectedText] = React.useState("")

  const updateLinkState = React.useCallback(() => {
    const { from, to } = editor.state.selection
    const { href, target } = editor.getAttributes("link") as LinkAttributes
    const text = editor.state.doc.textBetween(from, to, " ")

    setLinkAttrs({ href, target })
    setSelectedText(text)
  }, [editor])

  const shouldShow = React.useCallback(
    ({ editor, from, to }: ShouldShowProps) => {
      if (from === to) {
        return false
      }
      const { href } = editor.getAttributes("link") as LinkAttributes

      if (!editor.isActive("link") || !editor.isEditable) {
        return false
      }

      if (href) {
        updateLinkState()
        return true
      }
      return false
    },
    [updateLinkState]
  )

  const handleEdit = React.useCallback(() => {
    setShowEdit(true)
  }, [])

  const onSetLink = React.useCallback(
    (url: string, text?: string, openInNewTab?: boolean) => {
      editor
        .chain()
        .focus()
        .extendMarkRange("link")
        .insertContent({
          type: "text",
          text: text || url,
          marks: [
            {
              type: "link",
              attrs: {
                href: url,
                target: openInNewTab ? "_blank" : "",
              },
            },
          ],
        })
        .setLink({ href: url, target: openInNewTab ? "_blank" : "" })
        .run()
      setShowEdit(false)
      updateLinkState()
    },
    [editor, updateLinkState]
  )

  const onUnsetLink = React.useCallback(() => {
    editor.chain().focus().extendMarkRange("link").unsetLink().run()
    setShowEdit(false)
    updateLinkState()
  }, [editor, updateLinkState])

  return (
    <BubbleMenu
      editor={editor}
      shouldShow={shouldShow}
      options={{
        placement: "bottom-start",
        onHide: () => {
          setShowEdit(false)
        },
      }}
    >
      <div className="rounded-xl border border-foreground/10 bg-overlay text-overlay-foreground shadow-xs">
        {showEdit ? (
          <LinkEditBlock
            defaultUrl={linkAttrs.href}
            defaultText={selectedText}
            defaultIsNewTab={linkAttrs.target === "_blank"}
            onSave={onSetLink}
          />
        ) : (
          <LinkPopoverBlock
            onClear={onUnsetLink}
            url={linkAttrs.href}
            onEdit={handleEdit}
          />
        )}
      </div>
    </BubbleMenu>
  )
}
registry/preskok/ui/preskok-ui/editor/hooks/use-throttle.ts
"use client"

import { useCallback, useRef } from "react"

export function useThrottle<T extends (...args: never[]) => void>(
  callback: T,
  delay: number
): (...args: Parameters<T>) => void {
  const lastRan = useRef(0)
  const timeoutRef = useRef<NodeJS.Timeout | null>(null)

  return useCallback(
    (...args: Parameters<T>) => {
      const handler = () => {
        if (Date.now() - lastRan.current >= delay) {
          callback(...args)
          lastRan.current = Date.now()
        } else {
          if (timeoutRef.current) {
            clearTimeout(timeoutRef.current)
          }
          timeoutRef.current = setTimeout(
            () => {
              callback(...args)
              lastRan.current = Date.now()
            },
            delay - (Date.now() - lastRan.current)
          )
        }
      }

      handler()
    },
    [callback, delay]
  )
}
registry/preskok/ui/preskok-ui/editor/hooks/use-container-size.ts
"use client"

import { useCallback, useEffect, useState } from "react"

const DEFAULT_RECT: DOMRect = {
  top: 0,
  left: 0,
  bottom: 0,
  right: 0,
  x: 0,
  y: 0,
  width: 0,
  height: 0,
  toJSON: () => "{}",
}

export function useContainerSize(element: Element | null): DOMRect {
  const [size, setSize] = useState<DOMRect>(
    () => element?.getBoundingClientRect() ?? DEFAULT_RECT
  )

  const handleResize = useCallback(() => {
    if (!element) {
      return
    }

    const newRect = element.getBoundingClientRect()

    setSize((prevRect) => {
      if (
        Math.round(prevRect.width) === Math.round(newRect.width) &&
        Math.round(prevRect.height) === Math.round(newRect.height) &&
        Math.round(prevRect.x) === Math.round(newRect.x) &&
        Math.round(prevRect.y) === Math.round(newRect.y)
      ) {
        return prevRect
      }
      return newRect
    })
  }, [element])

  useEffect(() => {
    if (!element) {
      return
    }

    const resizeObserver = new ResizeObserver(handleResize)
    resizeObserver.observe(element)

    window.addEventListener("click", handleResize)
    window.addEventListener("resize", handleResize)

    return () => {
      resizeObserver.disconnect()
      window.removeEventListener("click", handleResize)
      window.removeEventListener("resize", handleResize)
    }
  }, [element, handleResize])

  return size
}
registry/preskok/ui/preskok-ui/editor/hooks/use-editor-context.ts
"use client"

import { useMemo } from "react"
import type { Editor } from "@tiptap/react"
import { useCurrentEditor, useEditorState } from "@tiptap/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 useEditorContext(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 }
}
registry/preskok/ui/preskok-ui/editor/hooks/use-editor.ts
"use client"

import * as React from "react"
import { TextStyle } from "@tiptap/extension-text-style"
import { Typography } from "@tiptap/extension-typography"
import { Placeholder, Selection } from "@tiptap/extensions"
import type { Content, Editor, UseEditorOptions } from "@tiptap/react"
import { useEditor as useTiptapEditor } from "@tiptap/react"
import { StarterKit } from "@tiptap/starter-kit"

import { cn } from "@/lib/utils"

import { Color } from "../extensions/color"
import { HorizontalRule } from "../extensions/horizontal-rule"
import { ResetMarksOnEnter } from "../extensions/reset-marks-on-enter"
import { UnsetAllMarks } from "../extensions/unset-all-marks"
import { getOutput } from "../utils"
import { useThrottle } from "./use-throttle"

export interface UseEditorProps extends UseEditorOptions {
  value?: Content
  output?: "html" | "json" | "text"
  placeholder?: string
  editorClassName?: string
  throttleDelay?: number
  onUpdate?: (content: Content) => void
  onBlur?: (content: Content) => void
}

const createExtensions = ({ placeholder }: { placeholder: string }) => [
  StarterKit.configure({
    blockquote: { HTMLAttributes: { class: "block-node" } },
    // bold
    bulletList: { HTMLAttributes: { class: "list-node" } },
    code: false,
    codeBlock: false,
    // document
    dropcursor: { width: 2, class: "ProseMirror-dropcursor border" },
    // gapcursor
    // hardBreak
    heading: { HTMLAttributes: { class: "heading-node" } },
    // undoRedo
    horizontalRule: false,
    // italic
    // listItem
    // listKeymap
    link: {
      enableClickSelection: true,
      openOnClick: false,
      HTMLAttributes: {
        class: "link",
      },
    },
    orderedList: { HTMLAttributes: { class: "list-node" } },
    paragraph: { HTMLAttributes: { class: "text-node" } },
    // strike
    // text
    // underline
    // trailingNode
  }),
  Color,
  TextStyle,
  Selection,
  Typography,
  UnsetAllMarks,
  HorizontalRule,
  ResetMarksOnEnter,
  Placeholder.configure({ placeholder: () => placeholder }),
]

export const useEditor = ({
  value,
  output = "html",
  placeholder = "",
  editorClassName,
  throttleDelay = 0,
  onUpdate,
  onBlur,
  ...props
}: UseEditorProps) => {
  const throttledSetValue = useThrottle(
    (value: Content) => onUpdate?.(value),
    throttleDelay
  )

  const handleUpdate = React.useCallback(
    (editor: Editor) => {
      throttledSetValue(getOutput(editor, output))
    },
    [output, throttledSetValue]
  )

  const handleCreate = React.useCallback(
    (editor: Editor) => {
      if (value && editor.isEmpty) {
        editor.commands.setContent(value)
      }
    },
    [value]
  )

  const handleBlur = React.useCallback(
    (editor: Editor) => onBlur?.(getOutput(editor, output)),
    [output, onBlur]
  )

  const editor = useTiptapEditor({
    immediatelyRender: false,
    extensions: createExtensions({ placeholder }),
    editorProps: {
      attributes: {
        autocomplete: "off",
        autocorrect: "off",
        autocapitalize: "off",
        class: cn("focus:outline-hidden", editorClassName),
      },
    },
    onUpdate: ({ editor }) => {
      handleUpdate(editor)
    },
    onCreate: ({ editor }) => {
      handleCreate(editor)
    },
    onBlur: ({ editor }) => handleBlur(editor),
    ...props,
  })

  return editor
}
registry/preskok/ui/preskok-ui/editor/hooks/use-theme.ts
"use client"

import * as React from "react"

export const useTheme = () => {
  return React.useSyncExternalStore(
    (onStoreChange) => {
      const darkModeMediaQuery = window.matchMedia(
        "(prefers-color-scheme: dark)"
      )
      darkModeMediaQuery.addEventListener("change", onStoreChange)
      return () =>
        darkModeMediaQuery.removeEventListener("change", onStoreChange)
    },
    () => window.matchMedia("(prefers-color-scheme: dark)").matches,
    () => false
  )
}

More from preskok

Browse all components in this library →