Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
@@ -0,0 +1,5 @@
*.md
node_modules
dist
public
build
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "lf",
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"semi": true,
"bracketSameLine": false,
"jsxSingleQuote": false,
"arrowParens": "always"
}
@@ -0,0 +1,46 @@
# @crux-kit/editor-tiptap
A free, headless rich-text editor for Crux apps, built on [TipTap](https://tiptap.dev)
(MIT-licensed). Emits and consumes plain **HTML strings**, so it drops into the same
`value` / `onChange` seam the previous TinyMCE editor used.
This package contains **all TipTap-specific editor code**. Other editor libraries live in
their own sibling packages (e.g. `@crux-kit/editor-quill`, `@crux-kit/editor-lexical`).
The editor is **preset-driven**: one library, multiple feature levels. Pick a preset per
app / per field instead of shipping a different editor library in each app.
## Usage
```tsx
import { RichTextEditor } from '@crux-kit/editor-tiptap';
<RichTextEditor value={html} onChange={setHtml} preset="light" placeholder="Write…" />;
```
### Presets
| Preset | Features |
| ------- | ------------------------------------------------------------------------------------------- |
| `light` | Bold, italic, underline, strike, headings, lists, blockquote, text align, links, undo/redo. |
Extend by adding new presets in `src/presets.ts` — the toolbar renders from the preset, so
new apps (e.g. web studio / page manager) reuse the same component with a different preset.
## Styling
The toolbar uses the shared design-system Tailwind tokens (`bg-accent`, `border-input`,
`text-muted-foreground`, …), so it themes automatically with the host app.
Wire it up once, entirely in the app's main Tailwind CSS entry:
```css
/* generate the toolbar's utility classes */
@source '../../../../../packages/design-system/editor-tiptap/src';
/* editor content styles */
@import '@crux-kit/editor-tiptap/styles.css';
```
(The content styles can also be pulled in from JS with
`import '@crux-kit/editor-tiptap/styles.css';` — but keeping both lines together in the CSS
entry is cleaner.)
@@ -0,0 +1,29 @@
{
"name": "@crux-kit/editor-tiptap",
"version": "1.3.1",
"type": "module",
"types": "src/index.ts",
"private": true,
"exports": {
".": "./src/index.ts",
"./styles.css": "./src/styles.css"
},
"style": "src/styles.css",
"scripts": {
"format": "prettier --write ."
},
"dependencies": {
"@crux-kit/utils": "*",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-text-align": "^3.29.2",
"@tiptap/extensions": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/react": "^3.29.2",
"@tiptap/starter-kit": "^3.29.2"
},
"peerDependencies": {
"lucide-react": "*",
"react": ">=19",
"react-dom": ">=19"
}
}
@@ -0,0 +1,69 @@
import { type Editor, EditorContent, useEditor } from '@tiptap/react';
import React from 'react';
import { cn } from '@crux-kit/utils';
import { Toolbar } from './components/Toolbar';
import { buildExtensions, type EditorPreset } from './presets';
export type RichTextEditorProps = {
/** Current value as an HTML string. */
value: string;
/** Called with the new HTML string on every change. */
onChange: (value: string) => void;
/** Feature level. Defaults to `light`. */
preset?: EditorPreset;
disabled?: boolean;
placeholder?: string;
className?: string;
/** Access the underlying TipTap editor instance once it is ready. */
onReady?: (editor: Editor) => void;
};
export const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
preset = 'light',
disabled = false,
placeholder,
className,
onReady,
}) => {
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
const onReadyRef = React.useRef(onReady);
onReadyRef.current = onReady;
const editor = useEditor({
immediatelyRender: false,
editable: !disabled,
extensions: buildExtensions(preset, placeholder),
content: value,
editorProps: {
attributes: {
class: 'crux-editor__content',
role: 'textbox',
'aria-multiline': 'true',
},
},
onCreate: ({ editor }) => onReadyRef.current?.(editor),
onUpdate: ({ editor }) => onChangeRef.current(editor.getHTML()),
});
React.useEffect(() => {
if (!editor) return;
const next = value ?? '';
if (next !== editor.getHTML()) {
editor.commands.setContent(next, { emitUpdate: false });
}
}, [editor, value]);
React.useEffect(() => {
editor?.setEditable(!disabled);
}, [editor, disabled]);
return (
<div className={cn('crux-editor overflow-hidden rounded-md', className)}>
<Toolbar editor={editor} preset={preset} disabled={disabled} />
<EditorContent editor={editor} />
</div>
);
};
@@ -0,0 +1,38 @@
import React from 'react';
import { cn } from '@crux-kit/utils';
import { type BlockType } from '../presets';
const OPTIONS: Array<{ value: BlockType; label: string }> = [
{ value: 'paragraph', label: 'Paragraph' },
{ value: 'h1', label: 'Heading 1' },
{ value: 'h2', label: 'Heading 2' },
{ value: 'h3', label: 'Heading 3' },
];
export const BlockTypeSelect: React.FC<{
value: BlockType;
disabled?: boolean;
onChange: (value: BlockType) => void;
}> = ({ value, disabled = false, onChange }) => {
return (
<select
aria-label="Text style"
title="Text style"
value={value}
disabled={disabled}
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => onChange(e.target.value as BlockType)}
className={cn(
'border-input bg-background text-foreground h-8 rounded-md border px-2 text-sm',
'hover:bg-muted focus-visible:ring-ring focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-1',
'disabled:pointer-events-none disabled:opacity-40',
)}
>
{OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
};
@@ -0,0 +1,103 @@
import { Check, Link2, Trash2 } from 'lucide-react';
import React from 'react';
import { cn } from '@crux-kit/utils';
import { ToolbarButton } from './ToolbarButton';
export const LinkEditor: React.FC<{
isActive: boolean;
disabled?: boolean;
getHref: () => string;
onApply: (href: string) => void;
onRemove: () => void;
}> = ({ isActive, disabled = false, getHref, onApply, onRemove }) => {
const [open, setOpen] = React.useState(false);
const [href, setHref] = React.useState('');
const wrapperRef = React.useRef<HTMLSpanElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const close = React.useCallback(() => setOpen(false), []);
const toggle = React.useCallback(() => {
setOpen((prev) => {
const next = !prev;
if (next) setHref(getHref());
return next;
});
}, [getHref]);
React.useEffect(() => {
if (!open) return;
const id = window.setTimeout(() => inputRef.current?.focus(), 0);
const onPointerDown = (e: MouseEvent) => {
if (!wrapperRef.current?.contains(e.target as Node)) close();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
window.clearTimeout(id);
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open, close]);
const apply = () => {
const trimmed = href.trim();
if (trimmed) onApply(trimmed);
else onRemove();
close();
};
const remove = () => {
onRemove();
close();
};
return (
<span ref={wrapperRef} className="relative inline-flex">
<ToolbarButton title="Insert link" active={isActive} disabled={disabled} onClick={toggle}>
<Link2 />
</ToolbarButton>
{open && (
<div
role="dialog"
aria-label="Edit link"
className={cn(
'border-border bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 flex w-72 items-center gap-1 rounded-md border p-1.5 shadow-md',
)}
>
<input
ref={inputRef}
type="url"
value={href}
placeholder="https://example.com"
onChange={(e) => setHref(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
apply();
}
}}
className={cn(
'border-input bg-background text-foreground h-8 flex-1 rounded-md border px-2 text-sm',
'focus-visible:ring-ring focus-visible:outline-hidden focus-visible:ring-2',
)}
/>
<ToolbarButton title="Apply link" onClick={apply}>
<Check />
</ToolbarButton>
{isActive && (
<ToolbarButton title="Remove link" onClick={remove}>
<Trash2 />
</ToolbarButton>
)}
</div>
)}
</span>
);
};
@@ -0,0 +1,285 @@
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Italic,
List,
ListOrdered,
Quote,
RemoveFormatting,
Redo2,
Strikethrough,
Underline,
Undo2,
Unlink,
} from 'lucide-react';
import React from 'react';
import { type Editor, useEditorState } from '@tiptap/react';
import { type BlockType, type ToolbarItem, toolbarLayouts, type EditorPreset } from '../presets';
import { BlockTypeSelect } from './BlockTypeSelect';
import { LinkEditor } from './LinkEditor';
import { ToolbarButton, ToolbarSeparator } from './ToolbarButton';
function getBlockType(editor: Editor): BlockType {
if (editor.isActive('heading', { level: 1 })) return 'h1';
if (editor.isActive('heading', { level: 2 })) return 'h2';
if (editor.isActive('heading', { level: 3 })) return 'h3';
return 'paragraph';
}
export const Toolbar: React.FC<{
editor: Editor | null;
preset: EditorPreset;
disabled?: boolean;
}> = ({ editor, preset, disabled = false }) => {
const state = useEditorState({
editor,
selector: ({ editor: e }) => {
if (!e) return null;
return {
canUndo: e.can().undo(),
canRedo: e.can().redo(),
blockType: getBlockType(e),
isBold: e.isActive('bold'),
isItalic: e.isActive('italic'),
isUnderline: e.isActive('underline'),
isStrike: e.isActive('strike'),
isBulletList: e.isActive('bulletList'),
isOrderedList: e.isActive('orderedList'),
isBlockquote: e.isActive('blockquote'),
alignLeft: e.isActive({ textAlign: 'left' }),
alignCenter: e.isActive({ textAlign: 'center' }),
alignRight: e.isActive({ textAlign: 'right' }),
alignJustify: e.isActive({ textAlign: 'justify' }),
isLink: e.isActive('link'),
};
},
});
if (!editor || !state) return null;
const setBlockType = (type: BlockType) => {
const chain = editor.chain().focus();
if (type === 'paragraph') chain.setParagraph().run();
else chain.setHeading({ level: Number(type.slice(1)) as 1 | 2 | 3 }).run();
};
const renderItem = (item: ToolbarItem, index: number): React.ReactNode => {
switch (item) {
case '|':
return <ToolbarSeparator key={`sep-${index}`} />;
case 'undo':
return (
<ToolbarButton
key={item}
title="Undo"
disabled={disabled || !state.canUndo}
onClick={() => editor.chain().focus().undo().run()}
>
<Undo2 />
</ToolbarButton>
);
case 'redo':
return (
<ToolbarButton
key={item}
title="Redo"
disabled={disabled || !state.canRedo}
onClick={() => editor.chain().focus().redo().run()}
>
<Redo2 />
</ToolbarButton>
);
case 'blockType':
return (
<BlockTypeSelect
key={item}
value={state.blockType}
disabled={disabled}
onChange={setBlockType}
/>
);
case 'bold':
return (
<ToolbarButton
key={item}
title="Bold"
active={state.isBold}
disabled={disabled}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<Bold />
</ToolbarButton>
);
case 'italic':
return (
<ToolbarButton
key={item}
title="Italic"
active={state.isItalic}
disabled={disabled}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<Italic />
</ToolbarButton>
);
case 'underline':
return (
<ToolbarButton
key={item}
title="Underline"
active={state.isUnderline}
disabled={disabled}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<Underline />
</ToolbarButton>
);
case 'strike':
return (
<ToolbarButton
key={item}
title="Strikethrough"
active={state.isStrike}
disabled={disabled}
onClick={() => editor.chain().focus().toggleStrike().run()}
>
<Strikethrough />
</ToolbarButton>
);
case 'bulletList':
return (
<ToolbarButton
key={item}
title="Bullet list"
active={state.isBulletList}
disabled={disabled}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<List />
</ToolbarButton>
);
case 'orderedList':
return (
<ToolbarButton
key={item}
title="Numbered list"
active={state.isOrderedList}
disabled={disabled}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered />
</ToolbarButton>
);
case 'blockquote':
return (
<ToolbarButton
key={item}
title="Quote"
active={state.isBlockquote}
disabled={disabled}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<Quote />
</ToolbarButton>
);
case 'alignLeft':
return (
<ToolbarButton
key={item}
title="Align left"
active={state.alignLeft}
disabled={disabled}
onClick={() => editor.chain().focus().setTextAlign('left').run()}
>
<AlignLeft />
</ToolbarButton>
);
case 'alignCenter':
return (
<ToolbarButton
key={item}
title="Align center"
active={state.alignCenter}
disabled={disabled}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
>
<AlignCenter />
</ToolbarButton>
);
case 'alignRight':
return (
<ToolbarButton
key={item}
title="Align right"
active={state.alignRight}
disabled={disabled}
onClick={() => editor.chain().focus().setTextAlign('right').run()}
>
<AlignRight />
</ToolbarButton>
);
case 'alignJustify':
return (
<ToolbarButton
key={item}
title="Justify"
active={state.alignJustify}
disabled={disabled}
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
>
<AlignJustify />
</ToolbarButton>
);
case 'link':
return (
<LinkEditor
key={item}
isActive={state.isLink}
disabled={disabled}
getHref={() => (editor.getAttributes('link')['href'] as string) ?? ''}
onApply={(href) =>
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
}
onRemove={() => editor.chain().focus().extendMarkRange('link').unsetLink().run()}
/>
);
case 'unlink':
return (
<ToolbarButton
key={item}
title="Remove link"
disabled={disabled || !state.isLink}
onClick={() => editor.chain().focus().extendMarkRange('link').unsetLink().run()}
>
<Unlink />
</ToolbarButton>
);
case 'clear':
return (
<ToolbarButton
key={item}
title="Clear formatting"
disabled={disabled}
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
>
<RemoveFormatting />
</ToolbarButton>
);
default:
return null;
}
};
return (
<div
role="toolbar"
aria-label="Formatting"
className="border-input flex flex-wrap items-center gap-0.5 rounded-t-md border border-b-0 p-1"
>
{toolbarLayouts[preset].map((item, index) => renderItem(item, index))}
</div>
);
};
@@ -0,0 +1,49 @@
import React from 'react';
import { cn } from '@crux-kit/utils';
export type ToolbarButtonProps = {
active?: boolean;
disabled?: boolean;
title: string;
onClick: () => void;
children: React.ReactNode;
};
/**
* A single toolbar control. Styled with the shared design-system Tailwind
* tokens so it matches the host app in light and dark mode. Kept dependency-free
* (plain <button>) so the package stays portable across apps.
*/
export const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
({ active = false, disabled = false, title, onClick, children }, ref) => {
return (
<button
ref={ref}
type="button"
title={title}
aria-label={title}
aria-pressed={active}
data-active={active}
disabled={disabled}
onClick={onClick}
// Prevent the editor from losing its selection when the control is pressed.
onMouseDown={(e) => e.preventDefault()}
className={cn(
'text-muted-foreground inline-flex h-8 min-w-8 shrink-0 items-center justify-center rounded-md px-2 text-sm transition-colors',
'hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-1',
'disabled:pointer-events-none disabled:opacity-40',
'data-[active=true]:bg-accent data-[active=true]:text-accent-foreground',
'[&_svg]:size-4 [&_svg]:shrink-0',
)}
>
{children}
</button>
);
},
);
ToolbarButton.displayName = 'ToolbarButton';
export const ToolbarSeparator: React.FC = () => (
<span aria-hidden className="bg-border mx-1 h-5 w-px shrink-0" />
);
@@ -0,0 +1,9 @@
export { RichTextEditor, type RichTextEditorProps } from './RichTextEditor';
export {
type EditorPreset,
type ToolbarItem,
type BlockType,
toolbarLayouts,
buildExtensions,
} from './presets';
export { type Editor } from '@tiptap/react';
@@ -0,0 +1,84 @@
import { type AnyExtension } from '@tiptap/core';
import { TextAlign } from '@tiptap/extension-text-align';
import { Placeholder } from '@tiptap/extensions';
import { StarterKit } from '@tiptap/starter-kit';
export type EditorPreset = 'light';
export type ToolbarItem =
| '|'
| 'undo'
| 'redo'
| 'blockType'
| 'bold'
| 'italic'
| 'underline'
| 'strike'
| 'bulletList'
| 'orderedList'
| 'blockquote'
| 'alignLeft'
| 'alignCenter'
| 'alignRight'
| 'alignJustify'
| 'link'
| 'unlink'
| 'clear';
export type BlockType = 'paragraph' | 'h1' | 'h2' | 'h3';
export const HEADING_LEVELS = [1, 2, 3] as const;
export function buildExtensions(preset: EditorPreset, placeholder?: string): AnyExtension[] {
switch (preset) {
case 'light':
default:
return [
StarterKit.configure({
heading: { levels: [...HEADING_LEVELS] },
link: {
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' },
},
codeBlock: false,
code: false,
horizontalRule: false,
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({
placeholder: placeholder ?? '',
showOnlyWhenEditable: true,
}),
];
}
}
export const toolbarLayouts: Record<EditorPreset, ToolbarItem[]> = {
light: [
'undo',
'redo',
'|',
'blockType',
'|',
'bold',
'italic',
'underline',
'strike',
'|',
'bulletList',
'orderedList',
'blockquote',
'|',
'alignLeft',
'alignCenter',
'alignRight',
'alignJustify',
'|',
'link',
'unlink',
'|',
'clear',
],
};
@@ -0,0 +1,108 @@
/**
* Content styles for @crux-kit/editor-tiptap.
* Plain CSS on purpose: this file is imported directly by consumers and must not
* depend on the host app's Tailwind pipeline. Colors reference the shared
* design-system CSS variables (--foreground, --border, …) so the editor themes
* automatically in light/dark mode.
*/
.crux-editor__content {
min-height: 12rem;
padding: 0.75rem 0.875rem;
outline: none;
color: var(--foreground);
font-size: 0.875rem;
line-height: 1.6;
border-bottom: 1px solid var(--color-input);
border-left: 1px solid var(--color-input);
border-right: 1px solid var(--color-input);
border-bottom-right-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.crux-editor__content :first-child {
margin-top: 0;
}
.crux-editor__content :last-child {
margin-bottom: 0;
}
.crux-editor__content p {
margin: 0 0 0.75em;
}
.crux-editor__content h1,
.crux-editor__content h2,
.crux-editor__content h3 {
margin: 1.25em 0 0.5em;
font-weight: 600;
line-height: 1.25;
color: var(--foreground);
}
.crux-editor__content h1 {
font-size: 1.75em;
}
.crux-editor__content h2 {
font-size: 1.4em;
}
.crux-editor__content h3 {
font-size: 1.15em;
}
.crux-editor__content ul,
.crux-editor__content ol {
margin: 0 0 0.75em;
padding-left: 1.5rem;
}
.crux-editor__content ul {
list-style: disc;
}
.crux-editor__content ol {
list-style: decimal;
}
.crux-editor__content li {
margin: 0.25em 0;
}
.crux-editor__content li p {
margin: 0;
}
.crux-editor__content blockquote {
margin: 0 0 0.75em;
padding-left: 1rem;
border-left: 3px solid var(--border);
color: var(--muted-foreground);
}
.crux-editor__content a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.crux-editor__content strong {
font-weight: 600;
}
/* Placeholder (from @tiptap/extensions Placeholder) */
.crux-editor__content p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--muted-foreground);
}
/* ProseMirror selection niceties */
.crux-editor__content .ProseMirror-selectednode {
outline: 2px solid var(--ring);
}
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM"],
"jsx": "react-jsx",
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"composite": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}