Add initial project files
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user