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 · mediumrich-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
)
}