diff --git a/README.md b/README.md index 47437385..842cf65a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A React component that lets you combine editable text with any component using annotated text. +**[Documentation](https://marked-input.vercel.app)** + ## Feature - Powerful annotations tool: add, edit, remove, visualize @@ -28,722 +30,6 @@ You can install the package via npm: npm install rc-marked-input ``` -## Usage - -There are many examples available in the [Storybook](https://marked-input.vercel.app). You can also try a template -on [CodeSandbox](https://codesandbox.io/s/configured-marked-input-305v6m). - -Here are a few examples to get you started: - -### Static marks · [![sandbox](https://user-images.githubusercontent.com/37639183/199624889-6129e303-6b44-4b82-859d-ada79942842c.svg)](https://codesandbox.io/s/marked-input-x5wx6k?file=/src/App.tsx) - -```javascript -import {MarkedInput} from 'rc-marked-input' - -const Mark = props => alert(props.meta)}>{props.value} - -const Marked = () => { - const [value, setValue] = useState('Hello, clickable marked @[world](Hello! Hello!)!') - return -} -``` - -#### Configured · [![sandbox](https://user-images.githubusercontent.com/37639183/199624889-6129e303-6b44-4b82-859d-ada79942842c.svg)](https://codesandbox.io/s/configured-marked-input-305v6m) - -The library allows you to configure the `MarkedInput` component in two ways. - -Let's declare markups and suggestions data: - -```tsx -const Data = ['First', 'Second', 'Third', 'Fourth', 'Fifth', 'Sixth'] -const AnotherData = ['Seventh', 'Eight', 'Ninth'] -const Primary = '@[__value__](primary:__meta__)' -const Default = '@[__value__](default)' -``` - -Using the components - -```tsx -import {MarkedInput} from 'rc-marked-input' - -export const App = () => { - const [value, setValue] = useState( - "Enter the '@' for creating @[Primary Mark](primary:Hello!) or '/' for @[Default mark](default)!" - ) - - return ( - ({label: value, primary: true, onClick: () => alert(meta)}), - overlay: {trigger: '@', data: Data}, - }, - }, - { - markup: Default, - slotProps: { - overlay: {trigger: '/', data: AnotherData}, - }, - }, - ]} - /> - ) -} -``` - -Using the `createMarkedInput`: - -```tsx -import {createMarkedInput} from 'rc-marked-input' - -const ConfiguredMarkedInput = createMarkedInput({ - Mark: Button, - options: [ - { - markup: Primary, - slotProps: { - mark: ({value, meta}) => ({label: value, primary: true, onClick: () => alert(meta)}), - overlay: {trigger: '@', data: ['First', 'Second', 'Third', 'Fourth', 'Fifth', 'Sixth']}, - }, - }, - { - markup: Default, - slotProps: { - mark: ({value}) => ({label: value}), - overlay: {trigger: '/', data: ['Seventh', 'Eight', 'Ninth']}, - }, - }, - ], -}) - -const App = () => { - const [value, setValue] = useState( - "Enter the '@' for creating @[Primary Mark](primary:Hello!) or '/' for @[Default mark](default)!" - ) - return -} -``` - -#### Static Props with Objects - -You can use `slotProps.mark` as a static object instead of a function. This is useful when you want to pass fixed props to your Mark component: - -```tsx -import {MarkedInput} from 'rc-marked-input' -import {Chip} from '@mui/material' - -const App = () => { - const [value, setValue] = useState('This is a @[static] chip!') - - return ( - - ) -} -``` - -**Key differences:** - -- **Object form**: Props are passed directly to the Mark component (full replacement of MarkProps) -- **Function form**: You can access and transform `value`, `meta`, `nested`, and `children` from the markup - -```tsx -// Object - static props -slotProps: { mark: { label: 'Fixed', color: 'primary' } } - -// Function - dynamic props based on markup -slotProps: { mark: ({ value, meta }) => ({ label: value, onClick: () => alert(meta) }) } -``` - -### Dynamic mark · [![sandbox](https://user-images.githubusercontent.com/37639183/199624889-6129e303-6b44-4b82-859d-ada79942842c.svg)](https://codesandbox.io/s/dynamic-mark-w2nj82?file=/src/App.js) - -Marks can be dynamic: editable, removable, etc. via the `useMark` hook helper. - -#### Editable - -```tsx -import {MarkedInput, useMark} from 'rc-marked-input' - -const Mark = () => { - const {label, change} = useMark() - - const handleInput = e => change({label: e.currentTarget.textContent ?? '', value: ' '}, {silent: true}) - - return -} - -export const Dynamic = () => { - const [value, setValue] = useState('Hello, dynamical mark @[world]( )!') - return -} -``` - -> **Note:** The silent option used to prevent re-rendering itself. - -#### Removable - -```tsx -const RemovableMark = () => { - const {label, remove} = useMark() - return -} - -export const Removable = () => { - const [value, setValue] = useState('I @[contain]( ) @[removable]( ) by click @[marks]( )!') - return -} -``` - -#### Focusable - -If passed the `ref` prop of the `useMark` hook in ref of a component then it component can be focused by key operations. - -### Nested Marks - -Marked Input supports nested marks, allowing you to create rich, hierarchical text structures. Nested marks enable complex formatting scenarios like markdown-style text, HTML-like tags, and multi-level annotations. - -#### Enabling Nested Marks - -To enable nesting, use the `__nested__` placeholder in your markup pattern instead of `__value__`: - -```tsx -// ✅ Supports nesting -const NestedMarkup = '@[__nested__]' - -// ❌ Does not support nesting (plain text only) -const FlatMarkup = '@[__value__]' -``` - -**Key Differences:** - -- `__value__` - Content is treated as plain text, nested patterns are ignored -- `__nested__` - Content supports nested structures, nested patterns are parsed - -#### Simple Nesting Example - -```tsx -import {MarkedInput} from 'rc-marked-input' - -const NestedMark = ({children, style}: {value?: string; children?: ReactNode; style?: React.CSSProperties}) => ( - {children} -) - -const App = () => { - const [value, setValue] = useState('This is **bold with *italic* inside**') - - return ( - ({ - value, - children, - style: {fontWeight: 'bold'}, - }), - }, - { - markup: '*__nested__*', - slotProps: { mark: ({value, children}) => ({ - value, - children, - style: {fontStyle: 'italic'}, - }), - }, - ]} - /> - ) -} -``` - -#### HTML-like Tags with Two Values - -ParserV2 supports **two values** patterns where a markup contains two `__value__` placeholders that must match. This is perfect for HTML-like tags where opening and closing tags should be identical. - -```tsx -const HtmlLikeMark = ({children, value, nested}: {value?: string; children?: ReactNode; nested?: string}) => { - // Use value as HTML element name (e.g., "div", "span", "mark") - const Tag = value! as React.ElementType - return {children || nested} -} - -const App = () => { - const [value, setValue] = useState( - '
This is a div with a mark inside and bold text with nested del
' - ) - - return ( - __nested__'}, - ]} - /> - ) -} -``` - -**Two Values Pattern Rules:** - -- Contains exactly two `__value__` placeholders -- Both values must be identical (e.g., `
` and `
`) -- If values don't match, the pattern won't be recognized -- Perfect for HTML/XML-like structures where tags must match - -**Examples of valid two values patterns:** - -- `<__value__>__nested__` - HTML tags -- `[__value__]__nested__[/__value__]` - BBCode-style tags -- `{{__value__}}__nested__{{/__value__}}` - Template tags - -### Overlay - -A default overlay is the suggestion component, but it can be easily replaced for any other. - -#### Suggestions - -```tsx -export const DefaultOverlay = () => { - const [value, setValue] = useState('Hello, default - suggestion overlay by trigger @!') - return ( - - ) -} -``` - -#### Custom overlay · [![sandbox](https://user-images.githubusercontent.com/37639183/199624889-6129e303-6b44-4b82-859d-ada79942842c.svg)](https://codesandbox.io/s/custom-overlay-1m5ctx?file=/src/App.tsx) - -```tsx -const Overlay = () =>

I am the overlay

-export const CustomOverlay = () => { - const [value, setValue] = useState('Hello, custom overlay by trigger @!') - return -} -``` - -#### Custom trigger - -```tsx -export const CustomTrigger = () => { - const [value, setValue] = useState('Hello, custom overlay by trigger /!') - return ( - null} - Overlay={Overlay} - value={value} - onChange={setValue} - options={[{slotProps: {overlay: {trigger: '/'}}}]} - /> - ) -} -``` - -#### Positioned - -The `useOverlay` has a left and right absolute coordinate of a current caret position in the `style` prop. - -```tsx -const Tooltip = () => { - const {style} = useOverlay() - return
I am the overlay
-} -export const PositionedOverlay = () => { - const [value, setValue] = useState('Hello, positioned overlay by trigger @!') - return -} -``` - -#### Selectable - -The `useOverlay` hook provide some methods like `select` for creating a new annotation. - -```tsx -const List = () => { - const {select} = useOverlay() - return ( -
    -
  • select({label: 'First'})}>Clickable First
  • -
  • select({label: 'Second'})}>Clickable Second
  • -
- ) -} - -export const SelectableOverlay = () => { - const [value, setValue] = useState('Hello, suggest overlay by trigger @!') - return -} -``` - -> **Note:** Recommend to pass the `ref` for an overlay component. It used to detect outside click. - -### Slots - -The `slots` and `slotProps` props allow you to customize internal components with type safety and flexibility. - -#### Available Slots - -- **container** - Root div wrapper for the entire component -- **span** - Text span elements for rendering text tokens - -#### Basic Usage - -```tsx - console.log('onKeyDown'), - onFocus: e => console.log('onFocus'), - style: {border: '1px solid #ccc', padding: '8px'}, - }, - span: { - className: 'custom-text-span', - style: {fontSize: '14px'}, - }, - }} -/> -``` - -#### Custom Components - -You can also replace the default components entirely using the `slots` prop: - -```tsx -const CustomContainer = forwardRef>((props, ref) => ( -
-)) - -const CustomSpan = forwardRef>((props, ref) => ( - -)) - - -``` - -See the [MUI documentation](https://mui.com/material-ui/customization/overriding-component-structure/) for more information about the slots pattern. - -### Overall view - -```tsx - -``` - -Or - -```tsx -const MarkedInput = createMarkedInput({ - Mark, - Overlay, - options: [ - { - markup: '@[__label__](__value__)', - slotProps: { - mark: getCustomMarkProps, - overlay: {trigger: '@', data: Data}, - }, - }, - { - markup: '@(__label__)[__value__]', - slotProps: { - mark: getAnotherCustomMarkProps, - overlay: {trigger: '/', data: AnotherData}, - }, - }, - ], -}) - -const App = () => -``` - -## API - -### MarkedInput - -| Name | Type | Default | Description | -| ------------- | ---------------------------- | ------------- | ---------------------------------------------- | -| value | string | `undefined` | Annotated text with markups for mark | -| defaultValue | string | `undefined` | Default value | -| onChange | (value: string) => void | `undefined` | Change event | -| Mark | ComponentType | `undefined` | Component that used for render markups | -| Overlay | ComponentType | `Suggestions` | Component that is rendered by trigger | -| readOnly | boolean | `undefined` | Prevents from changing the value | -| options | OptionProps[] | `[{}]` | Passed options for configure | -| showOverlayOn | OverlayTrigger | `change` | Triggering events for overlay | -| slots | Slots | `undefined` | Override internal components (container, span) | -| slotProps | SlotProps | `undefined` | Props to pass to slot components | - -### Helpers - -| Name | Type | Description | -| ----------------- | ----------------------------------------------------------------------------------- | -------------------------------------------- | -| createMarkedInput | (configs: MarkedInputProps): ConfiguredMarkedInput | Create the configured MarkedInput component. | -| annotate | (markup: Markup, params: {value: string, meta?: string}) => string | Make annotation from the markup | -| denote | (value: string, callback: (mark: MarkToken) => string, markups: Markup[]) => string | Transform the annotated text | -| useMark | () => MarkHandler | Allow to use dynamic mark | -| useOverlay | () => OverlayHandler | Use overlay props | -| useListener | (type, listener, deps) => void | Event listener | - -### Types - -```typescript -type OverlayTrigger = Array<'change' | 'selectionChange'> | 'change' | 'selectionChange' | 'none' -``` - -```typescript -interface MarkToken { - type: 'mark' - content: string - position: {start: number; end: number} - descriptor: MarkupDescriptor - value: string - meta?: string - nested?: { - content: string - start: number - end: number - } - children: Token[] // Nested tokens (empty array if no nesting) -} - -interface TextToken { - type: 'text' - content: string - position: {start: number; end: number} -} - -interface MarkProps { - value?: string - meta?: string - nested?: string // Raw nested content as string - children?: ReactNode // Rendered nested content -} - -type Token = TextToken | MarkToken -``` - -```typescript -interface OverlayHandler { - /** - * Style with caret absolute position. Used for placing an overlay. - */ - style: { - left: number - top: number - } - /** - * Used for close overlay. - */ - close: () => void - /** - * Used for insert an annotation instead a triggered value. - */ - select: (value: {value: string; meta?: string}) => void - /** - * Overlay match details - */ - match: OverlayMatch - ref: RefObject -} -``` - -```typescript -interface MarkHandler { - /** - * MarkToken ref. Used for focusing and key handling operations. - */ - ref: RefObject - /** - * Change mark. - * @options.silent doesn't change itself value and meta, only pass change event. - */ - change: (props: {value: string; meta?: string}, options?: {silent: boolean}) => void - /** - * Remove itself. - */ - remove: () => void - /** - * Passed the readOnly prop value - */ - readOnly?: boolean - /** - * Nesting depth of this mark (0 for root-level marks) - */ - depth: number - /** - * Whether this mark has nested children - */ - hasChildren: boolean - /** - * Parent mark token (undefined for root-level marks) - */ - parent?: MarkToken - /** - * Array of child tokens (read-only) - */ - children: Token[] -} -``` - -```typescript -type OverlayMatch = { - /** - * Found value via a overlayMatch - */ - value: string - /** - * Triggered value - */ - source: string - /** - * Piece of text, in which was a overlayMatch - */ - span: string - /** - * Html element, in which was a overlayMatch - */ - node: Node - /** - * Start position of a overlayMatch - */ - index: number - /** - * OverlayMatch's option - */ - option: Option -} -``` - -```typescript jsx -export interface MarkProps { - value?: string - meta?: string - nested?: string - children?: ReactNode -} - -export interface OverlayProps { - trigger?: string - data?: string[] -} - -export interface Option { - /** - * Template string instead of which the mark is rendered. - * Must contain placeholders: `__value__`, `__meta__`, and/or `__nested__` - * - * Placeholder types: - * - `__value__` - main content (plain text, no nesting) - * - `__meta__` - additional metadata (plain text, no nesting) - * - `__nested__` - content supporting nested structures - * - * @default "@[__value__](__meta__)" - */ - markup?: Markup - /** - * Per-option slot components (mark and overlay). - * If not specified, falls back to global Mark/Overlay components. - * - * Component Resolution Priority (for each slot): - * 1. option.slots[slot] (per-option component) - * 2. MarkedInputProps[slot] (global component) - * 3. Default component (Suggestions for overlay, undefined for mark) - * - * This allows fine-grained control with global fallbacks. - */ - slots?: { - mark?: ComponentType - overlay?: ComponentType - } - /** - * Props for slot components. - */ - slotProps?: { - /** - * Props for the mark component. Can be either: - * - A static object that completely replaces MarkProps - * - A function that transforms MarkProps into component-specific props - * - * @example - * // Static object - * mark: { label: 'Click me', primary: true } - * - * @example - * // Function - * mark: ({ value, meta }) => ({ label: value, onClick: () => alert(meta) }) - */ - mark?: TMarkProps | ((props: MarkProps) => TMarkProps) - /** - * Props for the overlay component. Passed directly to the Overlay component. - * - * @example - * overlay: { - * trigger: '@', - * data: ['Alice', 'Bob'] - * } - */ - overlay?: TOverlayProps - } -} -``` - ## Contributing If you want to contribute, you are welcome! Create an issue or start a discussion. diff --git a/package.json b/package.json index 96ad2043..61337753 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "build:all": "pnpm -r run build", "storybook:dev": "pnpm -F sb run dev", "storybook:build": "pnpm -F sb run build", + "website:dev": "pnpm -F website run dev", + "website:build": "pnpm -F website run build", "clean:git": "git clean -xdf", "clean:git:dry": "git clean -xdn", "lint": "oxlint .", diff --git a/packages/core/styles.css b/packages/core/styles.css index 6b46d3fa..8e7b5d7a 100644 --- a/packages/core/styles.css +++ b/packages/core/styles.css @@ -12,7 +12,7 @@ max-height: 186px; overflow-y: auto; padding-left: 0; - position: absolute; + position: fixed; background: white; width: fit-content; min-width: 100px; diff --git a/packages/markput/src/components/MarkedInput.tsx b/packages/markput/src/components/MarkedInput.tsx index 5d64b55c..a1dfc58a 100644 --- a/packages/markput/src/components/MarkedInput.tsx +++ b/packages/markput/src/components/MarkedInput.tsx @@ -90,4 +90,7 @@ export const _MarkedInput = (props: MarkedInputProps, ref: ForwardedRef ex } } -export type ConfiguredMarkedInput = FunctionComponent< - MarkedInputProps -> +export interface ConfiguredMarkedInput + extends FunctionComponent> {} /** * Available slots for customizing MarkedInput internal components diff --git a/vercel.json b/packages/storybook/vercel.json similarity index 59% rename from vercel.json rename to packages/storybook/vercel.json index 6c2311b5..ec331ce2 100644 --- a/vercel.json +++ b/packages/storybook/vercel.json @@ -2,6 +2,6 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "vite", "installCommand": "pnpm install --frozen-lockfile", - "buildCommand": "pnpm run storybook:build", - "outputDirectory": "packages/storybook/dist" + "buildCommand": "pnpm run build", + "outputDirectory": "dist" } diff --git a/packages/website/.gitignore b/packages/website/.gitignore new file mode 100644 index 00000000..577f8131 --- /dev/null +++ b/packages/website/.gitignore @@ -0,0 +1,22 @@ +# build output +dist/ +.vercel/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/packages/website/.vscode/extensions.json b/packages/website/.vscode/extensions.json new file mode 100644 index 00000000..22a15055 --- /dev/null +++ b/packages/website/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/packages/website/.vscode/launch.json b/packages/website/.vscode/launch.json new file mode 100644 index 00000000..d6422097 --- /dev/null +++ b/packages/website/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/packages/website/README.md b/packages/website/README.md new file mode 100644 index 00000000..402f7698 --- /dev/null +++ b/packages/website/README.md @@ -0,0 +1,256 @@ +# Markput Documentation Website + +[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) + +Documentation site for Markput, built with [Astro](https://astro.build) and [Starlight](https://starlight.astro.build). + +## 📚 Tech Stack Overview + +### Astro Framework + +- **Server-first rendering**: Components render on the server, sending minimal JavaScript to the browser +- **Zero JavaScript by default**: Astro automatically removes unused JavaScript +- **Content Collections**: Type-safe content management with frontmatter validation +- **File-based routing**: Each file in `src/content/docs/` becomes a route +- **Multi-framework support**: Can use React, Vue, Svelte, or other frameworks alongside Astro components + +### Starlight Documentation Framework + +- **Built on Astro**: Inherits all Astro performance benefits +- **Documentation-focused**: Pre-configured navigation, search, SEO, and accessibility +- **i18n ready**: Built-in internationalization support +- **Dark mode**: Automatic light/dark theme switching +- **Markdown/MDX**: Supports `.md` and `.mdx` with component integration + +### Tailwind CSS + +- Utility-first CSS framework for custom styling +- Configured in `src/styles/global.css` + +## 🗂️ Project Structure + +``` +packages/website/ +├── public/ # Static assets (favicons, images) +├── src/ +│ ├── assets/ # Images and media files (optimized by Astro) +│ ├── content/ +│ │ └── docs/ # Documentation pages (.md/.mdx) +│ ├── styles/ +│ │ └── global.css # Tailwind configuration and global styles +│ └── content.config.ts # Content Collections schema +├── astro.config.mjs # Astro and Starlight configuration +├── package.json +└── tsconfig.json +``` + +## 🧞 Commands + +Run from the project root: + +| Command | Action | +| :--------------------- | :----------------------------------------------- | +| `pnpm install` | Installs dependencies | +| `pnpm dev` | Starts local dev server at `localhost:4321` | +| `pnpm build` | Build production site to `./dist/` | +| `pnpm preview` | Preview build locally before deploying | +| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` | +| `pnpm astro -- --help` | Get help using the Astro CLI | + +## 📝 Content Development Guidelines + +### Creating Documentation Pages + +1. **Location**: All docs go in `src/content/docs/` +2. **Format**: Use `.md` for simple pages, `.mdx` for pages with components +3. **Routing**: File path determines URL + - `src/content/docs/guide.md` → `/guide` + - `src/content/docs/api/overview.md` → `/api/overview` + +### Frontmatter Schema + +Every doc page should include frontmatter: + +```yaml +--- +title: Page Title +description: Brief description for SEO and previews +--- +``` + +Additional optional fields (configured in `content.config.ts`): + +- `sidebar`: Custom sidebar configuration +- `tableOfContents`: Control TOC visibility +- `editUrl`: Override edit link +- `lastUpdated`: Custom date override + +### Working with Images + +**Optimized images** (recommended): + +```md +![Alt text](../../assets/image.png) +``` + +- Stored in `src/assets/` +- Automatically optimized by Astro +- Responsive and format-converted + +**Static images**: + +```md +![Alt text](/static-image.png) +``` + +- Stored in `public/` +- Served as-is without optimization + +### Using MDX Components + +MDX allows importing and using components: + +```mdx +--- +title: Example +--- + +import MyComponent from '../../components/MyComponent.astro' + +# Content + + +``` + +## 🎨 Styling Guidelines + +### Tailwind CSS + +- Global styles: `src/styles/global.css` +- Use Tailwind utility classes in MDX: + ```mdx +
Styled content
+ ``` + +### Custom CSS + +Add custom styles in `global.css`: + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom styles */ +.custom-class { + /* ... */ +} +``` + +## ⚙️ Configuration + +### Astro Config (`astro.config.mjs`) + +Key configuration points: + +- **Starlight settings**: Site title, navigation, sidebar +- **Integrations**: Add UI frameworks or other integrations +- **Build settings**: Output directory, adapters for deployment + +### Content Collections (`content.config.ts`) + +- Define content schemas with Zod +- Add type-safety to frontmatter +- Validate content at build time + +## 🚀 Development Best Practices + +### 1. Server-First Mindset + +- Astro renders on the server by default +- Only add client-side JavaScript when necessary +- Use `client:*` directives sparingly for interactivity + +### 2. Content Organization + +- Group related pages in folders +- Use index.md for section overviews +- Keep file names URL-friendly (lowercase, hyphens) + +### 3. Performance + +- Prefer `src/assets/` for images (auto-optimization) +- Minimize client-side JavaScript +- Use Astro's View Transitions for smooth navigation + +### 4. Type Safety + +- Define content schemas in `content.config.ts` +- Use TypeScript for components +- Validate frontmatter at build time + +### 5. Navigation Structure + +- Configure sidebar in `astro.config.mjs` +- Use consistent heading hierarchy (H1 → H2 → H3) +- Include descriptions for better SEO + +## 🔧 Common Patterns + +### Adding a New Documentation Section + +1. Create folder in `src/content/docs/` +2. Add `index.md` as section overview +3. Add pages within the folder +4. Update sidebar in `astro.config.mjs` + +### Creating Reusable Components + +1. Create `.astro` component in `src/components/` +2. Import in MDX files +3. Pass props for customization + +### Customizing Starlight + +Starlight can be extended with: + +- Custom CSS in `global.css` +- Astro integrations +- Custom components overriding defaults +- Plugins for additional functionality + +## 📚 Resources + +- [Starlight Documentation](https://starlight.astro.build/) +- [Astro Documentation](https://docs.astro.build/) +- [Astro Discord](https://astro.build/chat) +- [Tailwind CSS Documentation](https://tailwindcss.com/docs) + +## 🎯 Quick Reference for AI Assistant + +When working on this project: + +1. **File Operations**: + - Docs: `src/content/docs/` (.md/.mdx files) + - Images: `src/assets/` (optimized) or `public/` (static) + - Config: `astro.config.mjs`, `content.config.ts` + - Styles: `src/styles/global.css` + +2. **Architecture**: + - Server-side rendering (SSR) by default + - Zero JS baseline, hydrate components only when needed + - Type-safe content collections + - File-based routing + +3. **Don't**: + - Add unnecessary client-side JavaScript + - Bypass content collections for documentation + - Ignore frontmatter schema validation + - Create routes outside `src/content/docs/` + +4. **Do**: + - Use Astro components for reusable UI + - Leverage content collections for type safety + - Optimize images through `src/assets/` + - Follow Starlight conventions for consistency + - Use Tailwind utilities for styling diff --git a/packages/website/astro.config.ts b/packages/website/astro.config.ts new file mode 100644 index 00000000..28b556b4 --- /dev/null +++ b/packages/website/astro.config.ts @@ -0,0 +1,102 @@ +import {defineConfig} from 'astro/config' +import starlight from '@astrojs/starlight' +import starlightTypeDoc, {typeDocSidebarGroup} from 'starlight-typedoc' +import tailwindcss from '@tailwindcss/vite' +import react from '@astrojs/react' +import vercel from '@astrojs/vercel' + +const isDev = import.meta.env.DEV +const wipBadge = {text: '🚧', class: 'border-none bg-transparent'} + +const sidebarConfig = [ + { + label: 'Introduction', + items: [ + {label: 'Why Markput?', slug: 'introduction/why-markput'}, + {label: 'Getting Started', slug: 'introduction/getting-started'}, + ], + }, + { + label: 'Guides', + items: [ + {label: 'Configuration', slug: 'guides/configuration', badge: wipBadge}, + {label: 'Dynamic Marks', slug: 'guides/dynamic-marks', badge: wipBadge}, + {label: 'Nested Marks', slug: 'guides/nested-marks', badge: wipBadge}, + {label: 'Overlay Customization', slug: 'guides/overlay-customization', badge: wipBadge}, + {label: 'Slots Customization', slug: 'guides/slots-customization', badge: wipBadge}, + {label: 'Keyboard Handling', slug: 'guides/keyboard-handling', badge: wipBadge}, + ], + }, + { + label: 'Examples', + items: [ + {label: 'Mention System', slug: 'examples/mention-system', badge: wipBadge}, + {label: 'Slash Commands', slug: 'examples/slash-commands', badge: wipBadge}, + {label: 'Hashtags', slug: 'examples/hashtags', badge: wipBadge}, + {label: 'Markdown Editor', slug: 'examples/markdown-editor', badge: wipBadge}, + {label: 'HTML-like Tags', slug: 'examples/html-like-tags', badge: wipBadge}, + {label: 'Autocomplete', slug: 'examples/autocomplete', badge: wipBadge}, + ], + }, + { + label: 'Development', + badge: wipBadge, + items: [ + {label: 'How It Works', slug: 'development/how-it-works'}, + {label: 'Architecture', slug: 'development/architecture'}, + {label: 'Performance', slug: 'development/performance'}, + {label: 'RFC: Nested Marks', slug: 'development/rfc-nested-marks'}, + ], + }, + typeDocSidebarGroup, + {label: 'Comparisons', slug: 'comparisons', badge: wipBadge}, +].filter(item => isDev || ('badge' in item && item.badge !== wipBadge)) + +// https://astro.build/config +export default defineConfig({ + adapter: vercel({ + imageService: true, + webAnalytics: {enabled: true}, + }), + integrations: [ + starlight({ + plugins: [ + starlightTypeDoc({ + entryPoints: ['../markput/index.ts'], + tsconfig: '../markput/tsconfig.json', + output: 'api', + watch: true, + sidebar: { + label: 'API', + collapsed: false, + }, + typeDoc: { + useCodeBlocks: true, + parametersFormat: 'table', + classPropertiesFormat: 'table', + interfacePropertiesFormat: 'list', + gitRevision: 'next', + }, + }), + ], + title: 'Markput', + lastUpdated: true, + editLink: { + baseUrl: 'https://github.com/Nowely/marked-input/edit/next/packages/website/src/content/docs', + }, + social: [ + { + label: 'GitHub', + href: 'https://github.com/Nowely/marked-input', + icon: 'github', + }, + ], + sidebar: sidebarConfig, + customCss: ['./src/styles/global.css'], + }), + react(), + ], + vite: { + plugins: [tailwindcss()], + }, +}) diff --git a/packages/website/package.json b/packages/website/package.json new file mode 100644 index 00000000..77a63f31 --- /dev/null +++ b/packages/website/package.json @@ -0,0 +1,28 @@ +{ + "name": "website", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/react": "^4.4.2", + "@astrojs/starlight": "^0.36.2", + "@astrojs/starlight-tailwind": "^4.0.2", + "@astrojs/vercel": "^9.0.2", + "@tailwindcss/vite": "^4.1.17", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "astro": "^5.16.0", + "rc-marked-input": "workspace:*", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sharp": "^0.34.5", + "starlight-typedoc": "^0.21.4", + "tailwindcss": "^4.1.17" + } +} \ No newline at end of file diff --git a/packages/website/public/favicon.svg b/packages/website/public/favicon.svg new file mode 100644 index 00000000..cba5ac14 --- /dev/null +++ b/packages/website/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/website/src/assets/houston.webp b/packages/website/src/assets/houston.webp new file mode 100644 index 00000000..930c1649 Binary files /dev/null and b/packages/website/src/assets/houston.webp differ diff --git a/packages/website/src/components/demos/CodePreview.astro b/packages/website/src/components/demos/CodePreview.astro new file mode 100644 index 00000000..c41f003f --- /dev/null +++ b/packages/website/src/components/demos/CodePreview.astro @@ -0,0 +1,71 @@ +--- +import { Tabs, TabItem, Code } from '@astrojs/starlight/components'; + +interface Props { + code: string; +} + +const { code } = Astro.props; +--- + + + + + + +
+ +
+
+
+ + diff --git a/packages/website/src/components/demos/Step1Demo.tsx b/packages/website/src/components/demos/Step1Demo.tsx new file mode 100644 index 00000000..054a30d5 --- /dev/null +++ b/packages/website/src/components/demos/Step1Demo.tsx @@ -0,0 +1,8 @@ +import {MarkedInput} from 'rc-marked-input' + +export const Step1Demo = () => ( + alert(meta)}>{value}} + defaultValue="Hello @[World](123)!" + /> +) diff --git a/packages/website/src/components/demos/Step2Demo.tsx b/packages/website/src/components/demos/Step2Demo.tsx new file mode 100644 index 00000000..c4377f57 --- /dev/null +++ b/packages/website/src/components/demos/Step2Demo.tsx @@ -0,0 +1,18 @@ +import {MarkedInput} from 'rc-marked-input' + +export const Step2Demo = () => ( + +) diff --git a/packages/website/src/components/demos/Step3Demo.tsx b/packages/website/src/components/demos/Step3Demo.tsx new file mode 100644 index 00000000..b0df24f8 --- /dev/null +++ b/packages/website/src/components/demos/Step3Demo.tsx @@ -0,0 +1,49 @@ +import {MarkedInput, useOverlay} from 'rc-marked-input' +import {useState, useEffect, type RefObject} from 'react' + +const User = ({avatar, login, onClick}: {avatar?: string; login?: string; onClick?: () => void}) => ( + + + {login} + +) + +const CustomOverlay = () => { + const {select, match, style, ref} = useOverlay() + const [users, setUsers] = useState<{login: string; avatar_url: string}[]>([]) + + useEffect(() => { + if (!match.value) return + fetch(`https://api.github.com/search/users?q=${match.value}`) + .then(res => res.json()) + .then(data => setUsers(data.items?.slice(0, 10) || [])) + }, [match.value]) + + useEffect(() => ref.current?.showPopover(), []) + + return ( +
} + popover="auto" + style={{top: style.top, left: style.left}} + className="border border-gray-200 shadow-md" + > + {users?.map(user => ( + select({value: user.login, meta: user.avatar_url})} + /> + ))} +
+ ) +} + +export const Step3Demo = () => ( + } + Overlay={CustomOverlay} + /> +) diff --git a/packages/website/src/content.config.ts b/packages/website/src/content.config.ts new file mode 100644 index 00000000..e44a29f3 --- /dev/null +++ b/packages/website/src/content.config.ts @@ -0,0 +1,7 @@ +import {defineCollection} from 'astro:content' +import {docsLoader} from '@astrojs/starlight/loaders' +import {docsSchema} from '@astrojs/starlight/schema' + +export const collections = { + docs: defineCollection({loader: docsLoader(), schema: docsSchema()}), +} diff --git a/packages/website/src/content/docs/api/README.md b/packages/website/src/content/docs/api/README.md new file mode 100644 index 00000000..83aef386 --- /dev/null +++ b/packages/website/src/content/docs/api/README.md @@ -0,0 +1,35 @@ +--- +editUrl: false +next: false +prev: false +title: "rc-marked-input" +--- + +## Interfaces + +- [ConfiguredMarkedInput](/api/interfaces/configuredmarkedinput/) +- [MarkedInputComponent](/api/interfaces/markedinputcomponent/) +- [MarkedInputHandler](/api/interfaces/markedinputhandler/) +- [MarkedInputProps](/api/interfaces/markedinputprops/) +- [MarkHandler](/api/interfaces/markhandler/) +- [MarkProps](/api/interfaces/markprops/) +- [MarkToken](/api/interfaces/marktoken/) +- [Option](/api/interfaces/option/) +- [OverlayHandler](/api/interfaces/overlayhandler/) +- [OverlayProps](/api/interfaces/overlayprops/) +- [TextToken](/api/interfaces/texttoken/) + +## Type Aliases + +- [Markup](/api/type-aliases/markup/) +- [Token](/api/type-aliases/token/) + +## Functions + +- [annotate](/api/functions/annotate/) +- [createMarkedInput](/api/functions/createmarkedinput/) +- [denote](/api/functions/denote/) +- [MarkedInput](/api/functions/markedinput/) +- [useListener](/api/functions/uselistener/) +- [useMark](/api/functions/usemark/) +- [useOverlay](/api/functions/useoverlay/) diff --git a/packages/website/src/content/docs/api/functions/MarkedInput.md b/packages/website/src/content/docs/api/functions/MarkedInput.md new file mode 100644 index 00000000..409c2657 --- /dev/null +++ b/packages/website/src/content/docs/api/functions/MarkedInput.md @@ -0,0 +1,29 @@ +--- +editUrl: false +next: false +prev: false +title: "MarkedInput" +--- + +```ts +function MarkedInput(props): Element | null; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:96](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L96) + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TMarkProps` | `any` | +| `TOverlayProps` | [`OverlayProps`](/api/interfaces/overlayprops/) | + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `props` | [`MarkedInputProps`](/api/interfaces/markedinputprops/)\<`TMarkProps`, `TOverlayProps`\> | + +## Returns + +`Element` \| `null` diff --git a/packages/website/src/content/docs/api/functions/annotate.md b/packages/website/src/content/docs/api/functions/annotate.md new file mode 100644 index 00000000..d825100a --- /dev/null +++ b/packages/website/src/content/docs/api/functions/annotate.md @@ -0,0 +1,38 @@ +--- +editUrl: false +next: false +prev: false +title: "annotate" +--- + +```ts +function annotate(markup, params): string; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/utils/annotate.ts:18](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/utils/annotate.ts#L18) + +Make annotation from the markup for ParserV2 + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `markup` | [`Markup`](/api/type-aliases/markup/) | Markup pattern with __value__, __meta__, and/or __nested__ placeholders | +| `params` | \{ `meta?`: `string`; `nested?`: `string`; `value?`: `string`; \} | Object with optional value, meta, and nested strings | +| `params.meta?` | `string` | - | +| `params.nested?` | `string` | - | +| `params.value?` | `string` | - | + +## Returns + +`string` + +Annotated string with placeholders replaced + +## Example + +```typescript +annotate('@[__value__]', { value: 'Hello' }) // '@[Hello]' +annotate('@[__value__](__meta__)', { value: 'Hello', meta: 'world' }) // '@[Hello](world)' +annotate('@[__nested__]', { nested: 'content' }) // '@[content]' +``` diff --git a/packages/website/src/content/docs/api/functions/createMarkedInput.md b/packages/website/src/content/docs/api/functions/createMarkedInput.md new file mode 100644 index 00000000..1e56ed68 --- /dev/null +++ b/packages/website/src/content/docs/api/functions/createMarkedInput.md @@ -0,0 +1,31 @@ +--- +editUrl: false +next: false +prev: false +title: "createMarkedInput" +--- + +```ts +function createMarkedInput(configs): ConfiguredMarkedInput; +``` + +Defined in: [packages/markput/src/utils/functions/createMarkedInput.ts:13](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/functions/createMarkedInput.ts#L13) + +Create the configured MarkedInput component. + +## Type Parameters + +| Type Parameter | Default type | Description | +| ------ | ------ | ------ | +| `TMarkProps` | [`MarkProps`](/api/interfaces/markprops/) | Type of props for the Mark component (default: MarkProps) | +| `TOverlayProps` | [`OverlayProps`](/api/interfaces/overlayprops/) | Type of props for the Overlay component (default: OverlayProps) | + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `configs` | `Omit`\<[`MarkedInputProps`](/api/interfaces/markedinputprops/)\<`TMarkProps`, `TOverlayProps`\>, `"value"` \| `"onChange"`\> | + +## Returns + +[`ConfiguredMarkedInput`](/api/interfaces/configuredmarkedinput/)\<`TMarkProps`, `TOverlayProps`\> diff --git a/packages/website/src/content/docs/api/functions/denote.md b/packages/website/src/content/docs/api/functions/denote.md new file mode 100644 index 00000000..5934d967 --- /dev/null +++ b/packages/website/src/content/docs/api/functions/denote.md @@ -0,0 +1,39 @@ +--- +editUrl: false +next: false +prev: false +title: "denote" +--- + +```ts +function denote( + value, + callback, + markups): string; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/utils/denote.ts:20](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/utils/denote.ts#L20) + +Transform annotated text to another text by recursively processing all tokens + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `value` | `string` | Annotated text to process | +| `callback` | (`mark`) => `string` | Function to transform each MarkToken | +| `markups` | [`Markup`](/api/type-aliases/markup/)[] | Array of markup patterns to parse | + +## Returns + +`string` + +Transformed text + +## Example + +```typescript +const text = '@[Hello](world) and #[nested @[content]]' +const result = denote(text, mark => mark.value, ['@[__value__](__meta__)', '#[__nested__]']) +// Returns: 'Hello and nested content' +``` diff --git a/packages/website/src/content/docs/api/functions/useListener.md b/packages/website/src/content/docs/api/functions/useListener.md new file mode 100644 index 00000000..18e6229b --- /dev/null +++ b/packages/website/src/content/docs/api/functions/useListener.md @@ -0,0 +1,64 @@ +--- +editUrl: false +next: false +prev: false +title: "useListener" +--- + +## Call Signature + +```ts +function useListener( + key, + listener, + deps?): void; +``` + +Defined in: [packages/markput/src/utils/hooks/useListener.tsx:7](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useListener.tsx#L7) + +### Type Parameters + +| Type Parameter | +| ------ | +| `T` | + +### Parameters + +| Parameter | Type | +| ------ | ------ | +| `key` | `EventKey`\<`T`\> | +| `listener` | `Listener`\<`T`\> | +| `deps?` | `DependencyList` | + +### Returns + +`void` + +## Call Signature + +```ts +function useListener( + key, + listener, + deps?): void; +``` + +Defined in: [packages/markput/src/utils/hooks/useListener.tsx:8](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useListener.tsx#L8) + +### Type Parameters + +| Type Parameter | +| ------ | +| `K` *extends* keyof `HTMLElementEventMap` | + +### Parameters + +| Parameter | Type | +| ------ | ------ | +| `key` | `K` | +| `listener` | `Listener`\<`HTMLElementEventMap`\[`K`\]\> | +| `deps?` | `DependencyList` | + +### Returns + +`void` diff --git a/packages/website/src/content/docs/api/functions/useMark.md b/packages/website/src/content/docs/api/functions/useMark.md new file mode 100644 index 00000000..ee81d9c4 --- /dev/null +++ b/packages/website/src/content/docs/api/functions/useMark.md @@ -0,0 +1,28 @@ +--- +editUrl: false +next: false +prev: false +title: "useMark" +--- + +```ts +function useMark(options): MarkHandler; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:62](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L62) + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `T` *extends* `HTMLElement` | `HTMLElement` | + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `options` | `MarkOptions` | + +## Returns + +[`MarkHandler`](/api/interfaces/markhandler/)\<`T`\> diff --git a/packages/website/src/content/docs/api/functions/useOverlay.md b/packages/website/src/content/docs/api/functions/useOverlay.md new file mode 100644 index 00000000..6aa994d9 --- /dev/null +++ b/packages/website/src/content/docs/api/functions/useOverlay.md @@ -0,0 +1,16 @@ +--- +editUrl: false +next: false +prev: false +title: "useOverlay" +--- + +```ts +function useOverlay(): OverlayHandler; +``` + +Defined in: [packages/markput/src/utils/hooks/useOverlay.tsx:31](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useOverlay.tsx#L31) + +## Returns + +[`OverlayHandler`](/api/interfaces/overlayhandler/) diff --git a/packages/website/src/content/docs/api/interfaces/ConfiguredMarkedInput.md b/packages/website/src/content/docs/api/interfaces/ConfiguredMarkedInput.md new file mode 100644 index 00000000..10322556 --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/ConfiguredMarkedInput.md @@ -0,0 +1,164 @@ +--- +editUrl: false +next: false +prev: false +title: "ConfiguredMarkedInput" +--- + +Defined in: [packages/markput/src/types.ts:74](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L74) + +## Extends + +- `FunctionComponent`\<[`MarkedInputProps`](/api/interfaces/markedinputprops/)\<`TMarkProps`, `TOverlayProps`\>\> + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TMarkProps` | [`MarkProps`](/api/interfaces/markprops/) | +| `TOverlayProps` | [`OverlayProps`](/api/interfaces/overlayprops/) | + +```ts +ConfiguredMarkedInput(props, deprecatedLegacyContext?): ReactNode; +``` + +Defined in: [packages/markput/src/types.ts:74](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L74) + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `props` | [`MarkedInputProps`](/api/interfaces/markedinputprops/) | - | +| `deprecatedLegacyContext?` | `any` | **See** [React Docs](https://legacy.reactjs.org/docs/legacy-context.html#referencing-context-in-lifecycle-methods) :::caution[Deprecated] This API is no longer supported and may be removed in a future release. ::: | + +## Returns + +`ReactNode` + +## Properties + +### ~~contextTypes?~~ + +```ts +optional contextTypes: ValidationMap; +``` + +Defined in: node\_modules/.pnpm/@types+react@18.3.24/node\_modules/@types/react/index.d.ts:1156 + +:::caution[Deprecated] +Lets you specify which legacy context is consumed by +this component. +::: + +#### See + +[Legacy React Docs](https://legacy.reactjs.org/docs/legacy-context.html) + +#### Inherited from + +```ts +FunctionComponent.contextTypes +``` + +*** + +### ~~defaultProps?~~ + +```ts +optional defaultProps: Partial>; +``` + +Defined in: node\_modules/.pnpm/@types+react@18.3.24/node\_modules/@types/react/index.d.ts:1179 + +Used to define default values for the props accepted by +the component. + +:::caution[Deprecated] +Use [values for destructuring assignments instead](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#default_value|default). +::: + +#### See + +[React Docs](https://react.dev/reference/react/Component#static-defaultprops) + +#### Example + +```tsx +type Props = { name?: string } + +const MyComponent: FC = (props) => { + return
{props.name}
+} + +MyComponent.defaultProps = { + name: 'John Doe' +} +``` + +#### Inherited from + +```ts +FunctionComponent.defaultProps +``` + +*** + +### displayName? + +```ts +optional displayName: string; +``` + +Defined in: node\_modules/.pnpm/@types+react@18.3.24/node\_modules/@types/react/index.d.ts:1198 + +Used in debugging messages. You might want to set it +explicitly if you want to display a different name for +debugging purposes. + +#### See + +[Legacy React Docs](https://legacy.reactjs.org/docs/react-component.html#displayname) + +#### Example + +```tsx + +const MyComponent: FC = () => { + return
Hello!
+} + +MyComponent.displayName = 'MyAwesomeComponent' +``` + +#### Inherited from + +```ts +FunctionComponent.displayName +``` + +*** + +### propTypes? + +```ts +optional propTypes: WeakValidationMap>; +``` + +Defined in: node\_modules/.pnpm/@types+react@18.3.24/node\_modules/@types/react/index.d.ts:1147 + +Used to declare the types of the props accepted by the +component. These types will be checked during rendering +and in development only. + +We recommend using TypeScript instead of checking prop +types at runtime. + +#### See + +[React Docs](https://react.dev/reference/react/Component#static-proptypes) + +#### Inherited from + +```ts +FunctionComponent.propTypes +``` diff --git a/packages/website/src/content/docs/api/interfaces/MarkHandler.md b/packages/website/src/content/docs/api/interfaces/MarkHandler.md new file mode 100644 index 00000000..ce4aa806 --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/MarkHandler.md @@ -0,0 +1,174 @@ +--- +editUrl: false +next: false +prev: false +title: "MarkHandler" +--- + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:13](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L13) + +## Extends + +- `MarkStruct` + +## Type Parameters + +| Type Parameter | +| ------ | +| `T` | + +## Properties + +### change() + +```ts +change: (props, options?) => void; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:23](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L23) + +Change mark. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `props` | `MarkStruct` | - | +| `options?` | \{ `silent`: `boolean`; \} | The options object | +| `options.silent?` | `boolean` | If true, doesn't change itself label and value, only pass change event. | + +#### Returns + +`void` + +*** + +### children + +```ts +children: Token[]; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:51](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L51) + +Array of child tokens (read-only) + +*** + +### depth + +```ts +depth: number; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:39](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L39) + +Nesting depth of this mark (0 for root-level marks) + +*** + +### hasChildren + +```ts +hasChildren: boolean; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:43](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L43) + +Whether this mark has nested children + +*** + +### label + +```ts +label: string; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:9](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L9) + +#### Inherited from + +```ts +MarkStruct.label +``` + +*** + +### meta? + +```ts +optional meta: string; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:35](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L35) + +Meta value of the mark + +*** + +### parent? + +```ts +optional parent: MarkToken; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:47](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L47) + +Parent mark token (undefined for root-level marks) + +*** + +### readOnly? + +```ts +optional readOnly: boolean; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:31](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L31) + +Passed the readOnly prop value + +*** + +### ref + +```ts +ref: RefObject; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:17](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L17) + +MarkStruct ref. Used for focusing and key handling operations. + +*** + +### remove() + +```ts +remove: () => void; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:27](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L27) + +Remove itself. + +#### Returns + +`void` + +*** + +### value? + +```ts +optional value: string; +``` + +Defined in: [packages/markput/src/utils/hooks/useMark.ts:10](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useMark.ts#L10) + +#### Inherited from + +```ts +MarkStruct.value +``` diff --git a/packages/website/src/content/docs/api/interfaces/MarkProps.md b/packages/website/src/content/docs/api/interfaces/MarkProps.md new file mode 100644 index 00000000..3ed7c689 --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/MarkProps.md @@ -0,0 +1,58 @@ +--- +editUrl: false +next: false +prev: false +title: "MarkProps" +--- + +Defined in: [packages/markput/src/types.ts:13](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L13) + +Simplified props passed to Mark components via slotProps + +## Properties + +### children? + +```ts +optional children: ReactNode; +``` + +Defined in: [packages/markput/src/types.ts:21](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L21) + +Rendered children content (ReactNode) for nested marks + +*** + +### meta? + +```ts +optional meta: string; +``` + +Defined in: [packages/markput/src/types.ts:17](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L17) + +Additional metadata for the mark + +*** + +### nested? + +```ts +optional nested: string; +``` + +Defined in: [packages/markput/src/types.ts:19](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L19) + +Nested content as string (raw, unparsed) + +*** + +### value? + +```ts +optional value: string; +``` + +Defined in: [packages/markput/src/types.ts:15](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L15) + +Main content value of the mark diff --git a/packages/website/src/content/docs/api/interfaces/MarkToken.md b/packages/website/src/content/docs/api/interfaces/MarkToken.md new file mode 100644 index 00000000..7860278e --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/MarkToken.md @@ -0,0 +1,118 @@ +--- +editUrl: false +next: false +prev: false +title: "MarkToken" +--- + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:15](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L15) + +## Properties + +### children + +```ts +children: Token[]; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:30](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L30) + +*** + +### content + +```ts +content: string; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:17](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L17) + +*** + +### descriptor + +```ts +descriptor: MarkupDescriptor; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:22](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L22) + +*** + +### meta? + +```ts +optional meta: string; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:24](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L24) + +*** + +### nested? + +```ts +optional nested: object; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:25](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L25) + +#### content + +```ts +content: string; +``` + +#### end + +```ts +end: number; +``` + +#### start + +```ts +start: number; +``` + +*** + +### position + +```ts +position: object; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:18](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L18) + +#### end + +```ts +end: number; +``` + +#### start + +```ts +start: number; +``` + +*** + +### type + +```ts +type: "mark"; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:16](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L16) + +*** + +### value + +```ts +value: string; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:23](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L23) diff --git a/packages/website/src/content/docs/api/interfaces/MarkedInputComponent.md b/packages/website/src/content/docs/api/interfaces/MarkedInputComponent.md new file mode 100644 index 00000000..0e4b2788 --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/MarkedInputComponent.md @@ -0,0 +1,41 @@ +--- +editUrl: false +next: false +prev: false +title: "MarkedInputComponent" +--- + +Defined in: [packages/markput/src/components/MarkedInput.tsx:75](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L75) + +```ts +MarkedInputComponent(props): Element | null; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:76](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L76) + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TMarkProps` | `any` | +| `TOverlayProps` | [`OverlayProps`](/api/interfaces/overlayprops/) | + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `props` | [`MarkedInputProps`](/api/interfaces/markedinputprops/)\<`TMarkProps`, `TOverlayProps`\> | + +## Returns + +`Element` \| `null` + +## Properties + +### displayName? + +```ts +optional displayName: string; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:80](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L80) diff --git a/packages/website/src/content/docs/api/interfaces/MarkedInputHandler.md b/packages/website/src/content/docs/api/interfaces/MarkedInputHandler.md new file mode 100644 index 00000000..db1271b7 --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/MarkedInputHandler.md @@ -0,0 +1,46 @@ +--- +editUrl: false +next: false +prev: false +title: "MarkedInputHandler" +--- + +Defined in: [packages/markput/src/types.ts:102](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L102) + +## Properties + +### container + +```ts +readonly container: HTMLDivElement | null; +``` + +Defined in: [packages/markput/src/types.ts:104](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L104) + +Container element + +*** + +### overlay + +```ts +readonly overlay: HTMLElement | null; +``` + +Defined in: [packages/markput/src/types.ts:106](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L106) + +Overlay element if exists + +## Methods + +### focus() + +```ts +focus(): void; +``` + +Defined in: [packages/markput/src/types.ts:108](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L108) + +#### Returns + +`void` diff --git a/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md b/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md new file mode 100644 index 00000000..5ffc2d0d --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/MarkedInputProps.md @@ -0,0 +1,274 @@ +--- +editUrl: false +next: false +prev: false +title: "MarkedInputProps" +--- + +Defined in: [packages/markput/src/components/MarkedInput.tsx:38](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L38) + +Props for MarkedInput component with hierarchical type support. + +Type parameters: +- `TMarkProps` - Type of props for the global Mark component (default: MarkProps) +- `TOverlayProps` - Type of props for the global Overlay component (default: OverlayProps) + +The global Mark and Overlay components serve as defaults when options don't specify +their own slot components. Each option can override these with option.slots. + +Default types: +- TMarkProps = MarkProps: Type-safe base props (value, meta, nested, children) +- TOverlayProps = OverlayProps: Type-safe overlay props (trigger, data) + +## Example + +```typescript +// Using global Mark component with custom props type +interface ButtonProps { label: string; onClick: () => void } + + Mark={Button} + options={[{ + markup: '@[__value__]', + slotProps: { mark: { label: 'Click me', onClick: () => {} } } + }]} +/> +``` + +## Extends + +- `CoreMarkputProps` + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TMarkProps` | [`MarkProps`](/api/interfaces/markprops/) | +| `TOverlayProps` | [`OverlayProps`](/api/interfaces/overlayprops/) | + +## Properties + +### className? + +```ts +optional className: string; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:53](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L53) + +Additional classes + +*** + +### defaultValue? + +```ts +optional defaultValue: string; +``` + +Defined in: [packages/core/src/shared/types.ts:46](https://github.com/Nowely/marked-input/blob/next/packages/core/src/shared/types.ts#L46) + +Default value + +#### Inherited from + +```ts +CoreMarkputProps.defaultValue +``` + +*** + +### Mark? + +```ts +optional Mark: ComponentType; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:42](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L42) + +Global component used for rendering markups (fallback for option.slots.mark) + +*** + +### onChange()? + +```ts +optional onChange: (value) => void; +``` + +Defined in: [packages/core/src/shared/types.ts:48](https://github.com/Nowely/marked-input/blob/next/packages/core/src/shared/types.ts#L48) + +Change event handler + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `value` | `string` | + +#### Returns + +`void` + +#### Inherited from + +```ts +CoreMarkputProps.onChange +``` + +*** + +### options? + +```ts +optional options: Option[]; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:51](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L51) + +Configuration options for markups and overlays. +Each option can specify its own slot components and props via option.slots and option.slotProps. +Falls back to global Mark/Overlay components when not specified. + +#### Default + +```ts +[{overlayTrigger: '@', markup: '@[__label__](__value__)', data: []}] +``` + +#### Overrides + +```ts +CoreMarkputProps.options +``` + +*** + +### Overlay? + +```ts +optional Overlay: ComponentType; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:44](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L44) + +Global component used for rendering overlays like suggestions, mentions, etc (fallback for option.slots.overlay) + +*** + +### readOnly? + +```ts +optional readOnly: boolean; +``` + +Defined in: [packages/core/src/shared/types.ts:50](https://github.com/Nowely/marked-input/blob/next/packages/core/src/shared/types.ts#L50) + +Prevents from changing the value + +#### Inherited from + +```ts +CoreMarkputProps.readOnly +``` + +*** + +### ref? + +```ts +optional ref: ForwardedRef; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:40](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L40) + +Ref to handler + +*** + +### showOverlayOn? + +```ts +optional showOverlayOn: OverlayTrigger; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:72](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L72) + +Events that trigger overlay display + +#### Default + +```ts +'change' +``` + +#### Overrides + +```ts +CoreMarkputProps.showOverlayOn +``` + +*** + +### slotProps? + +```ts +optional slotProps: SlotProps; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:67](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L67) + +Props to pass to slot components + +#### Example + +```ts +slotProps={{ container: { onKeyDown: handler }, span: { className: 'custom' } }} +``` + +*** + +### slots? + +```ts +optional slots: Slots; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:61](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L61) + +Override internal components using slots + +#### Example + +```ts +slots={{ container: 'div', span: 'span' }} +``` + +*** + +### style? + +```ts +optional style: CSSProperties; +``` + +Defined in: [packages/markput/src/components/MarkedInput.tsx:55](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/components/MarkedInput.tsx#L55) + +Additional style + +*** + +### value? + +```ts +optional value: string; +``` + +Defined in: [packages/core/src/shared/types.ts:44](https://github.com/Nowely/marked-input/blob/next/packages/core/src/shared/types.ts#L44) + +Annotated text with markups for mark + +#### Inherited from + +```ts +CoreMarkputProps.value +``` diff --git a/packages/website/src/content/docs/api/interfaces/Option.md b/packages/website/src/content/docs/api/interfaces/Option.md new file mode 100644 index 00000000..a0447a19 --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/Option.md @@ -0,0 +1,129 @@ +--- +editUrl: false +next: false +prev: false +title: "Option" +--- + +Defined in: [packages/markput/src/types.ts:48](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L48) + +React-specific markup option for defining mark behavior and styling. + +## Example + +```ts +const option: Option = { + markup: '@[__value__]', + slots: { mark: Button }, + slotProps: { mark: { label: 'Click' } } +} +``` + +## Extends + +- `CoreOption` + +## Type Parameters + +| Type Parameter | Default type | +| ------ | ------ | +| `TMarkProps` | [`MarkProps`](/api/interfaces/markprops/) | +| `TOverlayProps` | [`OverlayProps`](/api/interfaces/overlayprops/) | + +## Properties + +### markup? + +```ts +optional markup: Markup; +``` + +Defined in: [packages/core/src/shared/types.ts:35](https://github.com/Nowely/marked-input/blob/next/packages/core/src/shared/types.ts#L35) + +Template string in which the mark is rendered. +Must contain placeholders: `__value__`, `__meta__`, and/or `__nested__` + +Placeholder types: +- `__value__` - main content (plain text, no nesting) +- `__meta__` - additional metadata (plain text, no nesting) +- `__nested__` - content supporting nested structures + +#### Examples + +```ts +// Simple value +"@[__value__]" +``` + +```ts +// Value with metadata +"@[__value__](__meta__)" +``` + +```ts +// Nested content support +"@[__nested__]" +``` + +#### Inherited from + +```ts +CoreOption.markup +``` + +*** + +### slotProps? + +```ts +optional slotProps: object; +``` + +Defined in: [packages/markput/src/types.ts:61](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L61) + +Props for slot components. + +#### mark? + +```ts +optional mark: TMarkProps | (props) => TMarkProps; +``` + +Props for the mark component. +Can be a static object or a function that transforms MarkProps. + +#### overlay? + +```ts +optional overlay: TOverlayProps; +``` + +Props for the overlay component. + +*** + +### slots? + +```ts +optional slots: object; +``` + +Defined in: [packages/markput/src/types.ts:52](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L52) + +Per-option slot components. + +#### mark? + +```ts +optional mark: ComponentType; +``` + +Mark component for this option. + +#### overlay? + +```ts +optional overlay: ComponentType; +``` + +Overlay component for this option. diff --git a/packages/website/src/content/docs/api/interfaces/OverlayHandler.md b/packages/website/src/content/docs/api/interfaces/OverlayHandler.md new file mode 100644 index 00000000..b912ffd0 --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/OverlayHandler.md @@ -0,0 +1,94 @@ +--- +editUrl: false +next: false +prev: false +title: "OverlayHandler" +--- + +Defined in: [packages/markput/src/utils/hooks/useOverlay.tsx:8](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useOverlay.tsx#L8) + +## Properties + +### close() + +```ts +close: () => void; +``` + +Defined in: [packages/markput/src/utils/hooks/useOverlay.tsx:19](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useOverlay.tsx#L19) + +Used for close overlay. + +#### Returns + +`void` + +*** + +### match + +```ts +match: OverlayMatch>; +``` + +Defined in: [packages/markput/src/utils/hooks/useOverlay.tsx:27](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useOverlay.tsx#L27) + +Overlay match details + +*** + +### ref + +```ts +ref: RefObject; +``` + +Defined in: [packages/markput/src/utils/hooks/useOverlay.tsx:28](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useOverlay.tsx#L28) + +*** + +### select() + +```ts +select: (value) => void; +``` + +Defined in: [packages/markput/src/utils/hooks/useOverlay.tsx:23](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useOverlay.tsx#L23) + +Used for insert an annotation instead a triggered value. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `value` | \{ `meta?`: `string`; `value`: `string`; \} | +| `value.meta?` | `string` | +| `value.value` | `string` | + +#### Returns + +`void` + +*** + +### style + +```ts +style: object; +``` + +Defined in: [packages/markput/src/utils/hooks/useOverlay.tsx:12](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/utils/hooks/useOverlay.tsx#L12) + +Style with caret absolute position. Used for placing an overlay. + +#### left + +```ts +left: number; +``` + +#### top + +```ts +top: number; +``` diff --git a/packages/website/src/content/docs/api/interfaces/OverlayProps.md b/packages/website/src/content/docs/api/interfaces/OverlayProps.md new file mode 100644 index 00000000..ae5bf753 --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/OverlayProps.md @@ -0,0 +1,34 @@ +--- +editUrl: false +next: false +prev: false +title: "OverlayProps" +--- + +Defined in: [packages/markput/src/types.ts:27](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L27) + +Default props for Overlay components via slotProps. + +## Properties + +### data? + +```ts +optional data: string[]; +``` + +Defined in: [packages/markput/src/types.ts:31](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L31) + +Data array for suggestions/autocomplete + +*** + +### trigger? + +```ts +optional trigger: string; +``` + +Defined in: [packages/markput/src/types.ts:29](https://github.com/Nowely/marked-input/blob/next/packages/markput/src/types.ts#L29) + +Trigger character(s) that activate the overlay diff --git a/packages/website/src/content/docs/api/interfaces/TextToken.md b/packages/website/src/content/docs/api/interfaces/TextToken.md new file mode 100644 index 00000000..b0a28e4c --- /dev/null +++ b/packages/website/src/content/docs/api/interfaces/TextToken.md @@ -0,0 +1,50 @@ +--- +editUrl: false +next: false +prev: false +title: "TextToken" +--- + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:6](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L6) + +## Properties + +### content + +```ts +content: string; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:8](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L8) + +*** + +### position + +```ts +position: object; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:9](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L9) + +#### end + +```ts +end: number; +``` + +#### start + +```ts +start: number; +``` + +*** + +### type + +```ts +type: "text"; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:7](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L7) diff --git a/packages/website/src/content/docs/api/type-aliases/Markup.md b/packages/website/src/content/docs/api/type-aliases/Markup.md new file mode 100644 index 00000000..5103aa8d --- /dev/null +++ b/packages/website/src/content/docs/api/type-aliases/Markup.md @@ -0,0 +1,35 @@ +--- +editUrl: false +next: false +prev: false +title: "Markup" +--- + +```ts +type Markup = + | `${ValueMarkup}` + | `${ValueMarkup}${MetaMarkup}` + | `${ValueMarkup}${MetaMarkup}${NestedMarkup}` + | `${ValueMarkup}${NestedMarkup}` + | `${ValueMarkup}${NestedMarkup}${MetaMarkup}` + | `${NestedMarkup}` + | `${NestedMarkup}${MetaMarkup}` + | `${NestedMarkup}${MetaMarkup}${ValueMarkup}` + | `${NestedMarkup}${ValueMarkup}` + | `${NestedMarkup}${ValueMarkup}${MetaMarkup}` + | `${MetaMarkup}${ValueMarkup}` + | `${MetaMarkup}${ValueMarkup}${NestedMarkup}` + | `${MetaMarkup}${NestedMarkup}` + | `${MetaMarkup}${NestedMarkup}${ValueMarkup}`; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:59](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L59) + +Modern Markup type supporting value, meta, and nested placeholders + +Examples: +- "@[__value__]" - simple value +- "@[__value__](__meta__)" - value with metadata +- "@[__nested__]" - nested content +- "@[__value__](__nested__)" - value with nested content +- "<__value__ __meta__>__nested__" - HTML-like with all features diff --git a/packages/website/src/content/docs/api/type-aliases/Token.md b/packages/website/src/content/docs/api/type-aliases/Token.md new file mode 100644 index 00000000..b27d8aef --- /dev/null +++ b/packages/website/src/content/docs/api/type-aliases/Token.md @@ -0,0 +1,14 @@ +--- +editUrl: false +next: false +prev: false +title: "Token" +--- + +```ts +type Token = + | TextToken + | MarkToken; +``` + +Defined in: [packages/core/src/features/parsing/ParserV2/types.ts:4](https://github.com/Nowely/marked-input/blob/next/packages/core/src/features/parsing/ParserV2/types.ts#L4) diff --git a/packages/website/src/content/docs/comparisons.md b/packages/website/src/content/docs/comparisons.md new file mode 100644 index 00000000..f37ba8e3 --- /dev/null +++ b/packages/website/src/content/docs/comparisons.md @@ -0,0 +1,187 @@ +--- +title: Comparison with Other Editors +description: Markput vs Draft.js, Slate, ProseMirror, Lexical - features, size, learning curve, use cases comparison +keywords: [comparison, Draft.js, Slate, ProseMirror, Lexical, rich text editor, alternatives, editor selection] +--- + +This page compares Markput with other popular rich text editor libraries to help you understand when Markput is the right choice for your project. + +## Feature Comparison + +| Feature | Markput | Draft.js | Slate | ProseMirror | +| ------------------- | -------------- | -------- | ------- | ----------- | +| **Markup-based** | ✅ | ❌ | ❌ | ❌ | +| **Custom patterns** | ✅ | Limited | Limited | Limited | +| **Bundle size** | ~15KB | ~100KB | ~150KB | ~200KB | +| **Learning curve** | Easy | Steep | Steep | Very steep | +| **TypeScript** | ✅ First-class | ✅ | ✅ | ⚠️ Limited | + +## When to Use Markput + +**Best for:** + +- Lightweight editors with custom markup (mentions, tags, commands) +- Applications that need @mentions, #hashtags, or /commands +- Projects where bundle size matters +- Teams that value simple, straightforward APIs +- Applications requiring pattern-based text formatting + +**Not for:** + +- Complex WYSIWYG editors with rich formatting toolbars +- Document editors with extensive formatting options (bold, italic, lists, etc.) +- Applications that need complex nested document structures +- Full-featured word processor replacements + +## Detailed Comparison + +### Markput + +**Strengths:** + +- Markup-based approach makes it easy to work with patterns like @mentions and #hashtags +- Very small bundle size (~15KB) +- Simple API with minimal learning curve +- First-class TypeScript support +- Pattern matching with regular expressions + +**Limitations:** + +- Not designed for complex WYSIWYG editing +- Limited to markup-based patterns +- Fewer formatting options compared to full-featured editors + +**Use cases:** + +- Social media comment systems +- Chat applications with mentions +- Command palettes with slash commands +- Tagging systems with hashtags + +### Draft.js + +**Strengths:** + +- Developed and maintained by Meta (Facebook) +- Mature ecosystem with many plugins +- Good for complex rich text editing +- Handles contentEditable quirks well + +**Limitations:** + +- Large bundle size (~100KB) +- Steep learning curve +- Complex API with many concepts to learn +- Limited TypeScript support +- Development has slowed down + +**Use cases:** + +- Rich text editors with formatting toolbars +- Content management systems +- Note-taking applications + +### Slate + +**Strengths:** + +- Highly customizable and extensible +- Active development and community +- Plugin architecture +- Good TypeScript support + +**Limitations:** + +- Large bundle size (~150KB) +- Steep learning curve +- Breaking changes between versions +- Requires significant setup + +**Use cases:** + +- Custom rich text editors +- Document editors +- Collaborative editing tools + +### ProseMirror + +**Strengths:** + +- Most powerful and flexible +- Excellent for collaborative editing +- Strong document model +- Used by many major applications + +**Limitations:** + +- Largest bundle size (~200KB) +- Very steep learning curve +- Complex architecture +- Limited TypeScript support + +**Use cases:** + +- Professional document editors +- Collaborative editing platforms +- Complex content management systems + +## Migration Guide + +If you're considering migrating from another editor to Markput, here's what you need to know: + +### From Draft.js + +1. Convert Draft.js content state to plain text with markup +2. Configure Markput options to match your entity types +3. Replace Draft.js components with Markput + +**Example:** + +```typescript +// Draft.js entity +{ type: 'mention', data: { id: '123', name: 'Alice' } } + +// Becomes Markput markup +'@[Alice](123)' +``` + +### From Slate + +1. Serialize Slate nodes to markup text +2. Create corresponding markup patterns +3. Migrate editor component + +### From ProseMirror + +ProseMirror's document model is very different from Markput's markup-based approach. Migration is best suited for applications that can simplify their editing needs to pattern-based markup. + +## Framework Support + +### Markput + +- **React**: ✅ Full support (`rc-marked-input`) +- **Vue/Svelte/Angular**: ⚠️ Core library only (`@markput/core`) + +### Other Editors + +- **Draft.js**: React only +- **Slate**: React only +- **ProseMirror**: Framework-agnostic + +## Conclusion + +Choose Markput if you need: + +- Pattern-based text input (@mentions, #hashtags, /commands) +- Small bundle size +- Simple API +- Quick implementation + +Choose other editors if you need: + +- Complex WYSIWYG editing +- Rich formatting toolbars +- Document-style editing +- Collaborative editing features + +**Still unsure?** Ask in [GitHub Discussions](https://github.com/Nowely/marked-input/discussions)! diff --git a/packages/website/src/content/docs/development/architecture.md b/packages/website/src/content/docs/development/architecture.md new file mode 100644 index 00000000..faf7032e --- /dev/null +++ b/packages/website/src/content/docs/development/architecture.md @@ -0,0 +1,726 @@ +--- +title: Architecture +description: Markput internal architecture - React layer, parser engine, token renderer, store, component hierarchy and data flow +keywords: [architecture, parser engine, token renderer, React hooks, component design, data flow, system design] +--- + +This guide explains Markput's internal architecture, data flow, and design decisions. + +## System Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MarkedInput │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ React Layer │ │ +│ │ • Components (MarkedInput, Suggestions) │ │ +│ │ • Hooks (useMark, useOverlay, useListener) │ │ +│ │ • Context Providers │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ Core Layer │ │ +│ │ • Parser (markup → tokens) │ │ +│ │ • Store (state management) │ │ +│ │ • EventBus (inter-component communication) │ │ +│ │ • Caret (cursor positioning) │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ DOM Layer │ │ +│ │ • contenteditable container │ │ +│ │ • Mark elements (custom components) │ │ +│ │ • Overlay element (suggestions) │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Component Hierarchy + +### React Component Tree + +``` + + ├─ # Provides store to child components + │ ├─ # contenteditable element + │ │ ├─ # Plain text token + │ │ ├─ # Mark token + │ │ │ └─ # User's custom Mark component + │ │ │ └─ uses useMark() hook + │ │ ├─ + │ │ └─ + │ │ └─ + │ │ + │ └─ # Portal for overlay + │ └─ # User's custom Overlay component + │ └─ uses useOverlay() hook + │ + └─ Event Handlers # Input, focus, blur, keyboard events +``` + +### Component Responsibilities + +| Component | Responsibility | +| ----------------- | --------------------------------------------------- | +| **MarkedInput** | Entry point, props validation, store initialization | +| **StoreProvider** | React context for store access | +| **Container** | contenteditable management, event handling | +| **TextNode** | Renders plain text tokens | +| **MarkNode** | Renders mark tokens, provides mark context | +| **Mark** | User's custom mark component | +| **OverlayPortal** | React portal for overlay positioning | +| **Overlay** | User's custom overlay component | + +## Data Flow + +### Input Flow (User Types) + +``` +1. User types in contenteditable + ↓ +2. onInput event fires + ↓ +3. Extract text from DOM + ↓ +4. Call onChange(newText) + ↓ +5. Parent updates value prop + ↓ +6. MarkedInput receives new value + ↓ +7. Parser.parse(value) → tokens + ↓ +8. Store.tokens = newTokens + ↓ +9. React re-renders with new tokens + ↓ +10. DOM updates with new marks +``` + +### Trigger Flow (Overlay Opens) + +``` +1. User types trigger character (e.g., '@') + ↓ +2. onInput event fires + ↓ +3. CheckTrigger event sent + ↓ +4. TriggerFinder.find() checks for trigger + ↓ +5. If found: + - Store.overlayMatch = { trigger, value, ... } + - SystemEvent.CheckTrigger sent + ↓ +6. Overlay component receives match via useOverlay() + ↓ +7. Overlay renders at cursor position + ↓ +8. User selects item: + - Overlay calls select({ value, meta }) + - SystemEvent.Select sent + ↓ +9. Markup inserted: annotate(markup, { value, meta }) + ↓ +10. onChange called with new text +``` + +### Selection Flow (Overlay Item Selected) + +``` +1. User clicks/enters on overlay item + ↓ +2. Overlay.select({ value, meta }) + ↓ +3. SystemEvent.Select sent + ↓ +4. Store receives Select event + ↓ +5. Create markup: annotate(markup, { value, meta }) + ↓ +6. Replace trigger span with markup + ↓ +7. Update DOM and cursor position + ↓ +8. Call onChange(newText) + ↓ +9. Close overlay (ClearTrigger event) +``` + +## Parsing Pipeline + +### Stage 1: Text Input + +``` +Input: "Hello @[Alice](123) and #[react]" +``` + +### Stage 2: Parser Initialization + +```typescript +const parser = new Parser([ + '@[__value__](__meta__)', // Mention pattern + '#[__value__]', // Hashtag pattern +]) +``` + +### Stage 3: Tokenization + +Parser converts text into token tree: + +```typescript +;[ + { + type: 'text', + content: 'Hello ', + position: {start: 0, end: 6}, + }, + { + type: 'mark', + content: '@[Alice](123)', + position: {start: 6, end: 19}, + value: 'Alice', + meta: '123', + descriptor: {index: 0, markup: '@[__value__](__meta__)'}, + children: [], + }, + { + type: 'text', + content: ' and ', + position: {start: 19, end: 24}, + }, + { + type: 'mark', + content: '#[react]', + position: {start: 24, end: 32}, + value: 'react', + descriptor: {index: 1, markup: '#[__value__]'}, + children: [], + }, +] +``` + +### Stage 4: React Rendering + +Each token renders as React component: + +```jsx + + Hello + + + + and + + + + +``` + +### Nested Parsing + +For nested marks like `**bold @[mention]**`: + +``` +1. Parse outer mark: **__nested__** + ↓ +2. Extract nested content: "bold @[mention]" + ↓ +3. Recursively parse nested content + ↓ +4. Build token tree: + { + type: 'mark', + nested: { content: 'bold @[mention]', ... }, + children: [ + { type: 'text', content: 'bold ' }, + { type: 'mark', value: 'mention', ... } + ] + } +``` + +## Event System + +### Event Bus Architecture + +```typescript +class EventBus { + private listeners = new Map>() + + on(event: EventKey, handler: Function): void + off(event: EventKey, handler: Function): void + send(event: EventKey, data?: any): void +} +``` + +### System Events + +| Event | When Fired | Payload | +| --------------- | ------------------------- | ------------------- | +| `STORE_UPDATED` | Store state changes | Updated store | +| `Change` | Text content changes | `{ value: string }` | +| `Parse` | Parsing triggered | - | +| `CheckTrigger` | Check for overlay trigger | - | +| `ClearTrigger` | Close overlay | - | +| `Select` | Overlay item selected | `{ mark, match }` | +| `Delete` | Mark deleted | `{ token }` | + +### Event Flow Example + +```typescript +// Component sends event +store.bus.send(SystemEvent.Change, {value: 'new text'}) + +// Multiple listeners can subscribe +store.bus.on(SystemEvent.Change, data => { + console.log('Text changed:', data.value) +}) + +store.bus.on(SystemEvent.Change, data => { + saveToLocalStorage(data.value) +}) + +// Clean up when done +store.bus.off(SystemEvent.Change, handler) +``` + +## State Management + +### Store Structure + +```typescript +class Store { + // Configuration + props: MarkedInputProps + + // Document state + tokens: Token[] + parser: Parser + previousValue?: string + + // UI state + refs: { + container: HTMLDivElement | null + overlay: HTMLElement | null + } + selecting?: boolean + + // Overlay state + overlayMatch?: OverlayMatch + + // Navigation + nodes: { + focus: NodeProxy + input: NodeProxy + } + recovery?: Recovery + + // Event system + bus: EventBus + key: KeyGenerator +} +``` + +### State Updates + +Store uses Proxy pattern for reactive updates: + +```typescript +const store = new Proxy(new Store(props), { + set(target, prop, value) { + if (IMMUTABLE_KEYS.has(prop)) { + return false // Prevent mutation of immutable properties + } + + if (target[prop] === value) { + return true // No change, skip update + } + + target[prop] = value + target.bus.send(SystemEvent.STORE_UPDATED, store) + return true + }, +}) +``` + +### State Access in React + +```typescript +// Via hook +const store = useStore() + +// Via selector (for performance) +const tokens = useStore(store => store.tokens) +const overlayMatch = useStore(store => store.overlayMatch) +``` + +## Re-render Optimization + +### Token Memoization + +Tokens are memoized to prevent unnecessary re-parsing: + +```typescript +const tokens = useMemo(() => { + return parser.parse(value) +}, [value, parser]) +``` + +### Mark Component Memoization + +Each mark component is wrapped with React.memo: + +```typescript +const MemoizedMark = memo(({ value, meta }) => { + return {value} +}) +``` + +### Selective Re-rendering + +Only changed tokens trigger re-renders: + +```typescript +function TokenRenderer({ tokens }) { + return tokens.map((token, index) => ( + + )) +} +``` + +### Store Selectors + +Use selectors to subscribe to specific state: + +```typescript +// ❌ Re-renders on ANY store change +const store = useStore() + +// ✅ Only re-renders when tokens change +const tokens = useStore(store => store.tokens) + +// ✅ Only re-renders when overlay state changes +const overlayMatch = useStore(store => store.overlayMatch) +``` + +## Cursor Management + +### Caret Position + +Cursor position is managed through: + +1. **Before change**: Save current cursor position +2. **After change**: Restore cursor to correct position + +```typescript +class Caret { + static save(): Recovery { + const selection = window.getSelection() + // Save range, offset, etc. + } + + static restore(recovery: Recovery): void { + // Restore cursor to saved position + } + + static getAbsolutePosition(): {left: number; top: number} { + // Get cursor coordinates for overlay + } +} +``` + +### Cursor Restoration + +After DOM updates, cursor must be restored: + +```typescript +function handleInput() { + const recovery = Caret.save() // 1. Save cursor + const newText = extractText(dom) // 2. Get new text + onChange(newText) // 3. Update value (triggers re-render) + + // After re-render: + useEffect(() => { + Caret.restore(recovery) // 4. Restore cursor + }) +} +``` + +## contenteditable Management + +### DOM Structure + +```html +
+ Hello + + + + and + + + +
+``` + +### Text Extraction + +Extract plain text from DOM, preserving marks: + +```typescript +function extractText(element: HTMLElement): string { + let text = '' + + for (const node of element.childNodes) { + if (node.nodeType === Node.TEXT_NODE) { + text += node.textContent + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement + if (el.dataset.mark) { + // Extract mark syntax from data attributes + text += el.dataset.markup || '' + } else { + text += extractText(el) // Recurse + } + } + } + + return text +} +``` + +### Mark Synchronization + +Ensure DOM marks match token tree: + +```typescript +function syncDOMWithTokens(container: HTMLElement, tokens: Token[]) { + // 1. Build new DOM tree from tokens + const newDOM = tokensToDOM(tokens) + + // 2. Diff with current DOM + const patches = diff(container.childNodes, newDOM) + + // 3. Apply minimal patches + applyPatches(container, patches) +} +``` + +## Performance Characteristics + +### Parsing Performance + +| Text Length | Parse Time | Notes | +| ------------- | ---------- | --------------------- | +| 100 chars | ~0.1ms | Very fast | +| 1,000 chars | ~1ms | Fast | +| 10,000 chars | ~10ms | Acceptable | +| 100,000 chars | ~100ms | Consider optimization | + +### Re-render Performance + +With proper memoization: + +- **Token changes**: Only changed tokens re-render +- **Overlay opens**: Only overlay component re-renders +- **Store updates**: Only components using affected state re-render + +### Memory Usage + +Typical memory footprint: + +- **Parser**: ~100KB (markup registry, matcher caches) +- **Store**: ~10KB (state objects) +- **Tokens**: ~100 bytes per token +- **React components**: ~50 bytes per mark + +## Design Patterns + +### Separation of Concerns + +``` +┌─────────────────┐ +│ React Layer │ UI components, hooks, context +├─────────────────┤ +│ Core Layer │ Parser, Store, EventBus +├─────────────────┤ +│ DOM Layer │ contenteditable, native events +└─────────────────┘ +``` + +### Inversion of Control + +User provides custom components: + +```typescript + +``` + +### Observer Pattern + +Event bus implements pub/sub: + +```typescript +bus.on(event, handler) // Subscribe +bus.send(event, data) // Publish +bus.off(event, handler) // Unsubscribe +``` + +### Factory Pattern + +`createMarkedInput` factory: + +```typescript +const Editor = createMarkedInput({ + Mark: MyMark, + options: myOptions, +}) +``` + +### Proxy Pattern + +Store uses Proxy for reactive updates: + +```typescript +const store = new Proxy(new Store(), {set}) +``` + +## Extensibility Points + +### 1. Custom Mark Components + +Replace default mark rendering: + +```typescript +const CustomMark: FC = ({ value }) => ( + +) + + +``` + +### 2. Custom Overlay + +Replace autocomplete UI: + +```typescript +const CustomOverlay: FC = () => { + const { match } = useOverlay() + return +} + + +``` + +### 3. Custom Slots + +Replace internal components: + +```typescript + +``` + +### 4. Event Listeners + +Hook into system events: + +```typescript +useListener('change', data => { + console.log('Changed:', data) +}) +``` + +## Common Architectural Patterns + +### Pattern: Controlled Component + +```typescript +function App() { + const [value, setValue] = useState('') + + return ( + + ) +} +``` + +### Pattern: Uncontrolled Component + +```typescript +function App() { + return ( + + ) +} +``` + +### Pattern: Compound Components + +```typescript + + {/* Future: Allow children for toolbars, etc. */} + +``` + +## Debugging Architecture + +### Inspect Store + +```typescript +// In React DevTools console +const store = useStore() +console.log('Store:', store) +console.log('Tokens:', store.tokens) +console.log('Overlay:', store.overlayMatch) +``` + +### Monitor Events + +```typescript +// Log all events +const events = [SystemEvent.STORE_UPDATED, SystemEvent.Change, SystemEvent.CheckTrigger, SystemEvent.Select] + +events.forEach(event => { + store.bus.on(event, data => { + console.log(`[Event] ${event.description}`, data) + }) +}) +``` + +### Visualize Token Tree + +```typescript +function printTokenTree(tokens: Token[], indent = 0) { + tokens.forEach(token => { + const prefix = ' '.repeat(indent) + if (token.type === 'text') { + console.log(`${prefix}Text: "${token.content}"`) + } else { + console.log(`${prefix}Mark: ${token.value}`) + printTokenTree(token.children, indent + 1) + } + }) +} +``` + +**See also:** + +- [How It Works](../introduction/how-it-works) - Understanding how Markput processes text diff --git a/packages/website/src/content/docs/development/how-it-works.md b/packages/website/src/content/docs/development/how-it-works.md new file mode 100644 index 00000000..8898aef0 --- /dev/null +++ b/packages/website/src/content/docs/development/how-it-works.md @@ -0,0 +1,523 @@ +--- +title: How It Works +description: Learn how Markput works - marks, tokens, parsing, overlay system, nested marks, and state management for React text editors +keywords: + [ + how it works, + architecture, + marks, + tokens, + parsing, + overlay, + nested marks, + state management, + component design, + token tree, + ] +--- + +This guide explains how Markput works under the hood. Understanding these concepts will help you build more sophisticated editors and troubleshoot issues effectively. + +> **TL;DR**: Markput turns text patterns into React components. You define patterns like `@[__value__]`, Markput parses them into tokens, and renders them as your custom components. + +## The Big Picture + +
+Visual Overview (Optional) + +Markput transforms plain text with special patterns into interactive React components. Here's the flow: + +``` +Plain Text with Patterns + ↓ + [Parser] + ↓ + Token Tree + ↓ + [Renderer] + ↓ + React Components +``` + +This process happens automatically when you type, and the result is a fully interactive editor. + +
+ +## Marks vs Tokens + +### Marks + +A **mark** is a special pattern in your text that gets rendered as a React component. It highlights or transforms specific text segments into interactive elements. + +```tsx +'Hello @[World](meta)!' +// ↑ ↑ +// Mark boundaries +``` + +**Mark Properties:** + +- **Content**: The entire matched pattern `@[World](meta)` +- **Value**: The text to display `"World"` +- **Meta**: Optional metadata `"meta"` +- **Position**: Start and end indices in the original string + +### Tokens + +A **token** is the internal representation used by Markput's parser. Your text is broken down into tokens: + +```tsx +'Hello @[World](meta)!'[ + // Becomes this token tree: + ({type: 'text', content: 'Hello '}, + {type: 'mark', value: 'World', meta: 'meta', content: '@[World](meta)'}, + {type: 'text', content: '!'}) +] +``` + +**Token Types:** + +- **TextToken**: Plain text segments +- **MarkToken**: Marked segments (rendered as your Mark component) + +## Markup Patterns + +Markup patterns define how marks are identified in your text. They use placeholder syntax: + +### Placeholders + +| Placeholder | Description | Supports Nesting | +| ------------ | ------------------------------------ | ---------------- | +| `__value__` | Main content (plain text only) | ❌ No | +| `__meta__` | Metadata (plain text only) | ❌ No | +| `__nested__` | Content that can contain other marks | ✅ Yes | + +### Common Patterns + +```tsx +// Basic mention +'@[__value__]' +// Matches: @[Alice], @[Bob] + +// Mention with metadata +'@[__value__](__meta__)' +// Matches: @[Alice](user:1), @[Bob](user:2) + +// Hashtag +'#[__value__]' +// Matches: #[react], #[javascript] + +// Bold (supports nesting) +'**__nested__**' +// Matches: **bold text**, **bold with *italic* inside** + +// HTML-like (two values pattern) +'<__value__>__nested__' +// Matches:
content
, text +``` + +### Pattern Matching Rules + +1. **Greedy Matching**: Patterns are matched from left to right, longest match first +2. **Non-Overlapping**: A character can only belong to one mark +3. **Escape Sequences**: (Not currently supported - use custom parsers for complex escaping) + +## The Parsing Process + +
+How Markput Parses Text (Deep Dive) + +Let's walk through how Markput processes your text step-by-step: + +### Step 1: Preparsing + +The text is scanned for potential mark boundaries. + +```tsx +Input: 'Hello @[World](meta) and @[Alice](user:1)!' + ↓ +Identifies: Two potential marks at positions 6-22 and 27-42 +``` + +### Step 2: Pattern Matching + +Each potential mark is tested against your markup patterns. + +```tsx +Markup: '@[__value__](__meta__)' + ↓ +Test: '@[World](meta)' → ✅ Match! + value: 'World', meta: 'meta' + ↓ +Test: '@[Alice](user:1)' → ✅ Match! + value: 'Alice', meta: 'user:1' +``` + +### Step 3: Tokenization + +The text is broken into tokens. + +```tsx +[ + { type: 'text', content: 'Hello ' }, + { type: 'mark', value: 'World', meta: 'meta', ... }, + { type: 'text', content: ' and ' }, + { type: 'mark', value: 'Alice', meta: 'user:1', ... }, + { type: 'text', content: '!' } +] +``` + +### Step 4: Rendering + +Each token is rendered as a React element. + +```tsx +TextToken → Hello +MarkToken → +TextToken → and +MarkToken → +TextToken → ! +``` + +**Key insight**: This happens for every keystroke, keeping tokens in sync with your text. + +
+ +## Nested Marks + +Nested marks allow hierarchical structures. Use `__nested__` to enable nesting: + +```tsx +// Flat (no nesting) +markup: '*__value__*' +value: '*bold with *italic* inside*' +// Result: One mark with value = "bold with *italic* inside" + +// Nested (supports hierarchy) +markup: '*__nested__*' +value: '*bold with *italic* inside*' +// Result: Parent mark contains child mark +``` + +### Token Tree for Nested Marks + +
+Token Structure Example (Advanced) + +```tsx +'**bold with *italic* text**' + +// Token tree: +{ + type: 'mark', + value: undefined, + nested: 'bold with *italic* text', + children: [ + { type: 'text', content: 'bold with ' }, + { + type: 'mark', + value: undefined, + nested: 'italic', + children: [ + { type: 'text', content: 'italic' } + ] + }, + { type: 'text', content: ' text' } + ] +} +``` + +Notice the `children` array - this is what makes nesting possible. Each mark can contain text and other marks. + +
+ +### Rendering Nested Marks + +When a mark has `children`, they're rendered as React children: + +```tsx +const Mark = ({children, nested}) => { + // For nested marks, use children (ReactNode) + if (children) { + return {children} + } + // For flat marks, use nested string + return {nested} +} +``` + +## The Overlay System + +The overlay system handles autocomplete and suggestion menus. + +### Trigger Flow + +``` +User types '@' + ↓ +Trigger detected + ↓ +Overlay rendered + ↓ +User selects 'Alice' + ↓ +Text updated: '@[Alice]' + ↓ +Overlay closed +``` + +### Overlay Lifecycle + +1. **Detection**: Text change matches a trigger character +2. **Rendering**: Overlay component is rendered with suggestions +3. **Positioning**: Overlay is positioned at caret location +4. **Selection**: User selects an item or closes overlay +5. **Insertion**: Selected value is inserted as a mark +6. **Cleanup**: Overlay is unmounted + +### Overlay Props + +The `useOverlay()` hook provides: + +```tsx +{ + style: { left: 120, top: 45 }, // Caret position + close: () => {...}, // Close the overlay + select: (item) => {...}, // Insert a mark + match: { // Match details + value: 'ali', // Current typed text + source: '@ali', // Full matched string + trigger: '@' // The trigger character + }, + ref: overlayRef // For outside click detection +} +``` + +## Component Architecture + +
+Internal Architecture (For Curious Minds) + +### High-Level Structure + +``` + + └── (editable div) + ├── (plain text) + ├── (your component) + ├── (plain text) + └── (if triggered) +``` + +### Props Flow + +``` +MarkedInput Props + ↓ +[Configuration Layer] + ↓ +[Parser + Store] + ↓ +[Token Renderer] + ↓ +React Components + ↓ +User Interaction + ↓ +Events → onChange + ↓ +Update State +``` + +The key insight: Everything flows through the store, which triggers re-renders only when tokens change. + +
+ +## State Management + +Markput uses an internal store for managing editor state: + +```tsx +Store State: +{ + value: string, // Current text + tokens: Token[], // Parsed token tree + selection: Range, // Cursor/selection position + overlay: OverlayState, // Overlay visibility & data + focused: boolean // Focus state +} +``` + +### Controlled vs Uncontrolled + +```tsx +// ✅ Controlled (recommended) +const [value, setValue] = useState('') + + +// ⚠️ Uncontrolled (less common) + +``` + +## Event System + +### Built-in Events + +| Event | When Triggered | Use Case | +| ------------------- | ----------------- | -------------------- | +| `onChange` | Text changes | Update parent state | +| `onFocus` | Editor focused | Show toolbar | +| `onBlur` | Editor blurred | Hide toolbar | +| `onKeyDown` | Key pressed | Custom shortcuts | +| `onSelectionChange` | Selection changes | Update toolbar state | + +### Custom Event Listeners + +Use `useListener` hook for custom events: + +```tsx +import {useListener} from 'rc-marked-input' + +const Mark = () => { + useListener( + 'customEvent', + data => { + console.log('Custom event:', data) + }, + [] + ) + + return Mark +} +``` + +## Options System + +Options allow per-pattern configuration. Each pattern can have its own Mark component and overlay: + +```tsx + +``` + +
+Advanced: Full Example with Props Transform + +```tsx + ({ + // Transform extracted props + label: value, + userId: meta, + }), + overlay: { + // Static overlay config + trigger: '@', + data: users, + }, + }, + }, + ]} +/> +``` + +### Option Resolution Priority + +``` +1. option.slots.mark (highest priority) +2. MarkedInput.Mark prop +3. undefined (error if no Mark provided) +``` + +
+ +## Performance Considerations + +
+Performance Tips & Optimization (Optional Reading) + +### Re-render Optimization + +Markput minimizes re-renders: + +- Token tree is memoized +- Components re-render only when their token changes +- Use `React.memo` for expensive Mark components + +```tsx +const ExpensiveMark = React.memo(({value}) => { + // Complex rendering logic + return {value} +}) +``` + +### Large Documents + +For large documents (1000+ marks): + +- Consider debouncing `onChange` +- Use `defaultValue` if possible +- Implement virtualization for mark lists + +For more details, see the [Performance Optimization](/development/performance) guide. + +
+ +## Debugging Tips + +
+Troubleshooting & Debug Tools + +### Visualize Tokens + +```tsx +import {parse} from '@markput/core' + +const tokens = parse(value, [{markup: '@[__value__]'}]) +console.log(JSON.stringify(tokens, null, 2)) +``` + +This is your best friend for understanding what Markput "sees" in your text. + +### Check Markup Matching + +```tsx +// Enable debug mode (if available) + +// Check console for parsing logs +``` + +### Common Issues & Solutions + +| Issue | Cause | Solution | +| ------------------- | ------------------------------ | ----------------------------------------------------- | +| Marks not rendering | Markup pattern mismatch | Check pattern syntax with console.log | +| Infinite re-renders | onChange creates new reference | Use `useCallback` | +| TypeScript errors | Generic type mismatch | Specify types explicitly in `>` | +| Overlay not showing | Trigger mismatch | Check that trigger character matches your pattern | + +
+ +--- + +**Still stuck?** Ask in [GitHub Discussions](https://github.com/Nowely/marked-input/discussions). diff --git a/packages/website/src/content/docs/development/performance.md b/packages/website/src/content/docs/development/performance.md new file mode 100644 index 00000000..ab62a477 --- /dev/null +++ b/packages/website/src/content/docs/development/performance.md @@ -0,0 +1,755 @@ +--- +title: Performance Optimization +description: Markput performance tuning - optimization techniques, large documents, memoization, benchmarking, scaling best practices +keywords: [performance, optimization, memoization, React.memo, large documents, benchmarking, scaling] +--- + +This guide covers performance optimization techniques for Markput applications. + +## Performance Overview + +### Baseline Performance + +Markput is optimized for typical use cases: + +| Scenario | Performance | Notes | +| ---------------- | --------------------- | -------------------------- | +| **Text length** | 100-10,000 chars | Excellent performance | +| **Marks count** | 10-100 marks | Fast rendering | +| **Typing speed** | Normal typing | No lag | +| **Parse time** | ~0.01ms per 100 chars | Very fast | +| **Re-render** | ~1-5ms | Optimized with memoization | + +### Performance Bottlenecks + +Common performance issues: + +1. **Large documents** (>10,000 characters) +2. **Many marks** (>100 marks) +3. **Complex mark components** (heavy rendering) +4. **Frequent re-renders** (missing memoization) +5. **Heavy onChange handlers** (blocking updates) + +## Large Documents + +### Problem: Slow Parsing + +**Symptoms:** + +- Typing feels sluggish +- Delays when pasting large text +- UI freezes on input + +**Solution 1: Debounce onChange** + +```typescript +import { useMemo } from 'react' +import { debounce } from 'lodash' + +function Editor() { + const [value, setValue] = useState('') + + // Debounce expensive operations + const debouncedSave = useMemo( + () => debounce((value: string) => { + saveToBackend(value) + }, 500), + [] + ) + + const handleChange = (newValue: string) => { + setValue(newValue) // Update immediately (fast) + debouncedSave(newValue) // Save later (slow) + } + + return ( + + ) +} +``` + +**Solution 2: Virtualization** + +For very large documents, use virtualization: + +```typescript +import { FixedSizeList } from 'react-window' + +function VirtualizedEditor() { + const [value, setValue] = useState('') + const lines = value.split('\n') + + return ( + + {({ index, style }) => ( +
+ { + const newLines = [...lines] + newLines[index] = newLine + setValue(newLines.join('\n')) + }} + Mark={MyMark} + /> +
+ )} +
+ ) +} +``` + +**Solution 3: Lazy Parsing** + +Parse only visible content: + +```typescript +function LazyEditor() { + const [value, setValue] = useState('') + const [visibleRange, setVisibleRange] = useState({ start: 0, end: 1000 }) + + const visibleText = value.substring(visibleRange.start, visibleRange.end) + + return ( + setVisibleRange(range)}> + { + const newValue = + value.substring(0, visibleRange.start) + + newText + + value.substring(visibleRange.end) + setValue(newValue) + }} + Mark={MyMark} + /> + + ) +} +``` + +### Problem: Slow Re-rendering + +**Solution: Memoize Mark Components** + +```typescript +import { memo } from 'react' + +// ❌ Re-renders on every change +const SlowMark: FC = ({ value }) => ( + {value} +) + +// ✅ Only re-renders when props change +const FastMark = memo(({ value }) => ( + {value} +)) + + +``` + +**Solution: Memoize Options** + +```typescript +function Editor() { + // ❌ Creates new options object on every render + return ( + + ) + + // ✅ Memoized options + const options = useMemo(() => [ + { markup: '@[__value__]' } + ], []) + + return +} +``` + +## Many Marks + +### Problem: Too Many DOM Nodes + +**Symptoms:** + +- Slow scrolling +- High memory usage +- Browser becomes unresponsive + +**Solution: Simplify Mark Components** + +```typescript +// ❌ Heavy mark component +const HeavyMark: FC = ({ value, meta }) => { + const user = useFetchUser(meta) // API call per mark! + const avatar = useFetchAvatar(user.id) // Another API call! + + return ( +
+ + {value} + +
+ ) +} + +// ✅ Lightweight mark component +const LightMark: FC = ({ value }) => ( + {value} +) +``` + +**Solution: Batch Data Fetching** + +```typescript +// Fetch all user data at once +function Editor() { + const [value, setValue] = useState('') + const marks = extractMarks(value) + const userIds = marks.map(m => m.meta) + + // Single batch request + const users = useFetchUsers(userIds) + + const options = useMemo(() => [{ + markup: '@[__value__](__meta__)', + slotProps: { + mark: ({ value, meta }: MarkProps) => ({ + value, + user: users[meta] // Pass cached data + }) + } + }], [users]) + + return +} +``` + +### Problem: Expensive Mark Rendering + +**Solution: Lazy Loading** + +```typescript +const LazyMark: FC = ({ value, meta }) => { + const [data, setData] = useState(null) + const isVisible = useIntersectionObserver(ref) + + useEffect(() => { + if (isVisible && !data) { + fetchData(meta).then(setData) + } + }, [isVisible, meta]) + + return ( + + {data ? : value} + + ) +} +``` + +## Memoization Strategies + +### Strategy 1: Component-Level Memoization + +```typescript +// Memoize entire mark component +const MemoizedMark = memo( + ({ value, meta }) => {value}, + (prev, next) => { + // Custom comparison + return prev.value === next.value && prev.meta === next.meta + } +) +``` + +### Strategy 2: Hook-Level Memoization + +```typescript +const MyMark: FC = () => { + const { value, meta } = useMark() + + // Memoize expensive computations + const displayName = useMemo(() => { + return formatName(value) // Expensive operation + }, [value]) + + const userLink = useMemo(() => { + return `/users/${meta}` + }, [meta]) + + return {displayName} +} +``` + +### Strategy 3: Data-Level Memoization + +```typescript +// Cache parsed tokens +const tokens = useMemo(() => { + return parser.parse(value) +}, [value, parser]) + +// Cache mark data +const markData = useMemo(() => { + return tokens.filter(t => t.type === 'mark').map(t => ({value: t.value, meta: t.meta})) +}, [tokens]) +``` + +### Strategy 4: Callback Memoization + +```typescript +function Editor() { + // ❌ New function on every render + const handleMarkClick = (id: string) => { + console.log(id) + } + + // ✅ Memoized callback + const handleMarkClick = useCallback((id: string) => { + console.log(id) + }, []) + + return +} +``` + +## Debouncing + +### Debounce onChange + +```typescript +import { useMemo } from 'react' +import debounce from 'lodash/debounce' + +function Editor() { + const [value, setValue] = useState('') + + const debouncedOnChange = useMemo( + () => debounce((newValue: string) => { + // Heavy operations: API calls, validation, etc. + saveToServer(newValue) + validateContent(newValue) + updateAnalytics(newValue) + }, 300), + [] + ) + + const handleChange = (newValue: string) => { + setValue(newValue) // Immediate update (UI) + debouncedOnChange(newValue) // Delayed operations + } + + return ( + + ) +} +``` + +### Debounce Overlay Search + +```typescript +const MyOverlay: FC = () => { + const { match } = useOverlay() + const [results, setResults] = useState([]) + + const debouncedSearch = useMemo( + () => debounce((query: string) => { + searchAPI(query).then(setResults) + }, 200), + [] + ) + + useEffect(() => { + debouncedSearch(match.value) + }, [match.value]) + + return
{results.map(r => ...)}
+} +``` + +### Throttle vs Debounce + +```typescript +// Debounce: Wait for user to stop typing +const debounced = debounce(fn, 300) +// Calls fn 300ms after last keystroke + +// Throttle: Call at most once per interval +const throttled = throttle(fn, 300) +// Calls fn at most every 300ms +``` + +**When to use:** + +- **Debounce**: API calls, validation, save operations +- **Throttle**: Scroll events, resize events, frequent updates + +## Profiling + +### React DevTools Profiler + +1. Install React DevTools extension +2. Open DevTools → Profiler tab +3. Click "Record" +4. Type in editor +5. Stop recording +6. Analyze flame graph + +**Look for:** + +- Long render times (>16ms) +- Frequent re-renders +- Unnecessary component updates + +### Chrome Performance Tab + +1. Open DevTools → Performance tab +2. Click "Record" +3. Perform actions in editor +4. Stop recording +5. Analyze timeline + +**Look for:** + +- Long scripting time +- Layout thrashing +- Excessive repaints + +### Custom Performance Monitoring + +```typescript +function measurePerformance(fn: () => T, label: string): T { + const start = performance.now() + const result = fn() + const end = performance.now() + console.log(`[${label}] ${(end - start).toFixed(2)}ms`) + return result +} + +// Usage +const tokens = measurePerformance(() => parser.parse(value), 'Parse') +``` + +### Performance Hooks + +```typescript +function usePerformanceMonitor(label: string) { + useEffect(() => { + const start = performance.now() + return () => { + const end = performance.now() + console.log(`[${label}] Render: ${(end - start).toFixed(2)}ms`) + } + }) +} + +function MyMark() { + usePerformanceMonitor('MyMark') + // ... component code +} +``` + +## Bundle Size Optimization + +### Tree Shaking + +Ensure proper tree shaking: + +```typescript +// ✅ Named imports (tree-shakeable) +import {MarkedInput, useMark} from 'rc-marked-input' + +// ❌ Namespace import (not tree-shakeable) +import * as Markput from 'rc-marked-input' +``` + +### Code Splitting + +Split large editors into separate chunks: + +```typescript +import { lazy, Suspense } from 'react' + +const AdvancedEditor = lazy(() => import('./AdvancedEditor')) + +function App() { + return ( + Loading...
}> + + + ) +} +``` + +### Dynamic Imports + +Load mark components on demand: + +```typescript +function App() { + const [MarkComponent, setMarkComponent] = useState(null) + + useEffect(() => { + import('./HeavyMark').then(module => { + setMarkComponent(() => module.HeavyMark) + }) + }, []) + + if (!MarkComponent) { + return
Loading...
+ } + + return +} +``` + +## Memory Management + +### Cleanup Event Listeners + +```typescript +function MyComponent() { + useEffect(() => { + const handler = e => console.log(e) + store.bus.on(SystemEvent.Change, handler) + + return () => { + store.bus.off(SystemEvent.Change, handler) // ✅ Cleanup + } + }, []) +} +``` + +### Avoid Memory Leaks + +```typescript +// ❌ Memory leak: closure captures large object +function Editor() { + const largeData = fetchLargeData() + + const handleChange = (value: string) => { + console.log(largeData) // Captures largeData forever! + } + + return +} + +// ✅ Fixed: only capture what you need +function Editor() { + const largeData = fetchLargeData() + const summary = largeData.summary // Small object + + const handleChange = (value: string) => { + console.log(summary) // Only captures summary + } + + return +} +``` + +### WeakMap for Caches + +```typescript +// Cache mark data without preventing GC +const markCache = new WeakMap() + +function getCachedData(mark: MarkToken): CachedData { + if (markCache.has(mark)) { + return markCache.get(mark)! + } + + const data = computeExpensiveData(mark) + markCache.set(mark, data) + return data +} +``` + +## Real-World Optimizations + +### Optimization 1: Batch Updates + +```typescript +// ❌ Multiple onChange calls +function insertMultipleMarks() { + marks.forEach(mark => { + const newValue = value + annotate(markup, mark) + onChange(newValue) // Triggers re-render each time! + }) +} + +// ✅ Single onChange call +function insertMultipleMarks() { + let newValue = value + marks.forEach(mark => { + newValue += annotate(markup, mark) + }) + onChange(newValue) // Single re-render +} +``` + +### Optimization 2: Request Deduplication + +```typescript +const pendingRequests = new Map>() + +function fetchWithDedup(url: string): Promise { + if (pendingRequests.has(url)) { + return pendingRequests.get(url)! + } + + const promise = fetch(url).then(r => r.json()) + pendingRequests.set(url, promise) + + promise.finally(() => { + pendingRequests.delete(url) + }) + + return promise +} +``` + +### Optimization 3: Incremental Rendering + +```typescript +function IncrementalEditor() { + const [value, setValue] = useState('') + const [rendered, setRendered] = useState('') + + useEffect(() => { + // Render in chunks to avoid blocking + const chunks = chunkText(value, 1000) + let currentChunk = 0 + + const timer = setInterval(() => { + if (currentChunk < chunks.length) { + setRendered(prev => prev + chunks[currentChunk]) + currentChunk++ + } else { + clearInterval(timer) + } + }, 16) // ~60fps + + return () => clearInterval(timer) + }, [value]) + + return +} +``` + +## Performance Checklist + +### ✅ Must Do + +- [ ] Memoize Mark components with `memo()` +- [ ] Memoize options array with `useMemo()` +- [ ] Debounce expensive onChange operations +- [ ] Use `useCallback` for event handlers +- [ ] Clean up event listeners in `useEffect` + +### ✅ Should Do + +- [ ] Profile with React DevTools +- [ ] Minimize mark component complexity +- [ ] Batch API requests for mark data +- [ ] Use stable keys for rendered marks +- [ ] Implement lazy loading for heavy marks + +### ✅ Consider For Large Apps + +- [ ] Implement virtualization for long documents +- [ ] Use code splitting for large editors +- [ ] Implement request deduplication +- [ ] Use WeakMap for caches +- [ ] Consider Web Workers for parsing + +## Performance Benchmarks + +### Test Setup + +```typescript +function benchmark(label: string, fn: () => void, iterations = 1000) { + const start = performance.now() + + for (let i = 0; i < iterations; i++) { + fn() + } + + const end = performance.now() + const avg = (end - start) / iterations + console.log(`[${label}] Avg: ${avg.toFixed(3)}ms`) +} +``` + +### Parsing Benchmarks + +```typescript +const parser = new Parser(['@[__value__](__meta__)']) + +benchmark('Parse 100 chars', () => { + parser.parse('Hello @[Alice](1) @[Bob](2)') +}) + +benchmark('Parse 1000 chars', () => { + parser.parse(longText) +}) +``` + +### Rendering Benchmarks + +```typescript +benchmark('Render 10 marks', () => { + render( + + ) +}) +``` + +## Common Performance Issues + +### Issue 1: Flickering on Type + +**Cause:** Re-parsing on every keystroke + +**Solution:** Debounce or use controlled input + +### Issue 2: Slow Overlay + +**Cause:** Heavy filtering/searching on every character + +**Solution:** Debounce search, limit results + +### Issue 3: Memory Leak + +**Cause:** Event listeners not cleaned up + +**Solution:** Always clean up in `useEffect` + +### Issue 4: Laggy Scrolling + +**Cause:** Too many DOM nodes + +**Solution:** Virtualization or pagination + +--- diff --git a/docs/RFC. Nested marks.md b/packages/website/src/content/docs/development/rfc-nested-marks.md similarity index 94% rename from docs/RFC. Nested marks.md rename to packages/website/src/content/docs/development/rfc-nested-marks.md index b1b60fbf..19db9fe8 100644 --- a/docs/RFC. Nested marks.md +++ b/packages/website/src/content/docs/development/rfc-nested-marks.md @@ -1,3 +1,9 @@ +--- +title: 'RFC: Nested Marks' +description: RFC nested marks feature proposal - hierarchical structures, ParserV2, tree-based parsing, implementation roadmap +keywords: [RFC, nested marks, ParserV2, proposal, hierarchical, roadmap, feature development] +--- + # RFC: Nested Marks ## Status: In Development (ParserV2 Ready, React API Pending) @@ -372,23 +378,6 @@ Input Text → Aho-Corasick → SegmentMatches - 🚧 Feature flag for nested mode not yet implemented - 🚧 Migration strategy requires implementation -## Next Steps - -### Current Priorities - -1. 🚧 **React Integration**: Implement `useMark` hook extensions and nested rendering -2. 🚧 **Component Updates**: Update `Piece` component to render nested children -3. 🚧 **Feature Flag**: Add `nested` prop to `MarkedInput` component -4. 🚧 **Type Safety**: Implement `NestedMarkHandler` and `NestedMarkStruct` types - -### Future Enhancements - -1. **Advanced Navigation**: Add tree traversal utilities -2. **Performance Monitoring**: Add instrumentation for complex documents -3. **Migration Tools**: Create codemods for v4.0.0 migration -4. **Lazy Loading**: Implement virtualization for deep nesting -5. **Undo/Redo**: Add nested operations support - ## Discussion Questions 1. What nesting level to support initially? **Answer: Arbitrary depth with O(N log N) complexity** @@ -473,17 +462,6 @@ const CustomMark = ({ label, value }) => { } ``` -## Testing - -### ParserV2 Tests - -ParserV2 is thoroughly tested with: - -- **104 unit tests** covering all parsing scenarios -- **Integration tests** for end-to-end functionality -- **Performance benchmarks** showing 65K+ ops/sec for complex patterns -- **Edge case handling** for malformed markup and Unicode content - ## Success Metrics ### Technical Metrics diff --git a/packages/website/src/content/docs/examples/autocomplete.md b/packages/website/src/content/docs/examples/autocomplete.md new file mode 100644 index 00000000..c960c44f --- /dev/null +++ b/packages/website/src/content/docs/examples/autocomplete.md @@ -0,0 +1,711 @@ +--- +title: 🚧 Autocomplete System +description: Advanced autocomplete tutorial - fuzzy search, categorized suggestions, recent items, multi-source, loading states +keywords: [autocomplete, fuzzy search, suggestions, filtering, categories, recent items, search] +--- + +This example demonstrates how to build a comprehensive autocomplete system with fuzzy matching, categories, keyboard navigation, and recent selections. + +## Use Case + +**What we're building:** + +- Multi-source autocomplete (users, emojis, variables) +- Fuzzy search matching +- Categorized suggestions +- Recent selections history +- Configurable triggers +- Loading states + +**Where to use it:** + +- IDE autocomplete +- Email composers (Gmail, Outlook) +- Command palettes +- Search interfaces +- Template editors + +## Complete Implementation + +### Step 1: Define Types + +```tsx +// types.ts +export interface AutocompleteItem { + id: string + label: string + value: string + category: string + icon?: string + description?: string + meta?: any +} + +export interface AutocompleteSource { + trigger: string + category: string + items: AutocompleteItem[] + fuzzy?: boolean + async?: boolean + fetchItems?: (query: string) => Promise +} +``` + +### Step 2: Fuzzy Search Utility + +```tsx +// fuzzySearch.ts +export function fuzzyMatch(query: string, text: string): boolean { + const pattern = query.toLowerCase().split('').join('.*') + const regex = new RegExp(pattern) + return regex.test(text.toLowerCase()) +} + +export function fuzzyScore(query: string, text: string): number { + if (text.toLowerCase().startsWith(query.toLowerCase())) { + return 100 // Exact prefix match gets highest score + } + + let score = 0 + let queryIndex = 0 + const lowerQuery = query.toLowerCase() + const lowerText = text.toLowerCase() + + for (let i = 0; i < lowerText.length && queryIndex < lowerQuery.length; i++) { + if (lowerText[i] === lowerQuery[queryIndex]) { + score += 10 + queryIndex++ + } + } + + return queryIndex === lowerQuery.length ? score : 0 +} + +export function fuzzyFilter(items: AutocompleteItem[], query: string): AutocompleteItem[] { + if (!query) return items + + return items + .map(item => ({ + item, + score: Math.max(fuzzyScore(query, item.label), fuzzyScore(query, item.value)), + })) + .filter(({score}) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({item}) => item) +} +``` + +### Step 3: Autocomplete Mark + +```tsx +// AutocompleteMark.tsx +import {FC} from 'react' +import './AutocompleteMark.css' + +interface AutocompleteMarkProps { + value: string + category: string + icon?: string +} + +export const AutocompleteMark: FC = ({value, category, icon}) => { + return ( + + {icon && {icon}} + {value} + + ) +} +``` + +```css +/* AutocompleteMark.css */ +.autocomplete-mark { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 8px; + font-weight: 500; + font-size: 14px; + vertical-align: middle; +} + +.category-user { + background-color: #e3f2fd; + color: #1976d2; + border: 1px solid #90caf9; +} + +.category-emoji { + background-color: #fff9c4; + color: #f57f17; + border: 1px solid #fff176; +} + +.category-variable { + background-color: #f3e5f5; + color: #7b1fa2; + border: 1px solid #ce93d8; +} + +.mark-icon { + font-size: 16px; +} + +.mark-value { + line-height: 1; +} +``` + +### Step 4: Advanced Overlay + +```tsx +// AdvancedAutocompleteOverlay.tsx +import {FC, useState, useEffect, useMemo, useRef} from 'react' +import {useOverlay} from 'rc-marked-input' +import {fuzzyFilter} from './fuzzySearch' +import type {AutocompleteItem, AutocompleteSource} from './types' +import './AdvancedAutocompleteOverlay.css' + +interface Props { + sources: AutocompleteSource[] + recentItems?: AutocompleteItem[] + onSelect?: (item: AutocompleteItem) => void +} + +export const AdvancedAutocompleteOverlay: FC = ({sources, recentItems = [], onSelect}) => { + const {style, match, select, close, ref} = useOverlay() + const [selectedIndex, setSelectedIndex] = useState(0) + const [loading, setLoading] = useState(false) + const [asyncItems, setAsyncItems] = useState([]) + const selectedRef = useRef(null) + + // Find active source based on trigger + const activeSource = sources.find(s => match.trigger === s.trigger) + + // Get all items + const allItems = useMemo(() => { + if (!activeSource) return [] + + let items = activeSource.async ? asyncItems : activeSource.items + + // Add recent items at the top if query is empty + if (!match.value && recentItems.length > 0) { + const recentForCategory = recentItems.filter(item => item.category === activeSource.category) + items = [...recentForCategory, ...items] + } + + // Apply fuzzy filtering + if (activeSource.fuzzy && match.value) { + return fuzzyFilter(items, match.value) + } + + // Simple filtering + return items.filter(item => item.label.toLowerCase().includes(match.value.toLowerCase())) + }, [activeSource, asyncItems, recentItems, match.value]) + + // Fetch async items + useEffect(() => { + if (activeSource?.async && activeSource.fetchItems) { + setLoading(true) + activeSource + .fetchItems(match.value) + .then(setAsyncItems) + .finally(() => setLoading(false)) + } + }, [activeSource, match.value]) + + // Reset selection on results change + useEffect(() => { + setSelectedIndex(0) + }, [allItems.length]) + + // Scroll selected into view + useEffect(() => { + selectedRef.current?.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }) + }, [selectedIndex]) + + const selectItem = (item: AutocompleteItem) => { + select({ + value: item.value, + meta: `${item.category}|${item.icon || ''}`, + }) + onSelect?.(item) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => Math.min(prev + 1, allItems.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => Math.max(prev - 1, 0)) + break + case 'Enter': + case 'Tab': + e.preventDefault() + if (allItems[selectedIndex]) { + selectItem(allItems[selectedIndex]) + } + break + case 'Escape': + e.preventDefault() + close() + break + } + } + + if (!activeSource) return null + + if (loading) { + return ( +
+
+
+ Loading... +
+
+ ) + } + + if (allItems.length === 0) { + return ( +
+
No results for "{match.value}"
+
+ ) + } + + // Group items by category + const itemsByCategory = allItems.reduce( + (acc, item) => { + if (!acc[item.category]) acc[item.category] = [] + acc[item.category].push(item) + return acc + }, + {} as Record + ) + + let globalIndex = 0 + + return ( +
+
+ {activeSource.category} + + {allItems.length} {allItems.length === 1 ? 'result' : 'results'} + +
+ +
+ {Object.entries(itemsByCategory).map(([category, items]) => ( +
+ {Object.keys(itemsByCategory).length > 1 &&
{category}
} + {items.map(item => { + const index = globalIndex++ + const isSelected = index === selectedIndex + + return ( + + ) + })} +
+ ))} +
+
+ ) +} +``` + +```css +/* AdvancedAutocompleteOverlay.css */ +.autocomplete-overlay { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + width: 400px; + max-height: 400px; + overflow: hidden; + z-index: 1000; +} + +.autocomplete-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #f5f5f5; + background-color: #fafafa; +} + +.autocomplete-title { + font-weight: 600; + font-size: 13px; + color: #424242; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.autocomplete-count { + font-size: 12px; + color: #9e9e9e; +} + +.autocomplete-list { + max-height: 340px; + overflow-y: auto; +} + +.autocomplete-category { + margin: 8px 0; +} + +.category-header { + padding: 8px 16px 4px; + font-size: 11px; + font-weight: 600; + color: #757575; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.autocomplete-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border: none; + background: white; + width: 100%; + cursor: pointer; + transition: background-color 0.1s ease; + text-align: left; +} + +.autocomplete-item:hover, +.autocomplete-item.selected { + background-color: #f5f5f5; +} + +.autocomplete-item.selected { + background-color: #e3f2fd; +} + +.item-icon { + font-size: 20px; + flex-shrink: 0; +} + +.item-info { + flex: 1; + min-width: 0; +} + +.item-label { + font-weight: 500; + font-size: 14px; + color: #212121; + margin-bottom: 2px; +} + +.item-description { + font-size: 12px; + color: #757575; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-shortcut { + display: inline-block; + padding: 2px 6px; + background-color: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + color: #757575; + min-width: 20px; + text-align: center; +} + +.autocomplete-loading, +.autocomplete-empty { + padding: 24px; + text-align: center; + color: #757575; + font-size: 14px; +} + +.autocomplete-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid #e0e0e0; + border-top-color: #2196f3; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} +``` + +### Step 5: Complete Editor + +```tsx +// AutocompleteEditor.tsx +import {FC, useState, useCallback} from 'react' +import {MarkedInput} from 'rc-marked-input' +import {AutocompleteMark} from './AutocompleteMark' +import {AdvancedAutocompleteOverlay} from './AdvancedAutocompleteOverlay' +import type {AutocompleteSource, AutocompleteItem} from './types' +import './AutocompleteEditor.css' + +const AUTOCOMPLETE_SOURCES: AutocompleteSource[] = [ + { + trigger: '@', + category: 'Users', + fuzzy: true, + items: [ + {id: '1', label: 'Alice Johnson', value: 'alice', category: 'user', icon: '👤'}, + {id: '2', label: 'Bob Smith', value: 'bob', category: 'user', icon: '👤'}, + {id: '3', label: 'Charlie Brown', value: 'charlie', category: 'user', icon: '👤'}, + ], + }, + { + trigger: ':', + category: 'Emojis', + fuzzy: true, + items: [ + {id: 'smile', label: 'smile', value: '😊', category: 'emoji', icon: '😊'}, + {id: 'heart', label: 'heart', value: '❤️', category: 'emoji', icon: '❤️'}, + {id: 'fire', label: 'fire', value: '🔥', category: 'emoji', icon: '🔥'}, + {id: 'rocket', label: 'rocket', value: '🚀', category: 'emoji', icon: '🚀'}, + {id: 'star', label: 'star', value: '⭐', category: 'emoji', icon: '⭐'}, + ], + }, + { + trigger: '{', + category: 'Variables', + items: [ + { + id: 'name', + label: 'User Name', + value: 'user.name', + category: 'variable', + icon: '📝', + description: 'Current user name', + }, + { + id: 'email', + label: 'User Email', + value: 'user.email', + category: 'variable', + icon: '📧', + description: 'Current user email', + }, + { + id: 'date', + label: 'Current Date', + value: 'date.now', + category: 'variable', + icon: '📅', + description: "Today's date", + }, + ], + }, +] + +export const AutocompleteEditor: FC = () => { + const [value, setValue] = useState('') + const [recentItems, setRecentItems] = useState([]) + + const handleSelect = useCallback((item: AutocompleteItem) => { + setRecentItems(prev => { + const filtered = prev.filter(i => i.id !== item.id) + return [item, ...filtered].slice(0, 5) // Keep last 5 + }) + }, []) + + const autocompleteOptions = AUTOCOMPLETE_SOURCES.map(source => ({ + markup: `${source.trigger}[__value__](__meta__)`, + slots: { + mark: AutocompleteMark, + overlay: () => ( + + ), + }, + slotProps: { + mark: ({value, meta}: any) => { + const [category = '', icon = ''] = (meta || '').split('|') + return {value, category, icon} + }, + }, + })) + + return ( +
+

Advanced Autocomplete Demo

+
+ @ Users + : Emojis + {'{ Variables'} +
+ + + + {recentItems.length > 0 && ( +
+

Recent Selections

+
+ {recentItems.map(item => ( + + {item.icon} {item.label} + + ))} +
+
+ )} +
+ ) +} +``` + +```css +/* AutocompleteEditor.css */ +.autocomplete-editor-container { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.autocomplete-editor-container h2 { + margin: 0 0 12px 0; + font-size: 24px; + color: #212121; +} + +.triggers-info { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.trigger-badge { + padding: 4px 12px; + background-color: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 12px; + font-size: 13px; + color: #616161; + font-family: monospace; +} + +.autocomplete-editor { + border: 2px solid #e0e0e0; + border-radius: 12px; + padding: 16px; + min-height: 200px; + font-size: 16px; + line-height: 1.6; + outline: none; + transition: border-color 0.2s ease; +} + +.autocomplete-editor:focus { + border-color: #2196f3; +} + +.autocomplete-editor:empty::before { + content: attr(placeholder); + color: #bdbdbd; + pointer-events: none; +} + +.recent-items { + margin-top: 24px; + padding: 16px; + background-color: #fafafa; + border-radius: 8px; +} + +.recent-items h4 { + margin: 0 0 12px 0; + font-size: 14px; + color: #616161; +} + +.recent-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.recent-item { + padding: 6px 12px; + background-color: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + font-size: 13px; + color: #424242; +} +``` diff --git a/packages/website/src/content/docs/examples/hashtags.md b/packages/website/src/content/docs/examples/hashtags.md new file mode 100644 index 00000000..093ef400 --- /dev/null +++ b/packages/website/src/content/docs/examples/hashtags.md @@ -0,0 +1,675 @@ +--- +title: 🚧 Hashtags +description: Twitter/Instagram hashtag system tutorial - trending topics, autocomplete, click tracking, usage analytics +keywords: [hashtags, trending topics, social media, autocomplete, tagging, analytics] +--- + +This example demonstrates how to build a hashtag system like Twitter, Instagram, or LinkedIn with trending topics, autocomplete, and click tracking. + +## Use Case + +**What we're building:** + +- Type `#` to create hashtags +- Autocomplete with trending hashtags +- Click to filter by hashtag +- Track hashtag usage +- Trending hashtags sidebar + +**Where to use it:** + +- Social media platforms +- Blog tagging systems +- Content categorization +- Search and filtering +- Analytics dashboards + +## Complete Implementation + +### Step 1: Define Types + +```tsx +// types.ts +export interface Hashtag { + tag: string + count: number + trend: 'up' | 'down' | 'stable' +} + +export interface HashtagMarkProps { + tag: string + count?: number + onClick: (tag: string) => void +} +``` + +### Step 2: Hashtag Mark Component + +```tsx +// HashtagMark.tsx +import {FC} from 'react' +import type {HashtagMarkProps} from './types' +import './HashtagMark.css' + +export const HashtagMark: FC = ({tag, count, onClick}) => { + return ( + + ) +} +``` + +```css +/* HashtagMark.css */ +.hashtag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background-color: #e8f5e9; + border: 1px solid #81c784; + border-radius: 12px; + color: #2e7d32; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; +} + +.hashtag:hover { + background-color: #c8e6c9; + border-color: #66bb6a; + transform: translateY(-1px); +} + +.hashtag:active { + transform: translateY(0); +} + +.hashtag-count { + font-size: 11px; + padding: 2px 4px; + background-color: rgba(46, 125, 50, 0.1); + border-radius: 6px; + font-weight: 500; +} +``` + +### Step 3: Hashtag Overlay + +```tsx +// HashtagOverlay.tsx +import {FC, useState, useEffect} from 'react' +import {useOverlay} from 'rc-marked-input' +import type {Hashtag} from './types' +import './HashtagOverlay.css' + +interface HashtagOverlayProps { + trending: Hashtag[] + onSelect?: (tag: string) => void +} + +export const HashtagOverlay: FC = ({trending, onSelect}) => { + const {style, match, select, close, ref} = useOverlay() + const [selectedIndex, setSelectedIndex] = useState(0) + + const filteredHashtags = trending.filter(hashtag => hashtag.tag.toLowerCase().includes(match.value.toLowerCase())) + + useEffect(() => { + setSelectedIndex(0) + }, [match.value]) + + const selectHashtag = (hashtag: Hashtag) => { + select({ + value: hashtag.tag, + meta: hashtag.count.toString(), + }) + onSelect?.(hashtag.tag) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => Math.min(prev + 1, filteredHashtags.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => Math.max(prev - 1, 0)) + break + case 'Enter': + case 'Tab': + e.preventDefault() + if (filteredHashtags[selectedIndex]) { + selectHashtag(filteredHashtags[selectedIndex]) + } + break + case 'Escape': + e.preventDefault() + close() + break + } + } + + if (filteredHashtags.length === 0) { + return ( +
+
No hashtags found. Create #{match.value}?
+
+ ) + } + + return ( +
+
Trending Hashtags
+ {filteredHashtags.map((hashtag, index) => ( + + ))} +
+ ) +} +``` + +```css +/* HashtagOverlay.css */ +.hashtag-overlay { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + min-width: 280px; + max-height: 320px; + overflow-y: auto; + z-index: 1000; +} + +.hashtag-overlay-header { + padding: 12px 16px; + border-bottom: 1px solid #f5f5f5; + font-weight: 600; + font-size: 13px; + color: #616161; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.hashtag-overlay-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border: none; + background: white; + width: 100%; + cursor: pointer; + transition: background-color 0.15s ease; + text-align: left; +} + +.hashtag-overlay-item:hover, +.hashtag-overlay-item.selected { + background-color: #f5f5f5; +} + +.hashtag-overlay-item.selected { + background-color: #e8f5e9; +} + +.hashtag-overlay-tag { + font-weight: 600; + font-size: 14px; + color: #2e7d32; +} + +.hashtag-overlay-meta { + display: flex; + align-items: center; + gap: 8px; +} + +.hashtag-overlay-count { + font-size: 12px; + color: #757575; +} + +.hashtag-overlay-trend { + font-size: 14px; + font-weight: 700; +} + +.trend-up { + color: #4caf50; +} + +.trend-down { + color: #f44336; +} + +.trend-stable { + color: #9e9e9e; +} + +.hashtag-overlay-empty { + padding: 16px; + text-align: center; + color: #757575; + font-size: 14px; +} +``` + +### Step 4: Trending Sidebar + +```tsx +// TrendingSidebar.tsx +import {FC} from 'react' +import type {Hashtag} from './types' +import './TrendingSidebar.css' + +interface TrendingSidebarProps { + hashtags: Hashtag[] + onHashtagClick: (tag: string) => void +} + +export const TrendingSidebar: FC = ({hashtags, onHashtagClick}) => { + const topTrending = hashtags.sort((a, b) => b.count - a.count).slice(0, 10) + + return ( + + ) +} +``` + +```css +/* TrendingSidebar.css */ +.trending-sidebar { + width: 320px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 12px; + padding: 20px; +} + +.trending-sidebar h3 { + margin: 0 0 16px 0; + font-size: 20px; + color: #212121; +} + +.trending-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.trending-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: white; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + width: 100%; +} + +.trending-item:hover { + background-color: #f5f5f5; + border-color: #e0e0e0; +} + +.trending-rank { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background-color: #f5f5f5; + border-radius: 4px; + font-weight: 700; + font-size: 12px; + color: #616161; + flex-shrink: 0; +} + +.trending-info { + flex: 1; + min-width: 0; +} + +.trending-tag { + font-weight: 600; + font-size: 14px; + color: #2e7d32; + margin-bottom: 2px; +} + +.trending-count { + font-size: 12px; + color: #757575; +} + +.trending-icon { + font-size: 16px; + font-weight: 700; +} +``` + +### Step 5: Complete Editor + +```tsx +// HashtagEditor.tsx +import {FC, useState, useCallback} from 'react' +import {MarkedInput} from 'rc-marked-input' +import type {Option} from 'rc-marked-input' +import {HashtagMark} from './HashtagMark' +import {HashtagOverlay} from './HashtagOverlay' +import {TrendingSidebar} from './TrendingSidebar' +import type {Hashtag, HashtagMarkProps} from './types' +import './HashtagEditor.css' + +const TRENDING_HASHTAGS: Hashtag[] = [ + {tag: 'react', count: 125340, trend: 'up'}, + {tag: 'javascript', count: 98720, trend: 'stable'}, + {tag: 'typescript', count: 87650, trend: 'up'}, + {tag: 'webdev', count: 76420, trend: 'down'}, + {tag: 'programming', count: 65200, trend: 'stable'}, + {tag: 'coding', count: 54890, trend: 'up'}, + {tag: 'frontend', count: 43210, trend: 'stable'}, + {tag: 'backend', count: 38900, trend: 'up'}, + {tag: 'fullstack', count: 32450, trend: 'down'}, + {tag: 'nodejs', count: 28700, trend: 'stable'}, +] + +export const HashtagEditor: FC = () => { + const [value, setValue] = useState('') + const [selectedHashtag, setSelectedHashtag] = useState(null) + const [hashtagCounts, setHashtagCounts] = useState>({}) + + const handleHashtagClick = useCallback((tag: string) => { + console.log('Hashtag clicked:', tag) + setSelectedHashtag(tag) + // Filter posts by hashtag, navigate, etc. + }, []) + + const handleHashtagSelect = useCallback((tag: string) => { + setHashtagCounts(prev => ({ + ...prev, + [tag]: (prev[tag] || 0) + 1, + })) + }, []) + + const hashtagOption: Option = { + markup: '#[__value__](__meta__)', + slots: { + mark: HashtagMark, + overlay: () => , + }, + slotProps: { + mark: ({value, meta}) => ({ + tag: value || '', + count: meta ? parseInt(meta) : undefined, + onClick: handleHashtagClick, + }), + }, + } + + return ( +
+
+

Hashtag System Demo

+

Type # to add hashtags

+ + + + {selectedHashtag && ( +
+ Filtered by: #{selectedHashtag} + +
+ )} +
+ + +
+ ) +} +``` + +```css +/* HashtagEditor.css */ +.hashtag-editor-layout { + display: flex; + gap: 24px; + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.hashtag-editor-main { + flex: 1; + min-width: 0; +} + +.hashtag-editor-main h2 { + margin: 0 0 8px 0; + font-size: 24px; + color: #212121; +} + +.hint { + margin: 0 0 16px 0; + color: #757575; + font-size: 14px; +} + +.hashtag-editor { + border: 2px solid #e0e0e0; + border-radius: 12px; + padding: 16px; + min-height: 150px; + font-size: 16px; + line-height: 1.6; + outline: none; + transition: border-color 0.2s ease; +} + +.hashtag-editor:focus { + border-color: #4caf50; +} + +.hashtag-editor:empty::before { + content: attr(placeholder); + color: #bdbdbd; + pointer-events: none; +} + +.selected-hashtag-info { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding: 12px; + background-color: #e8f5e9; + border-radius: 8px; + color: #2e7d32; +} + +.selected-hashtag-info button { + margin-left: auto; + padding: 4px 12px; + background: white; + border: 1px solid #81c784; + border-radius: 4px; + color: #2e7d32; + cursor: pointer; + font-size: 13px; +} + +@media (max-width: 1024px) { + .hashtag-editor-layout { + flex-direction: column; + } + + .trending-sidebar { + width: 100%; + } +} +``` + +## Variations + +### Variation 1: Hashtag Analytics + +```tsx +const HashtagAnalytics: FC = () => { + const [analytics, setAnalytics] = useState({ + totalHashtags: 0, + uniqueHashtags: 0, + topHashtag: '', + avgPerPost: 0, + }) + + const analyzeHashtags = (text: string) => { + const matches = text.match(/#\[([^\]]+)\]/g) || [] + const hashtags = matches.map(m => m.match(/#\[([^\]]+)\]/)?.[1]) + const unique = new Set(hashtags) + + setAnalytics({ + totalHashtags: hashtags.length, + uniqueHashtags: unique.size, + topHashtag: getMostUsed(hashtags), + avgPerPost: hashtags.length / getPostCount(), + }) + } + + return ( +
+
+
{analytics.totalHashtags}
+
Total Hashtags
+
+ {/* More stats... */} +
+ ) +} +``` + +### Variation 2: Hashtag Groups/Categories + +```tsx +interface HashtagCategory { + name: string + hashtags: string[] + color: string +} + +const categories: HashtagCategory[] = [ + { + name: 'Technology', + hashtags: ['react', 'javascript', 'typescript'], + color: '#2196f3', + }, + { + name: 'Design', + hashtags: ['ui', 'ux', 'figma'], + color: '#9c27b0', + }, +] + +const CategorizedHashtagMark: FC = ({tag}) => { + const category = categories.find(c => c.hashtags.includes(tag.toLowerCase())) + + return ( + + #{tag} + + ) +} +``` + +### Variation 3: Related Hashtags + +```tsx +const RelatedHashtags: FC<{currentTag: string}> = ({currentTag}) => { + const [related, setRelated] = useState([]) + + useEffect(() => { + fetch(`/api/hashtags/${currentTag}/related`) + .then(res => res.json()) + .then(setRelated) + }, [currentTag]) + + return ( +
+

Related to #{currentTag}

+ {related.map(tag => ( + + ))} +
+ ) +} +``` diff --git a/packages/website/src/content/docs/examples/html-like-tags.md b/packages/website/src/content/docs/examples/html-like-tags.md new file mode 100644 index 00000000..01d33c7f --- /dev/null +++ b/packages/website/src/content/docs/examples/html-like-tags.md @@ -0,0 +1,519 @@ +--- +title: 🚧 HTML-like Tags +description: Custom HTML/BBCode tags tutorial - opening/closing tags, nested attributes, tag rendering, custom markup +keywords: [HTML tags, BBCode, custom tags, closing tags, tag attributes, nesting, XML markup] +--- + +This example demonstrates how to create custom HTML-like tags with the "two values pattern" for matching opening and closing tags. + +## Use Case + +**What we're building:** + +- Custom XML/HTML-like tags (`text`) +- Matching opening and closing tags +- Nested tags support +- Tag attributes +- Visual tag rendering + +**Where to use it:** + +- BBCode editors (forums) +- Custom markup languages +- Template systems +- Rich text with semantic markup +- Educational coding platforms + +## Complete Implementation + +### Step 1: Define Tag Types + +```tsx +// types.ts +export type TagType = 'color' | 'size' | 'bg' | 'align' | 'link' | 'box' + +export interface TagProps { + tagName: TagType + children: React.ReactNode + attributes?: Record +} +``` + +### Step 2: Tag Component + +```tsx +// CustomTag.tsx +import {FC} from 'react' +import type {TagProps} from './types' +import './CustomTag.css' + +export const CustomTag: FC = ({tagName, children, attributes}) => { + switch (tagName) { + case 'color': + return ( + + {children} + + ) + + case 'size': + return ( + + {children} + + ) + + case 'bg': + return ( + + {children} + + ) + + case 'align': + return ( +
+ {children} +
+ ) + + case 'link': + return ( + + {children} + + ) + + case 'box': + return ( +
+ {children} +
+ ) + + default: + return {children} + } +} +``` + +```css +/* CustomTag.css */ +.tag-color { + font-weight: 500; +} + +.tag-size { + display: inline-block; +} + +.tag-bg { + border-radius: 4px; +} + +.tag-align { + margin: 8px 0; +} + +.tag-link { + color: #2196f3; + text-decoration: underline; + cursor: pointer; +} + +.tag-link:hover { + color: #1976d2; +} + +.tag-box { + border: 2px solid; + border-radius: 8px; + padding: 12px; + margin: 12px 0; +} +``` + +### Step 3: Tag Palette + +```tsx +// TagPalette.tsx +import {FC} from 'react' +import './TagPalette.css' + +interface TagInfo { + name: string + syntax: string + description: string + example: string +} + +const AVAILABLE_TAGS: TagInfo[] = [ + { + name: 'color', + syntax: 'text', + description: 'Change text color', + example: 'Blue text', + }, + { + name: 'size', + syntax: 'text', + description: 'Change font size (px)', + example: 'Big text', + }, + { + name: 'bg', + syntax: 'text', + description: 'Highlight background', + example: 'Highlighted', + }, + { + name: 'align', + syntax: 'text', + description: 'Text alignment', + example: 'Centered', + }, + { + name: 'link', + syntax: 'text', + description: 'Create hyperlink', + example: 'Click here', + }, + { + name: 'box', + syntax: 'text', + description: 'Bordered box', + example: 'Important', + }, +] + +export const TagPalette: FC<{onInsert: (syntax: string) => void}> = ({onInsert}) => { + return ( + + ) +} +``` + +```css +/* TagPalette.css */ +.tag-palette { + width: 320px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 12px; + padding: 20px; + max-height: 600px; + overflow-y: auto; +} + +.tag-palette h3 { + margin: 0 0 16px 0; + font-size: 18px; + color: #212121; +} + +.tag-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.tag-info { + padding: 12px; + background-color: #f5f5f5; + border-radius: 8px; +} + +.tag-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.tag-name { + font-family: monospace; + color: #d32f2f; + font-size: 14px; +} + +.insert-button { + width: 24px; + height: 24px; + background-color: #2196f3; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + font-weight: 700; + transition: background-color 0.2s; +} + +.insert-button:hover { + background-color: #1976d2; +} + +.tag-description { + font-size: 13px; + color: #616161; + margin-bottom: 6px; +} + +.tag-syntax { + display: block; + font-family: monospace; + font-size: 12px; + background-color: white; + padding: 6px 8px; + border-radius: 4px; + color: #424242; +} +``` + +### Step 4: HTML-like Tag Editor + +```tsx +// HtmlLikeEditor.tsx +import {FC, useState} from 'react' +import {MarkedInput} from 'rc-marked-input' +import {CustomTag} from './CustomTag' +import {TagPalette} from './TagPalette' +import type {TagType, TagProps} from './types' +import './HtmlLikeEditor.css' + +export const HtmlLikeEditor: FC = () => { + const [value, setValue] = useState( + 'Try these tags:\n\nBlue text\nLarge text\nHighlighted\n\nNested: Red with large inside' + ) + + const handleInsert = (syntax: string) => { + setValue(prev => prev + '\n' + syntax) + } + + // Parse attributes from meta string + const parseAttributes = (meta: string): Record => { + const attrs: Record = {} + + // Handle single value: "red" or "20" + if (!meta.includes('=') && !meta.includes(' ')) { + attrs.value = meta + return attrs + } + + // Handle key=value pairs: "color=red size=20" + const pairs = meta.match(/(\w+)=([^\s]+)/g) || [] + pairs.forEach(pair => { + const [key, val] = pair.split('=') + attrs[key] = val + }) + + return attrs + } + + const tagOptions = [ + { + markup: '<__value__>__nested__', + slots: { + mark: (props: any) => { + const tagName = props.value as TagType + const attributes = parseAttributes(props.meta || '') + + return ( + + {props.children} + + ) + }, + }, + slotProps: { + mark: ({value, meta, children}: any) => ({ + value, + meta, + children, + }), + }, + }, + ] + + return ( +
+
+

HTML-like Tags Demo

+

Use custom tags like <color=red>text</color>

+ + + +
+ Two Values Pattern: Opening and closing tags must match. Use + <tagName=value>content</tagName> syntax. +
+
+ + +
+ ) +} +``` + +```css +/* HtmlLikeEditor.css */ +.html-like-editor-layout { + display: flex; + gap: 24px; + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.html-like-editor-main { + flex: 1; + min-width: 0; +} + +.html-like-editor-main h2 { + margin: 0 0 8px 0; + font-size: 24px; + color: #212121; +} + +.hint { + margin: 0 0 16px 0; + color: #757575; + font-size: 14px; +} + +.html-like-editor { + border: 2px solid #e0e0e0; + border-radius: 12px; + padding: 20px; + min-height: 400px; + font-size: 16px; + line-height: 1.8; + outline: none; + transition: border-color 0.2s ease; +} + +.html-like-editor:focus { + border-color: #2196f3; +} + +.html-like-editor:empty::before { + content: attr(placeholder); + color: #bdbdbd; + pointer-events: none; +} + +.editor-help { + margin-top: 16px; + padding: 12px; + background-color: #e3f2fd; + border-radius: 8px; + font-size: 14px; + color: #1565c0; +} + +@media (max-width: 1024px) { + .html-like-editor-layout { + flex-direction: column; + } + + .tag-palette { + width: 100%; + } +} +``` + +## Variations + +### Variation 1: Self-Closing Tags + +```tsx +const selfClosingOptions = [ + { + markup: '<__value__ />', + slots: { + mark: (props: any) => { + switch (props.value) { + case 'br': + return
+ case 'hr': + return
+ default: + return null + } + }, + }, + }, +] + +// Usage:
or
+``` + +### Variation 2: Complex Attributes + +```tsx +const parseComplexAttributes = (meta: string) => { + // Supports: + const attrs: Record = {} + + const regex = /(\w+)(?:=(?:"([^"]*)"|(\S+)))?/g + let match + + while ((match = regex.exec(meta))) { + const [, key, quotedValue, unquotedValue] = match + attrs[key] = quotedValue || unquotedValue || true + } + + return attrs +} +``` + +### Variation 3: Tag Validation + +```tsx +const validateTag = (opening: string, closing: string): {valid: boolean; error?: string} => { + if (opening !== closing) { + return { + valid: false, + error: `Mismatched tags: <${opening}> and `, + } + } + + const allowedTags = ['color', 'size', 'bg', 'link', 'box'] + if (!allowedTags.includes(opening)) { + return { + valid: false, + error: `Unknown tag: <${opening}>`, + } + } + + return {valid: true} +} +``` diff --git a/packages/website/src/content/docs/examples/markdown-editor.md b/packages/website/src/content/docs/examples/markdown-editor.md new file mode 100644 index 00000000..2361ec16 --- /dev/null +++ b/packages/website/src/content/docs/examples/markdown-editor.md @@ -0,0 +1,534 @@ +--- +title: 🚧 Markdown Editor +description: GitHub markdown editor tutorial - bold, italic, code blocks, links, live preview, keyboard shortcuts +keywords: [markdown editor, rich text, formatting, bold, italic, code blocks, live preview, WYSIWYG] +--- + +This example demonstrates how to build a Markdown editor with bold, italic, links, images, and live preview - similar to GitHub, Stack Overflow, or Reddit. + +## Use Case + +**What we're building:** + +- Markdown formatting (bold, italic, code, links) +- Live preview +- Formatting toolbar +- Keyboard shortcuts (Ctrl+B, Ctrl+I, etc.) +- Nested formatting support + +**Where to use it:** + +- Documentation platforms (GitHub, GitLab) +- Q&A sites (Stack Overflow) +- Blogging platforms (Dev.to, Medium) +- Note-taking apps (Obsidian, Notion) +- Comment systems + +## Complete Implementation + +### Step 1: Define Markdown Rules + +```tsx +// markdown.ts +export interface MarkdownRule { + id: string + markup: string + component: React.ComponentType + icon: string + label: string + shortcut?: string + insertPattern?: {before: string; after: string} +} + +export const MARKDOWN_RULES: MarkdownRule[] = [ + { + id: 'bold', + markup: '**__nested__**', + component: BoldMark, + icon: 'B', + label: 'Bold', + shortcut: 'Mod-b', + insertPattern: {before: '**', after: '**'}, + }, + { + id: 'italic', + markup: '*__nested__*', + component: ItalicMark, + icon: 'I', + label: 'Italic', + shortcut: 'Mod-i', + insertPattern: {before: '*', after: '*'}, + }, + { + id: 'code', + markup: '`__value__`', + component: CodeMark, + icon: '<>', + label: 'Inline Code', + insertPattern: {before: '`', after: '`'}, + }, + { + id: 'link', + markup: '[__value__](__meta__)', + component: LinkMark, + icon: '🔗', + label: 'Link', + shortcut: 'Mod-k', + insertPattern: {before: '[', after: '](url)'}, + }, + { + id: 'image', + markup: '![__value__](__meta__)', + component: ImageMark, + icon: '🖼️', + label: 'Image', + }, +] +``` + +### Step 2: Mark Components + +```tsx +// MarkdownMarks.tsx +import {FC, ReactNode} from 'react' +import './MarkdownMarks.css' + +export const BoldMark: FC<{children: ReactNode}> = ({children}) => {children} + +export const ItalicMark: FC<{children: ReactNode}> = ({children}) => {children} + +export const CodeMark: FC<{value: string}> = ({value}) => {value} + +export const LinkMark: FC<{value: string; meta: string}> = ({value, meta}) => ( + { + if (!meta || meta === 'url') { + e.preventDefault() + } + }} + > + {value} + +) + +export const ImageMark: FC<{value: string; meta: string}> = ({value, meta}) => ( + + {value} { + e.currentTarget.style.display = 'none' + }} + /> + {value} + +) +``` + +```css +/* MarkdownMarks.css */ +.md-bold { + font-weight: 700; +} + +.md-italic { + font-style: italic; +} + +.md-code { + font-family: 'Monaco', 'Menlo', monospace; + background-color: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 3px; + padding: 2px 6px; + font-size: 0.9em; + color: #d32f2f; +} + +.md-link { + color: #2196f3; + text-decoration: underline; + cursor: pointer; +} + +.md-link:hover { + color: #1976d2; +} + +.md-image { + display: inline-block; + position: relative; +} + +.md-image img { + max-width: 100%; + height: auto; + border-radius: 4px; +} + +.md-image-alt { + display: block; + font-size: 12px; + color: #757575; + margin-top: 4px; +} +``` + +### Step 3: Formatting Toolbar + +```tsx +// FormattingToolbar.tsx +import {FC} from 'react' +import type {MarkdownRule} from './markdown' +import './FormattingToolbar.css' + +interface FormattingToolbarProps { + rules: MarkdownRule[] + onFormat: (rule: MarkdownRule) => void +} + +export const FormattingToolbar: FC = ({rules, onFormat}) => { + return ( +
+ {rules.map(rule => ( + + ))} +
+ ) +} +``` + +```css +/* FormattingToolbar.css */ +.formatting-toolbar { + display: flex; + gap: 4px; + padding: 8px; + background-color: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 8px 8px 0 0; + border-bottom: none; +} + +.toolbar-button { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.toolbar-button:hover { + background-color: #eeeeee; + border-color: #bdbdbd; +} + +.toolbar-button:active { + transform: scale(0.95); +} + +.toolbar-icon { + font-weight: 700; + font-size: 14px; + color: #424242; +} +``` + +### Step 4: Markdown Editor + +```tsx +// MarkdownEditor.tsx +import {FC, useState, useCallback, useRef} from 'react' +import {MarkedInput} from 'rc-marked-input' +import {FormattingToolbar} from './FormattingToolbar' +import {MARKDOWN_RULES} from './markdown' +import type {MarkdownRule} from './markdown' +import './MarkdownEditor.css' + +export const MarkdownEditor: FC = () => { + const [value, setValue] = useState('') + const [showPreview, setShowPreview] = useState(false) + const editorRef = useRef(null) + + const handleFormat = useCallback( + (rule: MarkdownRule) => { + if (!rule.insertPattern) return + + const {before, after} = rule.insertPattern + const selection = window.getSelection() + const selectedText = selection?.toString() || 'text' + + // Insert formatted text at cursor + const newText = `${before}${selectedText}${after}` + + // Get cursor position and insert + const cursorPos = getCursorPosition(editorRef.current) + const beforeCursor = value.substring(0, cursorPos) + const afterCursor = value.substring(cursorPos) + + setValue(beforeCursor + newText + afterCursor) + }, + [value] + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const isMod = e.metaKey || e.ctrlKey + + if (isMod) { + const rule = MARKDOWN_RULES.find(r => { + if (!r.shortcut) return false + const [mod, key] = r.shortcut.split('-') + return key === e.key.toLowerCase() + }) + + if (rule) { + e.preventDefault() + handleFormat(rule) + } + } + }, + [handleFormat] + ) + + const markdownOptions = MARKDOWN_RULES.map(rule => ({ + markup: rule.markup, + slots: {mark: rule.component}, + })) + + return ( +
+
+

Markdown Editor

+ +
+ + + +
+
+ +
+ + {showPreview && ( +
+
{renderMarkdownPreview(value)}
+
+ )} +
+ +
+ {value.length} characters + + Markdown Guide + +
+
+ ) +} + +// Helper to render markdown preview +function renderMarkdownPreview(markdown: string): string { + // Simple preview - in production use a library like marked or remark + return markdown + .replace(/\*\*([^\*]+)\*\*/g, '$1') + .replace(/\*([^\*]+)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '$1') +} + +function getCursorPosition(element: HTMLElement | null): number { + if (!element) return 0 + + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + + const range = selection.getRangeAt(0) + const preCaretRange = range.cloneRange() + preCaretRange.selectNodeContents(element) + preCaretRange.setEnd(range.endContainer, range.endOffset) + + return preCaretRange.toString().length +} +``` + +```css +/* MarkdownEditor.css */ +.markdown-editor-container { + max-width: 900px; + margin: 0 auto; + padding: 20px; +} + +.markdown-editor-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.markdown-editor-header h2 { + margin: 0; + font-size: 24px; + color: #212121; +} + +.preview-toggle { + padding: 8px 16px; + background-color: #2196f3; + color: white; + border: none; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.preview-toggle:hover { + background-color: #1976d2; +} + +.markdown-editor-layout { + position: relative; +} + +.editor-pane { + transition: opacity 0.2s; +} + +.editor-pane.hidden { + display: none; +} + +.markdown-input { + border: 1px solid #e0e0e0; + border-radius: 0 0 8px 8px; + border-top: none; + padding: 16px; + min-height: 300px; + font-size: 15px; + line-height: 1.6; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + outline: none; +} + +.markdown-input:focus { + border-color: #2196f3; +} + +.preview-pane { + border: 1px solid #e0e0e0; + border-radius: 0 0 8px 8px; + border-top: none; + padding: 16px; + min-height: 300px; + background-color: #fafafa; +} + +.preview-content { + font-size: 15px; + line-height: 1.6; +} + +.markdown-editor-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; + font-size: 14px; + color: #757575; +} + +.markdown-help { + color: #2196f3; + text-decoration: none; +} + +.markdown-help:hover { + text-decoration: underline; +} +``` + +## Variations + +### Variation 1: Syntax Highlighting + +```tsx +import Prism from 'prismjs' +import 'prismjs/themes/prism.css' + +const CodeBlockMark: FC<{value: string; meta: string}> = ({value, meta}) => { + const highlighted = Prism.highlight(value, Prism.languages[meta] || Prism.languages.plaintext, meta) + + return ( +
+            
+        
+ ) +} +``` + +### Variation 2: Table Support + +```tsx +const TableMark: FC<{value: string}> = ({value}) => { + const rows = value.split('\\n').map(row => row.split('|')) + + return ( + + + + {rows[0].map((cell, i) => ( + + ))} + + + + {rows.slice(2).map((row, i) => ( + + {row.map((cell, j) => ( + + ))} + + ))} + +
{cell.trim()}
{cell.trim()}
+ ) +} +``` diff --git a/packages/website/src/content/docs/examples/mention-system.md b/packages/website/src/content/docs/examples/mention-system.md new file mode 100644 index 00000000..c361698b --- /dev/null +++ b/packages/website/src/content/docs/examples/mention-system.md @@ -0,0 +1,811 @@ +--- +title: 🚧 Mention System +description: Twitter/Slack @mention system tutorial - autocomplete, user avatars, clickable marks, backend integration, mobile optimization +keywords: [mentions, social media, chat, autocomplete, avatars, user tagging, backend integration] +--- + +This example demonstrates how to build a complete mention system with autocomplete, user avatars, and clickable mentions. Perfect for social media apps, chat applications, and collaborative tools. + +## Contents + +- [Use Case](#use-case) - What we're building and where to use it +- [Complete Implementation](#complete-implementation) - Step-by-step code walkthrough +- [Step-by-Step Explanation](#step-by-step-explanation) - Detailed explanation of each part +- [Variations](#variations) - Different implementation approaches +- [Mobile Optimization](#mobile-optimization) - Making it work on mobile devices +- [Integration with Backend](#integration-with-backend) - Server-side integration +- [Next Steps](#next-steps) - Further learning resources + +## Use Case + +**What we're building:** + +- Type `@` to trigger user suggestions +- Autocomplete with user search +- Clickable mentions that link to profiles +- User avatars and display names +- Keyboard navigation +- Mobile-friendly design + +**Where to use it:** + +- Social media posts (Twitter, LinkedIn) +- Chat applications (Slack, Discord) +- Comments sections +- Collaborative documents +- Task management tools + +## Complete Implementation + +### Step 1: Define Types + +```tsx +// types.ts +export interface User { + id: string + username: string + displayName: string + avatar: string +} + +export interface MentionProps { + username: string + userId: string + displayName: string + avatar: string + onMentionClick: (userId: string) => void +} +``` + +### Step 2: Create the Mention Component + +```tsx +// MentionMark.tsx +import {FC} from 'react' +import type {MentionProps} from './types' +import './MentionMark.css' + +export const MentionMark: FC = ({username, userId, displayName, avatar, onMentionClick}) => { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault() + onMentionClick(userId) + } + + return ( + + ) +} +``` + +### Step 3: Style the Mention + +```css +/* MentionMark.css */ +.mention { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px 2px 4px; + background-color: #e3f2fd; + border: 1px solid #90caf9; + border-radius: 16px; + color: #1976d2; + font-weight: 500; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + vertical-align: middle; +} + +.mention:hover { + background-color: #bbdefb; + border-color: #64b5f6; + transform: translateY(-1px); +} + +.mention:active { + transform: translateY(0); +} + +.mention:focus { + outline: 2px solid #2196f3; + outline-offset: 2px; +} + +.mention-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; +} + +.mention-username { + line-height: 1; +} +``` + +### Step 4: Create Custom Suggestions Overlay + +```tsx +// MentionOverlay.tsx +import {FC, useState, useEffect} from 'react' +import {useOverlay} from 'rc-marked-input' +import type {User} from './types' +import './MentionOverlay.css' + +interface MentionOverlayProps { + users: User[] +} + +export const MentionOverlay: FC = ({users}) => { + const {style, match, select, close, ref} = useOverlay() + const [selectedIndex, setSelectedIndex] = useState(0) + + // Filter users based on typed text + const filteredUsers = users.filter( + user => + user.username.toLowerCase().includes(match.value.toLowerCase()) || + user.displayName.toLowerCase().includes(match.value.toLowerCase()) + ) + + // Reset selection when filtered list changes + useEffect(() => { + setSelectedIndex(0) + }, [match.value]) + + // Keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => Math.min(prev + 1, filteredUsers.length - 1)) + break + + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => Math.max(prev - 1, 0)) + break + + case 'Enter': + case 'Tab': + e.preventDefault() + if (filteredUsers[selectedIndex]) { + selectUser(filteredUsers[selectedIndex]) + } + break + + case 'Escape': + e.preventDefault() + close() + break + } + } + + const selectUser = (user: User) => { + select({ + value: user.username, + meta: `${user.userId}|${user.displayName}|${user.avatar}`, + }) + } + + if (filteredUsers.length === 0) { + return ( +
+
No users found for "{match.value}"
+
+ ) + } + + return ( +
+ {filteredUsers.map((user, index) => ( + + ))} +
+ ) +} +``` + +### Step 5: Style the Overlay + +```css +/* MentionOverlay.css */ +.mention-overlay { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-height: 300px; + overflow-y: auto; + min-width: 280px; + z-index: 1000; +} + +.mention-overlay-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border: none; + background: white; + width: 100%; + cursor: pointer; + transition: background-color 0.15s ease; + text-align: left; +} + +.mention-overlay-item:hover, +.mention-overlay-item.selected { + background-color: #f5f5f5; +} + +.mention-overlay-item.selected { + background-color: #e3f2fd; +} + +.mention-overlay-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.mention-overlay-info { + flex: 1; + min-width: 0; +} + +.mention-overlay-name { + font-weight: 600; + font-size: 14px; + color: #212121; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mention-overlay-username { + font-size: 13px; + color: #757575; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mention-overlay-empty { + padding: 16px; + text-align: center; + color: #757575; + font-size: 14px; +} + +/* Scrollbar styling */ +.mention-overlay::-webkit-scrollbar { + width: 8px; +} + +.mention-overlay::-webkit-scrollbar-track { + background: #f5f5f5; + border-radius: 0 8px 8px 0; +} + +.mention-overlay::-webkit-scrollbar-thumb { + background: #bdbdbd; + border-radius: 4px; +} + +.mention-overlay::-webkit-scrollbar-thumb:hover { + background: #9e9e9e; +} +``` + +### Step 6: Compose the Editor + +```tsx +// MentionEditor.tsx +import {FC, useState} from 'react' +import {MarkedInput} from 'rc-marked-input' +import type {Option} from 'rc-marked-input' +import {MentionMark} from './MentionMark' +import {MentionOverlay} from './MentionOverlay' +import type {User, MentionProps} from './types' +import './MentionEditor.css' + +// Sample users data +const USERS: User[] = [ + { + id: '1', + username: 'alice', + displayName: 'Alice Johnson', + avatar: 'https://i.pravatar.cc/150?img=1', + }, + { + id: '2', + username: 'bob', + displayName: 'Bob Smith', + avatar: 'https://i.pravatar.cc/150?img=2', + }, + { + id: '3', + username: 'charlie', + displayName: 'Charlie Brown', + avatar: 'https://i.pravatar.cc/150?img=3', + }, + { + id: '4', + username: 'diana', + displayName: 'Diana Prince', + avatar: 'https://i.pravatar.cc/150?img=4', + }, + { + id: '5', + username: 'eve', + displayName: 'Eve Davis', + avatar: 'https://i.pravatar.cc/150?img=5', + }, +] + +export const MentionEditor: FC = () => { + const [value, setValue] = useState('') + + const handleMentionClick = (userId: string) => { + console.log('Mention clicked:', userId) + // Navigate to user profile, open modal, etc. + window.alert(`Navigating to user profile: ${userId}`) + } + + const mentionOption: Option = { + markup: '@[__value__](__meta__)', + slots: { + mark: MentionMark, + overlay: () => , + }, + slotProps: { + mark: ({value, meta}) => { + // Parse meta: "userId|displayName|avatar" + const [userId = '', displayName = '', avatar = ''] = (meta || '').split('|') + + return { + username: value || '', + userId, + displayName, + avatar, + onMentionClick: handleMentionClick, + } + }, + }, + } + + return ( +
+

Mention System Demo

+

Type @ to mention someone

+ + + +
+ {value.length} characters + +
+
+ ) +} +``` + +### Step 7: Editor Styles + +```css +/* MentionEditor.css */ +.mention-editor-container { + max-width: 600px; + margin: 0 auto; + padding: 20px; +} + +.mention-editor-container h2 { + margin: 0 0 8px 0; + font-size: 24px; + color: #212121; +} + +.hint { + margin: 0 0 16px 0; + color: #757575; + font-size: 14px; +} + +.mention-editor { + border: 2px solid #e0e0e0; + border-radius: 12px; + padding: 16px; + min-height: 120px; + font-size: 16px; + line-height: 1.5; + outline: none; + transition: border-color 0.2s ease; +} + +.mention-editor:focus { + border-color: #2196f3; +} + +.mention-editor:empty::before { + content: attr(placeholder); + color: #bdbdbd; + pointer-events: none; +} + +.mention-editor-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; +} + +.char-count { + font-size: 14px; + color: #757575; +} + +.post-button { + padding: 10px 24px; + background-color: #2196f3; + color: white; + border: none; + border-radius: 20px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.post-button:hover:not(:disabled) { + background-color: #1976d2; +} + +.post-button:disabled { + background-color: #e0e0e0; + color: #9e9e9e; + cursor: not-allowed; +} +``` + +## Step-by-Step Explanation + +### 1. Type System + +We define clear TypeScript interfaces: + +- `User` - User data structure +- `MentionProps` - Props for the MentionMark component + +This ensures type safety throughout the implementation. + +### 2. Mention Component + +The `MentionMark` component: + +- Displays user avatar and username +- Is keyboard accessible (button element) +- Has hover and focus states +- Triggers click handler for navigation + +### 3. Custom Overlay + +The `MentionOverlay` component: + +- Filters users based on typed text +- Supports keyboard navigation (↑↓, Enter, Esc) +- Shows user avatars and names +- Handles empty states + +### 4. Data Flow + +``` +User types "@" + → Overlay appears + → User types "ali" + → Filters to matching users (Alice) + → User selects (click or Enter) + → Inserts: @[alice](1|Alice Johnson|avatar.jpg) + → Renders as clickable mention with avatar +``` + +### 5. Metadata Encoding + +We encode multiple values in `meta` using pipe separator: + +``` +meta: "userId|displayName|avatarUrl" +``` + +This allows the mark to have all necessary data for rendering. + +## Variations + +### Variation 1: Async User Loading + +```tsx +const MentionOverlayAsync: FC = () => { + const {match, select} = useOverlay() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + const fetchUsers = async () => { + setLoading(true) + try { + const response = await fetch(`/api/users/search?q=${encodeURIComponent(match.value)}`) + const data = await response.json() + setUsers(data) + } catch (error) { + console.error('Failed to fetch users:', error) + } finally { + setLoading(false) + } + } + + if (match.value.length >= 2) { + fetchUsers() + } else { + setUsers([]) + } + }, [match.value]) + + if (loading) { + return
Loading...
+ } + + // Render filtered users... +} +``` + +### Variation 2: Group Mentions + +```tsx +interface Group { + id: string + name: string + memberCount: number +} + +const GroupMentionMark: FC<{name: string; memberCount: number}> = ({name, memberCount}) => { + return ( + + @{name} ({memberCount} members) + + ) +} + +// Usage +const groupOption: Option = { + markup: '@@[__value__](__meta__)', + slots: {mark: GroupMentionMark}, + slotProps: { + mark: ({value, meta}) => ({ + name: value, + memberCount: parseInt(meta || '0'), + }), + }, +} +``` + +### Variation 3: Rich User Cards on Hover + +```tsx +const MentionWithCard: FC = props => { + const [showCard, setShowCard] = useState(false) + const [cardData, setCardData] = useState(null) + + const handleMouseEnter = async () => { + setShowCard(true) + const data = await fetchUserCard(props.userId) + setCardData(data) + } + + return ( + setShowCard(false)}> + @{props.username} + {showCard && cardData && } + + ) +} +``` + +### Variation 4: Mention Notifications + +```tsx +const MentionEditorWithNotifications: FC = () => { + const [value, setValue] = useState('') + const [mentionedUsers, setMentionedUsers] = useState([]) + + const handleChange = (newValue: string) => { + setValue(newValue) + + // Extract mentioned user IDs + const mentionRegex = /@\[([^\]]+)\]\(([^|]+)\|/g + const matches = [...newValue.matchAll(mentionRegex)] + const userIds = matches.map(match => match[2]) + + setMentionedUsers(userIds) + } + + const handlePost = async () => { + await fetch('/api/posts', { + method: 'POST', + body: JSON.stringify({ + content: value, + mentionedUsers, // Send for notifications + }), + }) + } + + return ( +
+ + {mentionedUsers.length > 0 &&

{mentionedUsers.length} user(s) will be notified

} + +
+ ) +} +``` + +## Mobile Optimization + +```css +/* Add to MentionEditor.css */ +@media (max-width: 768px) { + .mention-editor-container { + padding: 12px; + } + + .mention-overlay { + max-width: calc(100vw - 32px); + max-height: 50vh; + } + + .mention-overlay-item { + padding: 10px; + } + + .mention-overlay-avatar { + width: 32px; + height: 32px; + } + + .mention { + font-size: 13px; + } + + .mention-avatar { + width: 18px; + height: 18px; + } +} +``` + +## Integration with Backend + +### Saving Mentions + +```tsx +const handleSubmit = async () => { + // Extract mentions from value + const mentions = extractMentions(value) + + await fetch('/api/posts', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + content: value, // Store raw marked text + mentions: mentions.map(m => ({ + userId: m.userId, + position: m.position, + })), + }), + }) +} + +function extractMentions(text: string) { + const regex = /@\[([^\]]+)\]\(([^|]+)\|/g + const mentions = [] + let match + + while ((match = regex.exec(text)) !== null) { + mentions.push({ + username: match[1], + userId: match[2], + position: match.index, + }) + } + + return mentions +} +``` + +### Loading Saved Mentions + +```tsx +const loadPost = async (postId: string) => { + const response = await fetch(`/api/posts/${postId}`) + const post = await response.json() + + // post.content is already in marked format: + // "Hey @[alice](1|Alice Johnson|avatar.jpg)!" + setValue(post.content) +} +``` + +## Accessibility + +```tsx +const AccessibleMentionMark: FC = props => { + return ( + + ) +} + +// Announce to screen readers +;
+ {mentionedUsers.length > 0 && `${mentionedUsers.length} users mentioned`} +
+``` diff --git a/packages/website/src/content/docs/examples/slash-commands.md b/packages/website/src/content/docs/examples/slash-commands.md new file mode 100644 index 00000000..542d3659 --- /dev/null +++ b/packages/website/src/content/docs/examples/slash-commands.md @@ -0,0 +1,985 @@ +--- +title: 🚧 Slash Commands +description: Notion-style slash commands tutorial - command menu, text transformation, keyboard navigation, content insertion +keywords: [slash commands, command menu, Notion, text transformation, command palette, content insertion] +--- + +This example demonstrates how to build a slash command system like Notion, Slack, or Linear. Type `/` to trigger a menu of commands that transform text or insert content. + +## Use Case + +**What we're building:** + +- Type `/` to show command menu +- Commands for headings, lists, code blocks +- Search/filter commands as you type +- Keyboard navigation +- Command execution with content transformation + +**Where to use it:** + +- Note-taking apps (Notion, Obsidian) +- Document editors (Google Docs, Confluence) +- Chat applications (Slack, Discord) +- Project management tools (Linear, Height) +- Code editors with command palette + +## Complete Implementation + +### Step 1: Define Command Types + +```tsx +// types.ts +export type CommandType = + | 'heading1' + | 'heading2' + | 'heading3' + | 'bulletList' + | 'numberedList' + | 'quote' + | 'code' + | 'divider' + | 'todo' + +export interface Command { + id: CommandType + label: string + description: string + icon: string + aliases: string[] + execute: (editor: EditorContext) => void +} + +export interface EditorContext { + insertText: (text: string) => void + replaceSelection: (text: string) => void + getCurrentLine: () => string +} + +export interface CommandMarkProps { + command: CommandType + label: string +} +``` + +### Step 2: Define Available Commands + +````tsx +// commands.ts +import type {Command} from './types' + +export const COMMANDS: Command[] = [ + { + id: 'heading1', + label: 'Heading 1', + description: 'Large section heading', + icon: 'H1', + aliases: ['h1', 'title'], + execute: ctx => { + ctx.replaceSelection('# ') + }, + }, + { + id: 'heading2', + label: 'Heading 2', + description: 'Medium section heading', + icon: 'H2', + aliases: ['h2', 'subtitle'], + execute: ctx => { + ctx.replaceSelection('## ') + }, + }, + { + id: 'heading3', + label: 'Heading 3', + description: 'Small section heading', + icon: 'H3', + aliases: ['h3'], + execute: ctx => { + ctx.replaceSelection('### ') + }, + }, + { + id: 'bulletList', + label: 'Bullet List', + description: 'Create a simple bullet list', + icon: '•', + aliases: ['ul', 'list', 'bullet'], + execute: ctx => { + ctx.replaceSelection('- ') + }, + }, + { + id: 'numberedList', + label: 'Numbered List', + description: 'Create a numbered list', + icon: '1.', + aliases: ['ol', 'ordered'], + execute: ctx => { + ctx.replaceSelection('1. ') + }, + }, + { + id: 'quote', + label: 'Quote', + description: 'Insert a blockquote', + icon: '"', + aliases: ['blockquote', 'citation'], + execute: ctx => { + ctx.replaceSelection('> ') + }, + }, + { + id: 'code', + label: 'Code Block', + description: 'Insert a code block', + icon: '<>', + aliases: ['codeblock', 'pre'], + execute: ctx => { + ctx.insertText('\n```\n\n```\n') + }, + }, + { + id: 'divider', + label: 'Divider', + description: 'Insert a horizontal divider', + icon: '―', + aliases: ['hr', 'line', 'separator'], + execute: ctx => { + ctx.insertText('\n---\n') + }, + }, + { + id: 'todo', + label: 'To-do List', + description: 'Create a checklist', + icon: '☑', + aliases: ['checkbox', 'task', 'checklist'], + execute: ctx => { + ctx.replaceSelection('- [ ] ') + }, + }, +] + +export function searchCommands(query: string): Command[] { + const lowerQuery = query.toLowerCase() + + return COMMANDS.filter( + cmd => + cmd.label.toLowerCase().includes(lowerQuery) || + cmd.description.toLowerCase().includes(lowerQuery) || + cmd.aliases.some(alias => alias.includes(lowerQuery)) + ) +} +```` + +### Step 3: Command Mark Component + +```tsx +// CommandMark.tsx +import {FC} from 'react' +import type {CommandMarkProps} from './types' +import './CommandMark.css' + +export const CommandMark: FC = ({command, label}) => { + return /{label} +} +``` + +```css +/* CommandMark.css */ +.command-mark { + display: inline-block; + padding: 2px 8px; + background-color: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 13px; + color: #616161; + font-weight: 500; +} + +.command-mark:hover { + background-color: #eeeeee; +} +``` + +### Step 4: Command Overlay Component + +```tsx +// CommandOverlay.tsx +import {FC, useState, useEffect, useRef} from 'react' +import {useOverlay} from 'rc-marked-input' +import {COMMANDS, searchCommands} from './commands' +import type {Command} from './types' +import './CommandOverlay.css' + +export const CommandOverlay: FC = () => { + const {style, match, select, close, ref} = useOverlay() + const [selectedIndex, setSelectedIndex] = useState(0) + const selectedRef = useRef(null) + + // Filter commands based on search + const filteredCommands = searchCommands(match.value) + + // Reset selection when results change + useEffect(() => { + setSelectedIndex(0) + }, [match.value]) + + // Scroll selected item into view + useEffect(() => { + selectedRef.current?.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }) + }, [selectedIndex]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => Math.min(prev + 1, filteredCommands.length - 1)) + break + + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => Math.max(prev - 1, 0)) + break + + case 'Enter': + case 'Tab': + e.preventDefault() + if (filteredCommands[selectedIndex]) { + selectCommand(filteredCommands[selectedIndex]) + } + break + + case 'Escape': + e.preventDefault() + close() + break + } + } + + const selectCommand = (command: Command) => { + select({ + value: command.id, + meta: command.label, + }) + + // Execute command after selection + // This will be handled by the editor + } + + if (filteredCommands.length === 0) { + return ( +
+
No commands found for "/{match.value}"
+
+ ) + } + + return ( +
+
+ Commands + ↑↓ to navigate • Enter to select +
+ +
+ {filteredCommands.map((command, index) => ( + + ))} +
+
+ ) +} +``` + +### Step 5: Overlay Styles + +```css +/* CommandOverlay.css */ +.command-overlay { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + width: 360px; + max-height: 400px; + overflow: hidden; + z-index: 1000; +} + +.command-overlay-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #f5f5f5; + background-color: #fafafa; +} + +.command-overlay-title { + font-weight: 600; + font-size: 13px; + color: #424242; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.command-overlay-hint { + font-size: 12px; + color: #9e9e9e; +} + +.command-overlay-list { + max-height: 352px; + overflow-y: auto; +} + +.command-overlay-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: none; + background: white; + width: 100%; + cursor: pointer; + transition: background-color 0.1s ease; + text-align: left; +} + +.command-overlay-item:hover, +.command-overlay-item.selected { + background-color: #f5f5f5; +} + +.command-overlay-item.selected { + background-color: #e3f2fd; +} + +.command-overlay-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background-color: #f5f5f5; + border-radius: 6px; + font-weight: 700; + font-size: 16px; + color: #616161; + flex-shrink: 0; +} + +.command-overlay-item.selected .command-overlay-icon { + background-color: #2196f3; + color: white; +} + +.command-overlay-info { + flex: 1; + min-width: 0; +} + +.command-overlay-label { + font-weight: 500; + font-size: 14px; + color: #212121; + margin-bottom: 2px; +} + +.command-overlay-description { + font-size: 12px; + color: #757575; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.command-overlay-aliases { + font-size: 11px; + color: #9e9e9e; + padding: 2px 6px; + background-color: #fafafa; + border-radius: 3px; + font-family: monospace; +} + +.command-overlay-empty { + padding: 24px; + text-align: center; + color: #757575; + font-size: 14px; +} + +/* Scrollbar */ +.command-overlay-list::-webkit-scrollbar { + width: 6px; +} + +.command-overlay-list::-webkit-scrollbar-track { + background: transparent; +} + +.command-overlay-list::-webkit-scrollbar-thumb { + background: #e0e0e0; + border-radius: 3px; +} + +.command-overlay-list::-webkit-scrollbar-thumb:hover { + background: #bdbdbd; +} +``` + +### Step 6: Editor Component + +```tsx +// SlashCommandEditor.tsx +import {FC, useState, useRef} from 'react' +import {MarkedInput} from 'rc-marked-input' +import type {Option} from 'rc-marked-input' +import {CommandMark} from './CommandMark' +import {CommandOverlay} from './CommandOverlay' +import {COMMANDS} from './commands' +import type {CommandMarkProps, CommandType} from './types' +import './SlashCommandEditor.css' + +export const SlashCommandEditor: FC = () => { + const [value, setValue] = useState('') + const editorRef = useRef(null) + + const handleChange = (newValue: string) => { + // Check if a command was just inserted + const commandMatch = newValue.match(/\/\[([^\]]+)\]\(([^\)]+)\)/) + + if (commandMatch) { + const commandId = commandMatch[1] as CommandType + const command = COMMANDS.find(cmd => cmd.id === commandId) + + if (command) { + // Remove the command mark from the text + const beforeCommand = newValue.substring(0, commandMatch.index) + const afterCommand = newValue.substring(commandMatch.index! + commandMatch[0].length) + + // Execute the command + const editorContext = { + insertText: (text: string) => { + setValue(beforeCommand + text + afterCommand) + }, + replaceSelection: (text: string) => { + setValue(beforeCommand + text + afterCommand) + }, + getCurrentLine: () => { + const lines = beforeCommand.split('\n') + return lines[lines.length - 1] + }, + } + + command.execute(editorContext) + return + } + } + + setValue(newValue) + } + + const commandOption: Option = { + markup: '/[__value__](__meta__)', + slots: { + mark: CommandMark, + overlay: CommandOverlay, + }, + slotProps: { + mark: ({value, meta}) => ({ + command: value as CommandType, + label: meta || value || '', + }), + }, + } + + return ( +
+

Slash Commands Demo

+

Type / to see available commands

+ + + +
+

Keyboard Shortcuts

+
+
+ / + Open command menu +
+
+ + Navigate commands +
+
+ Enter + Execute command +
+
+ Esc + Close menu +
+
+
+
+ ) +} +``` + +### Step 7: Editor Styles + +```css +/* SlashCommandEditor.css */ +.slash-command-editor-container { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.slash-command-editor-container h2 { + margin: 0 0 8px 0; + font-size: 28px; + color: #212121; +} + +.hint { + margin: 0 0 20px 0; + color: #757575; + font-size: 14px; +} + +.slash-command-editor { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 24px; + min-height: 300px; + font-size: 16px; + line-height: 1.6; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + outline: none; + transition: border-color 0.2s ease; +} + +.slash-command-editor:focus { + border-color: #2196f3; +} + +.keyboard-shortcuts { + margin-top: 32px; + padding: 20px; + background-color: #fafafa; + border-radius: 8px; +} + +.keyboard-shortcuts h3 { + margin: 0 0 16px 0; + font-size: 16px; + color: #424242; +} + +.shortcuts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.shortcut { + display: flex; + align-items: center; + gap: 12px; +} + +.shortcut kbd { + display: inline-block; + padding: 4px 8px; + background-color: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + font-weight: 600; + color: #424242; + min-width: 28px; + text-align: center; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.shortcut span { + font-size: 13px; + color: #616161; +} +``` + +## Step-by-Step Explanation + +### 1. Command System Architecture + +``` +User types "/" + → Overlay shows all commands + → User types "hea" + → Filters to heading commands + → User selects "Heading 1" (Enter or click) + → Inserts /[heading1](Heading 1) + → onChange detects command mark + → Executes command.execute() + → Replaces mark with "# " + → User continues typing +``` + +### 2. Command Execution Flow + +When a command is selected: + +1. Mark is inserted: `/[heading1](Heading 1)` +2. `onChange` handler detects the pattern +3. Finds corresponding command from `COMMANDS` +4. Calls `command.execute(editorContext)` +5. Command transforms text (e.g., adds `# `) +6. Mark is removed, transformation remains + +### 3. Search and Filtering + +Commands are searchable by: + +- Label ("Heading 1") +- Description ("Large section heading") +- Aliases (["h1", "title"]) + +### 4. Keyboard Navigation + +Full keyboard support: + +- `↑↓` - Navigate commands +- `Enter` / `Tab` - Execute selected command +- `Esc` - Close menu +- Auto-scroll selected item into view + +## Variations + +### Variation 1: Commands with Parameters + +```tsx +interface ParameterizedCommand extends Command { + parameters?: { + name: string + type: 'text' | 'number' | 'select' + options?: string[] + }[] +} + +const linkCommand: ParameterizedCommand = { + id: 'link', + label: 'Link', + description: 'Insert a hyperlink', + icon: '🔗', + aliases: ['url', 'anchor'], + parameters: [ + {name: 'url', type: 'text'}, + {name: 'text', type: 'text'}, + ], + execute: (ctx, params) => { + const {url, text} = params + ctx.insertText(`[${text}](${url})`) + }, +} +``` + +### Variation 2: Recent Commands + +```tsx +const useRecentCommands = () => { + const [recent, setRecent] = useState([]) + + const addRecentCommand = (commandId: string) => { + setRecent(prev => { + const filtered = prev.filter(id => id !== commandId) + return [commandId, ...filtered].slice(0, 5) + }) + } + + return {recent, addRecentCommand} +} + +// Show recent commands first in overlay +const sortedCommands = [ + ...filteredCommands.filter(cmd => recent.includes(cmd.id)), + ...filteredCommands.filter(cmd => !recent.includes(cmd.id)), +] +``` + +### Variation 3: Command Categories + +```tsx +interface CommandCategory { + id: string + label: string + icon: string + commands: Command[] +} + +const CATEGORIES: CommandCategory[] = [ + { + id: 'text', + label: 'Text Formatting', + icon: '✏️', + commands: [ + /* heading commands */ + ], + }, + { + id: 'lists', + label: 'Lists', + icon: '📋', + commands: [ + /* list commands */ + ], + }, + { + id: 'media', + label: 'Media', + icon: '🖼️', + commands: [ + /* image, video commands */ + ], + }, +] + +// Render with categories +{ + CATEGORIES.map(category => ( +
+
+ {category.icon} + {category.label} +
+ {category.commands.map(cmd => ( + + ))} +
+ )) +} +``` + +### Variation 4: Custom Command Registration + +```tsx +const useCustomCommands = () => { + const [customCommands, setCustomCommands] = useState([]) + + const registerCommand = (command: Command) => { + setCustomCommands(prev => [...prev, command]) + } + + const allCommands = [...COMMANDS, ...customCommands] + + return {allCommands, registerCommand} +} + +// Usage +const MyEditor: FC = () => { + const {allCommands, registerCommand} = useCustomCommands() + + useEffect(() => { + // Register custom command + registerCommand({ + id: 'timestamp', + label: 'Insert Timestamp', + description: 'Insert current date and time', + icon: '🕐', + aliases: ['time', 'date', 'now'], + execute: ctx => { + const timestamp = new Date().toLocaleString() + ctx.insertText(timestamp) + }, + }) + }, []) + + return +} +``` + +### Variation 5: AI-Powered Commands + +```tsx +const aiCommands: Command[] = [ + { + id: 'ai-continue', + label: 'Continue Writing', + description: 'Let AI continue your text', + icon: '✨', + aliases: ['ai', 'continue', 'complete'], + execute: async ctx => { + const currentText = ctx.getCurrentLine() + const completion = await fetchAICompletion(currentText) + ctx.insertText(completion) + }, + }, + { + id: 'ai-summarize', + label: 'Summarize', + description: 'Summarize selected text', + icon: '📝', + aliases: ['summary', 'tldr'], + execute: async ctx => { + const selection = ctx.getSelection() + const summary = await fetchAISummary(selection) + ctx.replaceSelection(summary) + }, + }, +] +``` + +## Mobile Optimization + +```css +@media (max-width: 768px) { + .command-overlay { + width: calc(100vw - 32px); + max-height: 60vh; + } + + .command-overlay-header { + padding: 10px 12px; + } + + .command-overlay-hint { + display: none; /* Hide on mobile */ + } + + .command-overlay-item { + padding: 10px 12px; + } + + .command-overlay-icon { + width: 28px; + height: 28px; + font-size: 14px; + } + + .command-overlay-label { + font-size: 13px; + } + + .command-overlay-description { + font-size: 11px; + } + + .command-overlay-aliases { + display: none; /* Hide aliases on mobile */ + } +} +``` + +## Advanced Features + +### Command History and Undo + +```tsx +interface CommandHistory { + commandId: string + timestamp: number + textBefore: string + textAfter: string +} + +const useCommandHistory = () => { + const [history, setHistory] = useState([]) + + const addToHistory = (entry: CommandHistory) => { + setHistory(prev => [...prev, entry].slice(-20)) // Keep last 20 + } + + const undo = () => { + if (history.length === 0) return null + + const lastCommand = history[history.length - 1] + setHistory(prev => prev.slice(0, -1)) + return lastCommand.textBefore + } + + return {history, addToHistory, undo} +} +``` + +### Command Shortcuts + +```tsx +const COMMAND_SHORTCUTS: Record = { + 'Mod-1': 'heading1', + 'Mod-2': 'heading2', + 'Mod-3': 'heading3', + 'Mod-Shift-7': 'bulletList', + 'Mod-Shift-8': 'numberedList', +} + +const handleKeyDown = (e: React.KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey + const shift = e.shiftKey + const key = e.key + + const shortcut = `${mod ? 'Mod-' : ''}${shift ? 'Shift-' : ''}${key}` + const commandId = COMMAND_SHORTCUTS[shortcut] + + if (commandId) { + e.preventDefault() + const command = COMMANDS.find(cmd => cmd.id === commandId) + if (command) { + command.execute(editorContext) + } + } +} +``` diff --git a/packages/website/src/content/docs/guides/configuration.md b/packages/website/src/content/docs/guides/configuration.md new file mode 100644 index 00000000..8efee033 --- /dev/null +++ b/packages/website/src/content/docs/guides/configuration.md @@ -0,0 +1,685 @@ +--- +title: 🚧 Configuration +description: Configure Markput - inline configuration, factory pattern, markup patterns, options, and advanced setup for React text editors +keywords: [configuration, factory pattern, markup patterns, options, inline config, setup, advanced] +--- + +Markput provides flexible configuration options through the `MarkedInput` component and the `createMarkedInput` factory. This guide covers all configuration patterns from basic to advanced. + +## Configuration Methods + +There are two main ways to configure Markput: + +### Method 1: Inline Configuration + +Pass configuration directly to ``: + +```tsx +import {MarkedInput} from 'rc-marked-input' +import {useState} from 'react' + +function Editor() { + const [value, setValue] = useState('') + + return ( + + ) +} +``` + +**Use when:** + +- Configuration changes based on props or state +- Different instances need different configs +- Building a reusable editor component + +### Method 2: Factory Configuration + +Create a pre-configured component with `createMarkedInput`: + +```tsx +import {createMarkedInput} from 'rc-marked-input' + +const ConfiguredEditor = createMarkedInput({ + Mark: MyMarkComponent, + options: [ + /* configuration */ + ], +}) + +// Use anywhere +function App() { + const [value, setValue] = useState('') + return +} +``` + +**Use when:** + +- Configuration is static across the app +- Want to reuse the same config multiple times +- Prefer a cleaner, more declarative API + +**Key Difference:** `createMarkedInput` bakes configuration into the component, while inline config allows runtime changes. + +## The Options Array + +The `options` array defines how different markup patterns are handled: + +```tsx +options={[ + { + markup: '@[__value__](__meta__)', + slots: { mark: MentionComponent }, + slotProps: { + mark: ({ value, meta }) => ({ userId: meta }), + overlay: { trigger: '@', data: users } + } + }, + { + markup: '#[__value__]', + slots: { mark: HashtagComponent }, + slotProps: { + overlay: { trigger: '#', data: hashtags } + } + } +]} +``` + +### Option Structure + +Each option has three main properties: + +| Property | Type | Purpose | +| ----------- | -------- | ----------------------------------------- | +| `markup` | `string` | Pattern to match (e.g., `'@[__value__]'`) | +| `slots` | `object` | Per-option components (mark, overlay) | +| `slotProps` | `object` | Props for slot components | + +## Markup Patterns + +The `markup` property defines what text pattern to match: + +```tsx +// Basic mention +markup: '@[__value__]' +// Matches: @[Alice], @[Bob] + +// Mention with metadata +markup: '@[__value__](__meta__)' +// Matches: @[Alice](user:1), @[Bob](user:2) + +// Reversed pattern +markup: '@(__meta__)[__value__]' +// Matches: @(user:1)[Alice], @(user:2)[Bob] + +// Nested marks +markup: '**__nested__**' +// Matches: **bold text**, **bold with *italic*** + +// HTML-like tags +markup: '<__value__>__nested__' +// Matches:
content
, text +``` + +**Placeholders:** + +- `__value__` - Main content (plain text) +- `__meta__` - Metadata (plain text) +- `__nested__` - Content that can contain other marks + +See [How It Works](../introduction/how-it-works#markup-patterns) for detailed explanation of markup patterns. + +## slotProps.mark + +The `slotProps.mark` property transforms markup data into component props. It supports two forms: + +### Function Form (Dynamic) + +Transform markup data dynamically: + +```tsx +slotProps: { + mark: ({value, meta, nested, children}) => ({ + // Transform markup data into component props + label: value, + userId: meta, + onClick: () => console.log('Clicked', value), + }) +} +``` + +**Parameters:** + +- `value` - Content from `__value__` placeholder +- `meta` - Content from `__meta__` placeholder +- `nested` - Raw string from `__nested__` placeholder +- `children` - Rendered React nodes for nested marks + +**Example:** Twitter-style mentions + +```tsx +{ + markup: '@[__value__](__meta__)', + slotProps: { + mark: ({ value, meta }) => ({ + username: value, + userId: meta, + href: `/users/${meta}`, + onClick: (e) => { + e.preventDefault() + navigateToUser(meta) + } + }) + } +} +``` + +### Object Form (Static) + +Pass fixed props to all marks: + +```tsx +slotProps: { + mark: { + variant: 'outlined', + color: 'primary', + size: 'small' + } +} +``` + +**Use when:** + +- All marks should have the same props +- Props don't depend on markup content +- Working with UI library components (MUI, Chakra) + +**Example:** MUI Chip + +```tsx +import {Chip} from '@mui/material' + +;, + }, + }, + }, + ]} +/> +``` + +### Function vs Object Comparison + +```tsx +// ✅ Function - Access markup data +mark: ({ value, meta }) => ({ + label: value, // From markup + userId: meta, // From markup + onClick: () => {} // Custom logic +}) + +// ✅ Object - Fixed props +mark: { + variant: 'outlined', // Static + color: 'primary', // Static + size: 'small' // Static +} + +// ❌ Cannot mix forms +mark: { + label: value, // Error: 'value' is not defined + color: 'primary' +} +``` + +## slotProps.overlay + +Configure the suggestion overlay: + +```tsx +slotProps: { + overlay: { + trigger: '@', // Character that shows overlay + data: ['Alice', 'Bob'] // Suggestion data + } +} +``` + +### Common Configurations + +**Basic suggestions:** + +```tsx +overlay: { + trigger: '@', + data: ['Alice', 'Bob', 'Charlie'] +} +``` + +**With objects:** + +```tsx +overlay: { + trigger: '/', + data: [ + { label: 'Heading', value: 'h1' }, + { label: 'Bold', value: 'bold' }, + { label: 'Italic', value: 'italic' } + ] +} +``` + +**Dynamic data (from API):** + +```tsx +const [users, setUsers] = useState([]) + +useEffect(() => { + fetchUsers().then(setUsers) +}, []) + +// In options: +overlay: { + trigger: '@', + data: users.map(u => u.username) +} +``` + +**Multiple triggers:** + +```tsx +options={[ + { + markup: '@[__value__]', + slotProps: { + overlay: { trigger: '@', data: users } + } + }, + { + markup: '#[__value__]', + slotProps: { + overlay: { trigger: '#', data: hashtags } + } + }, + { + markup: '/[__value__]', + slotProps: { + overlay: { trigger: '/', data: commands } + } + } +]} +``` + +## Slots: Global vs Per-Option + +You can specify components at two levels: + +### Global Components + +Apply to all marks/overlays unless overridden: + +```tsx + +``` + +### Per-Option Components + +Override global components for specific patterns: + +```tsx + +``` + +### Component Resolution Priority + +For **each** option, components are resolved in this order: + +``` +1. option.slots.mark (highest priority) +2. MarkedInput.Mark prop +3. undefined (error if no Mark) + +1. option.slots.overlay (highest priority) +2. MarkedInput.Overlay prop +3. Default Suggestions (built-in) +``` + +**Example:** + +```tsx + +``` + +## Complete Examples + +### Example 1: Multi-Trigger Editor + +```tsx +import {MarkedInput} from 'rc-marked-input' +import {useState} from 'react' + +function MultiTriggerEditor() { + const [value, setValue] = useState('') + + const users = ['Alice', 'Bob', 'Charlie'] + const hashtags = ['react', 'javascript', 'typescript'] + const commands = ['heading', 'bold', 'italic'] + + return ( + ( + {value} + )} + options={[ + { + markup: '@[__value__](mention)', + slotProps: { + overlay: {trigger: '@', data: users}, + }, + }, + { + markup: '#[__value__](hashtag)', + slotProps: { + overlay: {trigger: '#', data: hashtags}, + }, + }, + { + markup: '/[__value__](command)', + slotProps: { + overlay: {trigger: '/', data: commands}, + }, + }, + ]} + /> + ) +} +``` + +### Example 2: Per-Pattern Components + +```tsx +import {MarkedInput} from 'rc-marked-input' + +const MentionMark = ({value, meta}) => ( + + @{value} + +) + +const HashtagMark = ({value}) => ( + + #{value} + +) + +function Editor() { + const [value, setValue] = useState('') + + return ( + ({value, meta}), + overlay: {trigger: '@', data: users}, + }, + }, + { + markup: '#[__value__]', + slots: {mark: HashtagMark}, + slotProps: { + mark: ({value}) => ({value}), + overlay: {trigger: '#', data: hashtags}, + }, + }, + ]} + /> + ) +} +``` + +### Example 3: createMarkedInput Factory + +```tsx +import {createMarkedInput} from 'rc-marked-input' +import {Chip} from '@mui/material' + +const MentionEditor = createMarkedInput({ + Mark: Chip, + options: [ + { + markup: '@[__value__](__meta__)', + slotProps: { + mark: ({value, meta}) => ({ + label: `@${value}`, + variant: 'filled', + color: 'primary', + size: 'small', + onClick: () => console.log('Clicked', meta), + }), + overlay: { + trigger: '@', + data: ['Alice', 'Bob', 'Charlie'], + }, + }, + }, + ], +}) + +// Use anywhere in your app +function App() { + const [value, setValue] = useState('') + return +} +``` + +## TypeScript Configuration + +Type your configuration for better IDE support: + +```tsx +import {MarkedInput} from 'rc-marked-input' +import type {Option} from 'rc-marked-input' + +interface MentionProps { + username: string + userId: string + onClick: () => void +} + +const options: Option[] = [ + { + markup: '@[__value__](__meta__)', + slotProps: { + mark: ({value, meta}) => ({ + username: value || '', + userId: meta || '', + onClick: () => console.log('Clicked'), + }), + overlay: { + trigger: '@', + data: ['Alice', 'Bob'], + }, + }, + }, +] + +function TypedEditor() { + return Mark={MentionComponent} options={options} value="" onChange={() => {}} /> +} +``` + +## Common Patterns + +### Pattern: Mention System + +```tsx +{ + markup: '@[__value__](__meta__)', + slotProps: { + mark: ({ value, meta }) => ({ + username: value, + userId: meta + }), + overlay: { trigger: '@', data: users } + } +} +``` + +### Pattern: Slash Commands + +```tsx +{ + markup: '/[__value__](__meta__)', + slotProps: { + mark: ({ value }) => ({ command: value }), + overlay: { + trigger: '/', + data: ['heading', 'bold', 'italic'] + } + } +} +``` + +### Pattern: Template Variables + +```tsx +{ + markup: '{{__value__}}', + slotProps: { + mark: ({ value }) => ({ variable: value }), + overlay: { + trigger: '{{', + data: ['name', 'email', 'date'] + } + } +} +``` + +### Pattern: Markdown-style + +```tsx +options={[ + { + markup: '**__nested__**', + slotProps: { + mark: ({ children }) => ({ style: { fontWeight: 'bold' }, children }) + } + }, + { + markup: '*__nested__*', + slotProps: { + mark: ({ children }) => ({ style: { fontStyle: 'italic' }, children }) + } + } +]} +``` + +## Best Practices + +### ✅ Do + +```tsx +// Use createMarkedInput for static configs +const Editor = createMarkedInput({ Mark, options }) + +// Memoize dynamic options +const options = useMemo(() => [...], [dependencies]) + +// Type your components +const Mark: React.FC = ({ value }) => {value} + +// Use descriptive metadata +markup: '@[__value__](user:__meta__)' +``` + +### ❌ Don't + +```tsx +// Don't create new functions in render +slotProps: { + mark: ({ value }) => ({ onClick: () => {} }) // Creates new function each render +} + +// Don't use complex logic in slotProps +slotProps: { + mark: ({ value, meta }) => { + // Heavy computation + const result = expensiveOperation(value) + return { data: result } + } +} + +// Don't mix object and function forms +mark: { + label: value, // Error: value not defined + color: 'blue' +} +``` + +**Try it live:** [CodeSandbox - Configured Component](https://codesandbox.io/s/configured-marked-input-305v6m) + +**Questions?** Ask in [GitHub Discussions](https://github.com/Nowely/marked-input/discussions). diff --git a/packages/website/src/content/docs/guides/dynamic-marks.md b/packages/website/src/content/docs/guides/dynamic-marks.md new file mode 100644 index 00000000..779b053d --- /dev/null +++ b/packages/website/src/content/docs/guides/dynamic-marks.md @@ -0,0 +1,659 @@ +--- +title: 🚧 Dynamic Marks +description: Build interactive React marks - useMark hook tutorial for editable, removable, focusable mentions and marks +keywords: [useMark hook, interactive marks, editable, removable, focusable, mark state, event handling] +--- + +Dynamic marks are marks that users can interact with - edit, remove, focus, or trigger custom actions. Markput provides the `useMark()` hook to build dynamic mark components. + +## The useMark Hook + +The `useMark()` hook gives your Mark component access to its internal state and methods: + +```tsx +import {useMark} from 'rc-marked-input' + +function DynamicMark() { + const mark = useMark() + + return {mark.value} +} +``` + +### useMark API + +The hook returns an object with these properties: + +| Property | Type | Description | +| ------------- | ------------------------ | -------------------------------------- | +| `value` | `string \| undefined` | Mark's value (from `__value__`) | +| `meta` | `string \| undefined` | Mark's metadata (from `__meta__`) | +| `nested` | `string \| undefined` | Raw nested content (from `__nested__`) | +| `label` | `string` | Display label (value or nested) | +| `ref` | `RefObject` | Ref for keyboard focus | +| `change()` | `function` | Update the mark | +| `remove()` | `function` | Remove the mark | +| `readOnly` | `boolean \| undefined` | Whether editor is read-only | +| `depth` | `number` | Nesting depth (0 for root) | +| `hasChildren` | `boolean` | Whether mark has nested children | +| `parent` | `MarkToken \| undefined` | Parent mark (if nested) | +| `children` | `Token[]` | Child tokens (if nested) | + +## Editable Marks + +Make marks editable with `contentEditable`: + +```tsx +import {MarkedInput, useMark} from 'rc-marked-input' +import {useState} from 'react' + +function EditableMark() { + const {label, change} = useMark() + + const handleInput = e => { + const newValue = e.currentTarget.textContent || '' + change( + {value: newValue, meta: ''}, + {silent: true} // Don't trigger re-render + ) + } + + return ( + + {label} + + ) +} + +function Editor() { + const [value, setValue] = useState('Edit this @[mark]!') + + return +} +``` + +### The `change()` Method + +Update a mark's value and metadata: + +```tsx +change( + { + value: string, // New value + meta?: string // New metadata (optional) + }, + { + silent?: boolean // Skip re-render (default: false) + } +) +``` + +**Parameters:** + +- `value` - New mark content +- `meta` - New metadata (optional) +- `options.silent` - If `true`, updates editor state without re-rendering the mark itself. Use for `contentEditable` to prevent cursor jumping. + +**Example: Edit with metadata** + +```tsx +function EditableTagMark() { + const {label, meta, change} = useMark() + const [color, setColor] = useState(meta || 'blue') + + const handleEdit = e => { + change({value: e.target.value, meta: color}) + } + + const handleColorChange = newColor => { + setColor(newColor) + change({value: label, meta: newColor}) + } + + return ( +
+ + +
+ ) +} +``` + +### Silent Updates + +The `silent` option prevents the mark from re-rendering itself: + +```tsx +// ❌ Without silent - cursor jumps on each keystroke +change({value: newValue}) + +// ✅ With silent - smooth editing experience +change({value: newValue}, {silent: true}) +``` + +**When to use `silent: true`:** + +- `contentEditable` elements +- Inline editing +- Real-time input updates +- Preventing cursor position loss + +**When NOT to use:** + +- Button clicks +- Dropdown selections +- Color picker changes +- Any non-text input + +## Removable Marks + +Allow users to delete marks: + +```tsx +import {useMark} from 'rc-marked-input' + +function RemovableMark() { + const {label, remove} = useMark() + + return ( + + {label} + + + ) +} +``` + +### The `remove()` Method + +Removes the mark from the editor: + +```tsx +remove() // No parameters, no return value +``` + +**Example: Removable tag with confirmation** + +```tsx +function ConfirmRemovableMark() { + const {label, remove} = useMark() + + const handleRemove = () => { + if (window.confirm(`Remove "${label}"?`)) { + remove() + } + } + + return ( +
+ {label} + +
+ ) +} +``` + +**Example: Keyboard shortcut (Backspace)** + +```tsx +function KeyboardRemovableMark() { + const {label, remove, ref} = useMark() + + const handleKeyDown = e => { + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault() + remove() + } + } + + return ( + + {label} + + ) +} +``` + +## Focusable Marks + +Make marks focusable for keyboard navigation: + +```tsx +import {useMark} from 'rc-marked-input' + +function FocusableMark() { + const {label, ref} = useMark() + + return ( + + {label} + + ) +} +``` + +### The `ref` Property + +The `ref` from `useMark()` enables keyboard focus management: + +```tsx +const {ref} = useMark() + +return ( + + {content} + +) +``` + +**Built-in keyboard behavior with `ref`:** + +- **Arrow Left/Right**: Navigate between marks +- **Backspace/Delete**: Remove marks (if handler provided) +- **Tab**: Focus next mark + +**Example: Focus with visual feedback** + +```tsx +function FocusableMark() { + const {label, ref} = useMark() + const [focused, setFocused] = useState(false) + + return ( + setFocused(true)} + onBlur={() => setFocused(false)} + style={{ + outline: focused ? '2px solid blue' : 'none', + padding: '2px 4px', + }} + > + {label} + + ) +} +``` + +## Read-Only State + +Disable interactions when editor is read-only: + +```tsx +function InteractiveMark() { + const {label, remove, readOnly} = useMark() + + return ( + + {label} + {!readOnly && } + + ) +} + +// Usage +; +``` + +## Nested Mark Information + +Access parent/child relationships: + +```tsx +function NestedAwareMark({children}) { + const {label, depth, hasChildren, parent} = useMark() + + return ( +
+ Depth: {depth} + {hasChildren && (has children)} + {parent && (child of {parent.value})} +
{children || label}
+
+ ) +} +``` + +### Nesting Properties + +| Property | Type | Description | +| ------------- | ------------------------ | ------------------------ | +| `depth` | `number` | Nesting level (0 = root) | +| `hasChildren` | `boolean` | Has nested marks | +| `parent` | `MarkToken \| undefined` | Parent mark token | +| `children` | `Token[]` | Child token array | + +**Example: Collapse/Expand nested marks** + +```tsx +function CollapsibleMark({children}) { + const {label, hasChildren} = useMark() + const [collapsed, setCollapsed] = useState(false) + + if (!hasChildren) { + return {label} + } + + return ( +
+ + {label} + {!collapsed &&
{children}
} +
+ ) +} +``` + +## Complete Examples + +### Example 1: Tag Editor + +```tsx +import {MarkedInput, useMark} from 'rc-marked-input' +import {useState} from 'react' + +function TagMark() { + const {label, remove, ref} = useMark() + + return ( + + {label} + + + ) +} + +function TagEditor() { + const [value, setValue] = useState('Skills: @[React] @[TypeScript] @[Node.js]') + + return ( + + ) +} +``` + +### Example 2: Inline Editable + +```tsx +function EditableInlineMark() { + const {label, change, ref} = useMark() + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState(label) + + const handleSave = () => { + change({value: editValue}) + setIsEditing(false) + } + + const handleCancel = () => { + setEditValue(label) + setIsEditing(false) + } + + if (isEditing) { + return ( + + setEditValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleSave() + if (e.key === 'Escape') handleCancel() + }} + autoFocus + /> + + + + ) + } + + return ( + setIsEditing(true)} className="mark" title="Double-click to edit"> + {label} + + ) +} +``` + +### Example 3: Color-Coded Tags + +```tsx +function ColorTagMark() { + const {label, meta, change, remove} = useMark() + const color = meta || 'blue' + + const colors = { + blue: '#e3f2fd', + green: '#e8f5e9', + red: '#ffebee', + yellow: '#fff9c4', + } + + return ( + + {label} + + + + ) +} + +// Usage +; +``` + +## Best Practices + +### ✅ Do + +```tsx +// Use silent for contentEditable +change({ value: newValue }, { silent: true }) + +// Provide keyboard navigation +{label} + +// Respect readOnly state +{!readOnly && } + +// Handle edge cases +const handleRemove = () => { + if (confirm('Remove?')) remove() +} +``` + +### ❌ Don't + +```tsx +// Don't call change in render +function Bad() { + const {change} = useMark() + change({value: 'new'}) // Infinite loop! + return Bad +} + +// Don't forget suppressContentEditableWarning +;{label} // Warning! + +// Don't modify mark during parent render +function Bad() { + const {remove} = useMark() + useEffect(() => { + if (someCondition) remove() // Can cause issues + }) +} +``` + +## Edge Cases & Troubleshooting + +### Cursor Jumping in ContentEditable + +**Problem:** Cursor jumps to end on every keystroke + +**Solution:** Use `silent: true` + +```tsx +change({value: newValue}, {silent: true}) +``` + +### Remove Not Working + +**Problem:** `remove()` doesn't do anything + +**Checklist:** + +1. Is `readOnly` set to `true`? +2. Is the Mark component inside ``? +3. Check browser console for errors + +### Focus Not Working + +**Problem:** Can't focus mark with keyboard + +**Solution:** Add both `ref` and `tabIndex` + +```tsx + + {label} + +``` + +### State Not Updating + +**Problem:** Mark doesn't re-render after `change()` + +**Cause:** Using `silent: true` when you shouldn't + +**Solution:** Only use `silent` for `contentEditable` + +```tsx +// ✅ For contentEditable + change({...}, { silent: true })} /> + +// ✅ For button clicks (no silent) + +``` + +## TypeScript Usage + +Type your dynamic marks: + +```tsx +import {useMark} from 'rc-marked-input' +import type {MarkHandler} from 'rc-marked-input' + +interface TagMarkProps { + // Custom props if needed +} + +function TypedDynamicMark(props: TagMarkProps) { + const mark: MarkHandler = useMark() + + const handleEdit = (newValue: string) => { + mark.change({value: newValue}) + } + + return ( + + {mark.label} + + + ) +} +``` + +## Performance Considerations + +### Memoize Event Handlers + +```tsx +const handleRemove = useCallback(() => { + if (confirm('Remove?')) { + remove() + } +}, [remove]) +``` + +### Avoid Heavy Computations + +```tsx +// ❌ Bad - runs on every render +function Bad() { + const {label} = useMark() + const processed = expensiveOperation(label) // Slow! + return {processed} +} + +// ✅ Good - memoized +function Good() { + const {label} = useMark() + const processed = useMemo(() => expensiveOperation(label), [label]) + return {processed} +} +``` + +**Try it:** [CodeSandbox - Dynamic Marks](https://codesandbox.io/s/dynamic-mark-w2nj82) diff --git a/packages/website/src/content/docs/guides/keyboard-handling.md b/packages/website/src/content/docs/guides/keyboard-handling.md new file mode 100644 index 00000000..a890234e --- /dev/null +++ b/packages/website/src/content/docs/guides/keyboard-handling.md @@ -0,0 +1,932 @@ +--- +title: 🚧 Keyboard Handling +description: Keyboard shortcuts and navigation in Markput - built-in keys, custom hotkeys, onKeyDown events, Cmd/Ctrl modifiers +keywords: [keyboard shortcuts, hotkeys, onKeyDown, navigation, Cmd, Ctrl, modifier keys, focus management] +--- + +Markput provides built-in keyboard support for common editing operations and allows you to add custom keyboard shortcuts. This guide covers everything from basic navigation to advanced keyboard interactions. + +## Built-in Keyboard Support + +Markput handles common keyboard operations automatically: + +| Key | Action | Context | +| -------------------- | ---------------------- | -------------------- | +| **Arrow Left/Right** | Navigate between marks | When mark is focused | +| **Backspace** | Delete previous mark | At mark boundary | +| **Delete** | Delete next mark | At mark boundary | +| **Enter** | Insert line break | In editor | +| **Tab** | Focus next mark | When mark is focused | +| **Esc** | Close overlay | When overlay is open | +| **Arrow Up/Down** | Navigate suggestions | When overlay is open | + +These behaviors work out of the box without any configuration. + +## Basic Keyboard Events + +### Listening to Key Presses + +Use `slotProps.container` to listen to keyboard events on the editor: + +```tsx +import {MarkedInput} from 'rc-marked-input' +import {useState} from 'react' + +function EditorWithKeyboard() { + const [value, setValue] = useState('') + + const handleKeyDown = (e: React.KeyboardEvent) => { + console.log('Key pressed:', e.key) + console.log('Modifier keys:', { + ctrl: e.ctrlKey, + meta: e.metaKey, + shift: e.shiftKey, + alt: e.altKey, + }) + } + + return ( + console.log('Key released:', e.key), + onKeyPress: e => console.log('Character:', e.key), + }, + }} + /> + ) +} +``` + +### Key Event Properties + +```tsx +interface KeyboardEvent { + key: string // 'Enter', 'a', 'Backspace', etc. + code: string // Physical key: 'KeyA', 'Enter', etc. + ctrlKey: boolean // Ctrl pressed (Cmd on Mac) + metaKey: boolean // Meta/Cmd key + shiftKey: boolean // Shift pressed + altKey: boolean // Alt/Option pressed + repeat: boolean // Key is being held down +} +``` + +## Custom Keyboard Shortcuts + +### Save Shortcut (Ctrl/Cmd+S) + +```tsx +function EditorWithSave() { + const [value, setValue] = useState('') + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Ctrl+S (Windows/Linux) or Cmd+S (Mac) + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault() + console.log('Saving:', value) + // Call your save function here + saveToServer(value) + } + } + + return ( + + ) +} +``` + +### Multiple Shortcuts + +```tsx +function EditorWithShortcuts() { + const [value, setValue] = useState('') + + const handleKeyDown = (e: React.KeyboardEvent) => { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 + const modKey = isMac ? e.metaKey : e.ctrlKey + + if (modKey) { + switch (e.key.toLowerCase()) { + case 's': + e.preventDefault() + console.log('Save') + break + + case 'b': + e.preventDefault() + console.log('Bold') + insertMarkup('**', '**') + break + + case 'i': + e.preventDefault() + console.log('Italic') + insertMarkup('*', '*') + break + + case 'k': + e.preventDefault() + console.log('Insert link') + showLinkDialog() + break + + case 'z': + e.preventDefault() + if (e.shiftKey) { + console.log('Redo') + } else { + console.log('Undo') + } + break + } + } + } + + return ( + + ) +} +``` + +### Shortcut Helper + +Create a reusable shortcut matcher: + +```tsx +type Shortcut = { + key: string + ctrl?: boolean + meta?: boolean + shift?: boolean + alt?: boolean + action: () => void +} + +function useKeyboardShortcuts(shortcuts: Shortcut[]) { + return (e: React.KeyboardEvent) => { + for (const shortcut of shortcuts) { + const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase() + const ctrlMatch = shortcut.ctrl ? e.ctrlKey : true + const metaMatch = shortcut.meta ? e.metaKey : true + const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey + const altMatch = shortcut.alt ? e.altKey : !e.altKey + + if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) { + e.preventDefault() + shortcut.action() + break + } + } + } +} + +// Usage +function Editor() { + const [value, setValue] = useState('') + + const handleKeyDown = useKeyboardShortcuts([ + {key: 's', ctrl: true, action: () => console.log('Save')}, + {key: 'b', ctrl: true, action: () => console.log('Bold')}, + {key: 'i', ctrl: true, action: () => console.log('Italic')}, + {key: 'z', ctrl: true, shift: true, action: () => console.log('Redo')}, + {key: 'z', ctrl: true, action: () => console.log('Undo')}, + ]) + + return ( + + ) +} +``` + +## Mark-Specific Keyboard Events + +### Handling Keys Within Marks + +Use `useMark()` to handle keyboard events specific to marks: + +```tsx +import {useMark} from 'rc-marked-input' + +function KeyboardAwareMark() { + const {label, remove, ref} = useMark() + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'Backspace': + case 'Delete': + e.preventDefault() + remove() + break + + case 'Enter': + e.preventDefault() + console.log('Edit mark:', label) + break + + case 'Escape': + e.preventDefault() + // Blur the mark + e.currentTarget.blur() + break + } + } + + return ( + + {label} + + ) +} +``` + +### Editable Mark with Enter Key + +```tsx +function EditableMark() { + const {label, change, ref} = useMark() + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState(label) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isEditing) { + if (e.key === 'Enter') { + e.preventDefault() + change({value: editValue}) + setIsEditing(false) + } else if (e.key === 'Escape') { + e.preventDefault() + setEditValue(label) + setIsEditing(false) + } + } else { + if (e.key === 'Enter') { + e.preventDefault() + setIsEditing(true) + } else if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault() + remove() + } + } + } + + if (isEditing) { + return ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => setIsEditing(false)} + autoFocus + /> + ) + } + + return ( + + {label} + + ) +} +``` + +## Navigation Between Marks + +### Arrow Key Navigation + +Built-in arrow key navigation works when marks have `ref` and `tabIndex`: + +```tsx +function NavigableMark() { + const {label, ref} = useMark() + + return ( + { + e.currentTarget.style.border = '1px solid blue' + }} + onBlur={e => { + e.currentTarget.style.border = '1px solid transparent' + }} + > + {label} + + ) +} +``` + +**Keyboard behavior:** + +- **Arrow Right** - Focus next mark +- **Arrow Left** - Focus previous mark +- **Tab** - Focus next mark +- **Shift+Tab** - Focus previous mark + +### Custom Navigation Logic + +Override default navigation: + +```tsx +function CustomNavigationMark() { + const {label, ref} = useMark() + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowRight') { + e.preventDefault() + // Custom logic: jump to end of editor + const editor = e.currentTarget.closest('[contenteditable]') + if (editor) { + const range = document.createRange() + const sel = window.getSelection() + range.selectNodeContents(editor) + range.collapse(false) + sel?.removeAllRanges() + sel?.addRange(range) + } + } + } + + return ( + + {label} + + ) +} +``` + +## Overlay Keyboard Interactions + +The overlay handles keyboard events automatically: + +| Key | Action | +| -------------- | ------------------------------------ | +| **Arrow Up** | Select previous item | +| **Arrow Down** | Select next item | +| **Enter** | Insert selected item | +| **Esc** | Close overlay | +| **Tab** | Insert selected item (if configured) | + +### Custom Overlay Keyboard Behavior + +```tsx +import {useOverlay} from 'rc-marked-input' + +function CustomOverlay() { + const {select, close, match} = useOverlay() + const [selectedIndex, setSelectedIndex] = useState(0) + + const items = ['Alice', 'Bob', 'Charlie'] + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => Math.min(prev + 1, items.length - 1)) + break + + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => Math.max(prev - 1, 0)) + break + + case 'Enter': + case 'Tab': + e.preventDefault() + select({value: items[selectedIndex]}) + break + + case 'Escape': + e.preventDefault() + close() + break + + // Custom: Ctrl+Number for quick selection + case '1': + case '2': + case '3': + if (e.ctrlKey) { + e.preventDefault() + const index = parseInt(e.key) - 1 + if (items[index]) { + select({value: items[index]}) + } + } + break + } + } + + return ( +
+ {items.map((item, index) => ( +
select({value: item})} + > + {item} + {e.ctrlKey && index < 3 && (Ctrl+{index + 1})} +
+ ))} +
+ ) +} +``` + +## Preventing Default Behavior + +### When to Prevent Default + +```tsx +function Editor() { + const handleKeyDown = (e: React.KeyboardEvent) => { + // Prevent browser shortcuts + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 's': // Save + case 'b': // Bold + case 'i': // Italic + case 'u': // Underline + case 'k': // Link + e.preventDefault() + // Your custom logic + break + } + } + + // Prevent Enter if you want single-line input + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + // Handle submission + } + + // Prevent Tab if you want custom behavior + if (e.key === 'Tab') { + e.preventDefault() + // Custom tab handling + } + } + + return ( + + ) +} +``` + +### Allowing Specific Defaults + +```tsx +function SelectivePreventDefault() { + const handleKeyDown = (e: React.KeyboardEvent) => { + // Only prevent on specific conditions + if (e.key === 'Enter') { + // Allow Shift+Enter for new lines + if (e.shiftKey) { + return // Let default behavior happen + } + + // Prevent plain Enter + e.preventDefault() + handleSubmit() + } + } + + return ( + + ) +} +``` + +## Focus Management + +### Programmatic Focus + +Focus the editor programmatically: + +```tsx +function EditorWithFocus() { + const editorRef = useRef(null) + + const focusEditor = () => { + editorRef.current?.focus() + } + + return ( +
+ + +
+ ) +} +``` + +### Auto-Focus on Mount + +```tsx +function AutoFocusEditor() { + const editorRef = useRef(null) + + useEffect(() => { + editorRef.current?.focus() + }, []) + + return ( + + ) +} +``` + +### Focus Trap + +Keep focus within editor: + +```tsx +function FocusTrapEditor() { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + e.preventDefault() + // Keep focus in editor, don't tab out + } + } + + return ( + + ) +} +``` + +## Complete Examples + +### Example 1: Vim-Style Navigation + +```tsx +function VimStyleEditor() { + const [value, setValue] = useState('') + const [mode, setMode] = useState<'normal' | 'insert'>('insert') + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (mode === 'normal') { + switch (e.key) { + case 'i': + e.preventDefault() + setMode('insert') + break + + case 'h': // Left + e.preventDefault() + moveCursor(-1) + break + + case 'l': // Right + e.preventDefault() + moveCursor(1) + break + + case 'x': // Delete + e.preventDefault() + deleteAtCursor() + break + + case 'u': // Undo + e.preventDefault() + undo() + break + } + } else if (mode === 'insert') { + if (e.key === 'Escape') { + e.preventDefault() + setMode('normal') + } + } + } + + return ( +
+
Mode: {mode.toUpperCase()}
+ +
+ ) +} +``` + +### Example 2: Keyboard Shortcuts Legend + +```tsx +function EditorWithLegend() { + const [value, setValue] = useState('') + const [showLegend, setShowLegend] = useState(false) + + const shortcuts = [ + {keys: 'Ctrl/Cmd + S', action: 'Save'}, + {keys: 'Ctrl/Cmd + B', action: 'Bold'}, + {keys: 'Ctrl/Cmd + I', action: 'Italic'}, + {keys: 'Ctrl/Cmd + K', action: 'Insert Link'}, + {keys: 'Ctrl/Cmd + Z', action: 'Undo'}, + {keys: 'Ctrl/Cmd + Shift + Z', action: 'Redo'}, + {keys: 'Esc', action: 'Close Overlay'}, + ] + + const handleKeyDown = (e: React.KeyboardEvent) => { + const mod = e.ctrlKey || e.metaKey + + if (e.key === '?' && e.shiftKey) { + e.preventDefault() + setShowLegend(!showLegend) + return + } + + if (mod) { + switch (e.key.toLowerCase()) { + case 's': + e.preventDefault() + save() + break + // ... other shortcuts + } + } + } + + return ( +
+ + + {showLegend && ( +
+

Keyboard Shortcuts

+ {shortcuts.map(shortcut => ( +
+ {shortcut.keys} - {shortcut.action} +
+ ))} +

Press ? to toggle this legend

+
+ )} +
+ ) +} +``` + +### Example 3: Single-Line Input with Enter to Submit + +```tsx +function SingleLineInput() { + const [value, setValue] = useState('') + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit(value) + setValue('') // Clear after submit + } + } + + const handleSubmit = (text: string) => { + console.log('Submitted:', text) + // Your submit logic here + } + + return ( +
+ + Press Enter to submit +
+ ) +} +``` + +## Best Practices + +### ✅ Do + +```tsx +// Use useCallback for stable event handlers +const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Handler logic + }, + [dependencies] +) + +// Check for both Ctrl and Meta for cross-platform support +if (e.ctrlKey || e.metaKey) { + // Shortcut logic +} + +// Prevent default when handling shortcuts +if (e.key === 's' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + save() +} + +// Use lowercase for key comparison +if (e.key.toLowerCase() === 'a') { + // Handle 'A' or 'a' +} + +// Add visual feedback for focused marks +; e.currentTarget.classList.add('focused')} +> + {label} + +``` + +### ❌ Don't + +```tsx +// Don't forget preventDefault for custom shortcuts +if (e.key === 's' && e.ctrlKey) { + save() // Browser will still open save dialog! +} + +// Don't hardcode Cmd/Ctrl +if (e.metaKey) { // Only works on Mac! + // Wrong +} + +// Don't compare key codes (deprecated) +if (e.keyCode === 13) { // Use e.key === 'Enter' instead + // Wrong +} + +// Don't block all keyboard events +e.preventDefault() // Prevents typing! +e.stopPropagation() // Breaks event bubbling + +// Don't forget accessibility + // Missing keyboard support! + {label} + +``` + +## Accessibility Considerations + +### Keyboard-Only Navigation + +Ensure all functionality is accessible via keyboard: + +```tsx +function AccessibleMark() { + const {label, remove, ref} = useMark() + + return ( + { + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault() + remove() + } + }} + onClick={e => { + e.preventDefault() + // Visual selection + }} + > + {label} + + ) +} +``` + +### Announce Keyboard Shortcuts + +```tsx +function AccessibleEditor() { + return ( +
+
+ +
+ +
+ Type @ to mention someone. Use arrow keys to navigate. Press Enter to select. Press Escape to cancel. +
+
+ ) +} +``` + +## TypeScript Types + +```tsx +import type {KeyboardEvent, KeyboardEventHandler} from 'react' + +interface ShortcutConfig { + key: string + ctrl?: boolean + meta?: boolean + shift?: boolean + alt?: boolean + action: () => void + description?: string +} + +type KeyHandler = (e: KeyboardEvent) => void + +const handleKeyDown: KeyboardEventHandler = e => { + // Type-safe event handler +} +``` diff --git a/packages/website/src/content/docs/guides/nested-marks.md b/packages/website/src/content/docs/guides/nested-marks.md new file mode 100644 index 00000000..d2a9b4e2 --- /dev/null +++ b/packages/website/src/content/docs/guides/nested-marks.md @@ -0,0 +1,650 @@ +--- +title: 🚧 Nested Marks +description: Nested marks tutorial - hierarchical text structures, __nested__ placeholder, markdown formatting, HTML-like tags in Markput +keywords: [nested marks, hierarchical structures, token tree, children, markdown, HTML-like tags, nesting] +--- + +Nested marks allow you to create rich, hierarchical text structures where marks can contain other marks. This enables complex formatting scenarios like markdown-style text, HTML-like tags, and multi-level mark structures. + +## Understanding Nesting + +### Flat vs Nested + +**Flat marks** (`__value__`): Content is plain text, nested patterns are ignored. + +```tsx +markup: '@[__value__]' +value: '@[Hello *world*]' +// Result: One mark with value = "Hello *world*" (literal asterisks) +``` + +**Nested marks** (`__nested__`): Content can contain other marks. + +```tsx +markup: '*__nested__*' +value: '*Hello **world***' +// Result: Italic mark containing "Hello " + bold mark "world" +``` + +### Key Differences + +| Feature | `__value__` | `__nested__` | +| ---------------- | -------------------- | -------------------------------------- | +| **Content Type** | Plain text | Supports child marks | +| **Parsing** | No recursive parsing | Recursive parsing | +| **Props** | `value` string | `children` ReactNode + `nested` string | +| **Use Case** | Simple marks | Hierarchical structures | + +## Enabling Nested Marks + +Use the `__nested__` placeholder instead of `__value__`: + +```tsx +// ✅ Supports nesting +const NestedMarkup = '**__nested__**' + +// ❌ Does not support nesting +const FlatMarkup = '**__value__**' +``` + +**Example: Markdown-style formatting** + +```tsx +import {MarkedInput} from 'rc-marked-input' +import {useState} from 'react' + +const FormatMark = ({children, nested}) => { + // For nested marks, children is ReactNode + // For flat marks, nested is string + return {children || nested} +} + +function MarkdownEditor() { + const [value, setValue] = useState('This is **bold with *italic* inside**') + + return ( + ({ + children, + style: {fontWeight: 'bold'}, + }), + }, + }, + { + markup: '*__nested__*', + slotProps: { + mark: ({children}) => ({ + children, + style: {fontStyle: 'italic'}, + }), + }, + }, + ]} + /> + ) +} +``` + +## Props for Nested Marks + +When using `__nested__`, your Mark component receives: + +```tsx +interface MarkProps { + value?: string // undefined for nested marks + meta?: string // Metadata (if __meta__ used) + nested?: string // Raw nested content as string + children?: ReactNode // Rendered nested children (use this!) +} +``` + +### children vs nested + +| Prop | Type | Description | When to Use | +| ---------- | ----------- | -------------------- | ------------------------------------ | +| `children` | `ReactNode` | Rendered child marks | **Recommended** - for rendering | +| `nested` | `string` | Raw text content | Edge cases - for processing raw text | + +**Example:** + +```tsx +function NestedMark({children, nested}) { + // ✅ Recommended: Use children + return {children} + + // ⚠️ Edge case: Use nested for raw text + // return {nested?.toUpperCase()} +} +``` + +## Simple Nesting Example + +```tsx +import {MarkedInput} from 'rc-marked-input' +import {useState} from 'react' + +function SimpleMark({children, style}) { + return {children} +} + +function SimpleNested() { + const [value, setValue] = useState('Text with **bold and *italic* formatting**') + + return ( + ({ + children, + style: {fontWeight: 'bold'}, + }), + }, + }, + { + markup: '*__nested__*', + slotProps: { + mark: ({children}) => ({ + children, + style: {fontStyle: 'italic'}, + }), + }, + }, + ]} + /> + ) +} +``` + +**Output for** `'**bold *italic***'`: + +```html + + bold + italic + +``` + +## Two Values Pattern (HTML-like Tags) + +ParserV2 supports **two values patterns** where opening and closing tags must match: + +```tsx +markup: '<__value__>__nested__' +``` + +**How it works:** + +- Pattern contains exactly **two** `__value__` placeholders +- Both values must be **identical** to match +- Perfect for HTML/XML-like structures + +### HTML-like Tags Example + +```tsx +import {MarkedInput} from 'rc-marked-input' +import {useState} from 'react' + +function HtmlLikeMark({value, children, nested}) { + // Use value as the HTML tag name + const Tag = (value || 'span') as React.ElementType + return {children || nested} +} + +function HtmlEditor() { + const [value, setValue] = useState( + '
Container with highlighted text and bold with italic
' + ) + + return ( + __nested__'}]} + /> + ) +} +``` + +**Matching rules:** + +```tsx +// ✅ Valid - tags match +'
content
' +'content' + +// ❌ Invalid - tags don't match +'
content' // Won't be recognized +'content' // Won't be recognized +``` + +### Custom Two Values Patterns + +```tsx +// BBCode-style +markup: '[__value__]__nested__[/__value__]' +// Matches: [b]text[/b], [i]text[/i] + +// Template tags +markup: '{{__value__}}__nested__{{/__value__}}' +// Matches: {{section}}content{{/section}} + +// Custom brackets +markup: '<<__value__>>__nested__<>' +// Matches: <>content<> +``` + +## Deep Nesting + +Marks can be nested to any depth: + +```tsx +'**bold with *italic with ~~strikethrough~~***' + +// Renders as: + + bold with + + italic with + strikethrough + + +``` + +**Example: Multi-level formatting** + +```tsx +function MultiLevelEditor() { + const [value, setValue] = useState('Normal **bold *italic ~~strike !!highlight!!~~***') + + return ( + {children}} + options={[ + { + markup: '**__nested__**', + slotProps: { + mark: ({children}) => ({ + children, + style: {fontWeight: 'bold'}, + }), + }, + }, + { + markup: '*__nested__*', + slotProps: { + mark: ({children}) => ({ + children, + style: {fontStyle: 'italic'}, + }), + }, + }, + { + markup: '~~__nested__~~', + slotProps: { + mark: ({children}) => ({ + children, + style: {textDecoration: 'line-through'}, + }), + }, + }, + { + markup: '!!__nested__!!', + slotProps: { + mark: ({children}) => ({ + children, + style: {background: 'yellow'}, + }), + }, + }, + ]} + /> + ) +} +``` + +## Accessing Nesting Information + +Use `useMark()` hook to access nesting details: + +```tsx +import {useMark} from 'rc-marked-input' + +function NestedAwareMark({children}) { + const {depth, hasChildren, parent, children: tokens} = useMark() + + return ( +
+
+ Depth: {depth} | Has children: {hasChildren ? 'Yes' : 'No'} | Parent: {parent?.value || 'None'} +
+
{children}
+
+ ) +} +``` + +### Nesting Properties + +| Property | Type | Description | +| ------------- | ------------------------ | -------------------------------- | +| `depth` | `number` | Nesting level (0 = root) | +| `hasChildren` | `boolean` | Whether mark has nested children | +| `parent` | `MarkToken \| undefined` | Parent mark token | +| `children` | `Token[]` | Array of child tokens | + +**Example: Collapsible nested structure** + +```tsx +function CollapsibleMark({children}) { + const {label, hasChildren, depth} = useMark() + const [collapsed, setCollapsed] = useState(false) + + if (!hasChildren) { + return {label} + } + + return ( +
+ + {label} + {!collapsed &&
{children}
} +
+ ) +} +``` + +## Mixed Nesting and Metadata + +Combine `__nested__` with `__meta__`: + +```tsx +markup: '@[__nested__](__meta__)' +``` + +**Example: Colored nested text** + +```tsx +function ColoredMark({children, meta}) { + const colors = { + red: '#ffebee', + blue: '#e3f2fd', + green: '#e8f5e9', + } + + return {children} +} + +// Usage +; ({ + children, + style: {fontWeight: 'bold'}, + }), + }, + }, + ]} +/> +``` + +## Complete Examples + +### Example 1: Markdown Editor + +```tsx +import {MarkedInput} from 'rc-marked-input' +import {useState} from 'react' + +function MarkdownMark({children, nested}) { + return {children || nested} +} + +function MarkdownEditor() { + const [value, setValue] = useState('This is **bold**, this is *italic*, and this is **bold with *italic* inside**.') + + return ( + ({ + children, + style: {fontWeight: 'bold'}, + }), + }, + }, + { + markup: '*__nested__*', + slotProps: { + mark: ({children}) => ({ + children, + style: {fontStyle: 'italic'}, + }), + }, + }, + ]} + /> + ) +} +``` + +### Example 2: HTML Tag Editor + +```tsx +function HtmlTagMark({value, children}) { + // Map tag names to React components or HTML elements + const tagMap: Record = { + div: 'div', + span: 'span', + p: 'p', + b: 'strong', + i: 'em', + mark: 'mark', + code: 'code', + } + + const Tag = tagMap[value || 'span'] || 'span' + + return {children} +} + +function HtmlEditor() { + const [value, setValue] = useState('
Article with highlighted bold text
') + + return ( + __nested__'}]} + /> + ) +} +``` + +### Example 3: Custom BBCode + +```tsx +function BBCodeMark({value, children}) { + const styles: Record = { + b: {fontWeight: 'bold'}, + i: {fontStyle: 'italic'}, + u: {textDecoration: 'underline'}, + color: {color: 'red'}, + size: {fontSize: '20px'}, + } + + return {children} +} + +function BBCodeEditor() { + const [value, setValue] = useState('[b]Bold [i]and italic[/i][/b] with [color]red text[/color]') + + return ( + + ) +} +``` + +## Performance Considerations + +### Rendering Performance + +Nested marks create more React elements: + +```tsx +// Flat: 1 mark = 1 React element +'@[simple]' → simple + +// Nested: Multiple React elements +'**bold *italic***' → italic +``` + +**Optimization tips:** + +1. **Memoize Mark component:** + +```tsx +const Mark = React.memo(({children}) => {children}) +``` + +2. **Limit nesting depth for large documents:** + +```tsx +// Set reasonable max depth in parser config (if supported) +maxDepth: 5 +``` + +3. **Use flat marks when possible:** + +```tsx +// If you don't need nesting, use __value__ +markup: '@[__value__]' // Faster than __nested__ +``` + +### Parsing Performance + +Deep nesting requires recursive parsing: + +```tsx +// Fast: 1 level +'**bold**' + +// Slower: 5 levels +'**a *b ~~c __d !!e!!__~~***' +``` + +**Best practices:** + +- Avoid unnecessarily deep nesting (>5 levels) +- Use flat marks for simple cases +- Profile with React DevTools for large documents + +## Best Practices + +### ✅ Do + +```tsx +// Use children for rendering +{children} + +// Provide fallback for non-nested +{children || nested} + +// Memoize expensive Mark components +const Mark = React.memo(({ children }) => {children}) + +// Type your component properly +function Mark({ children }: { children?: ReactNode }) { + return {children} +} +``` + +### ❌ Don't + +```tsx +// Don't modify children directly +function Bad({children}) { + return {children.toUpperCase()} // Error! +} + +// Don't use value with __nested__ +function Bad({value}) { + return {value} // value is undefined! +} + +// Don't create infinite loops +markup: '**__nested__**' +value: '**text **nested****' // Can cause issues +``` + +## TypeScript Support + +Type your nested Mark components: + +```tsx +import type {ReactNode} from 'react' + +interface NestedMarkProps { + value?: string + meta?: string + nested?: string + children?: ReactNode // Important for nested marks +} + +function TypedMark({children, nested}: NestedMarkProps) { + return {children || nested} +} +``` + +**For HTML-like tags:** + +```tsx +interface HtmlMarkProps { + value?: string // Tag name + children?: ReactNode +} + +function HtmlMark({value, children}: HtmlMarkProps) { + const Tag = (value || 'span') as React.ElementType + return {children} +} +``` + +**Key Takeaways:** + +- Use `__nested__` placeholder for hierarchical structures +- Render `children` prop in your Mark component +- Two values pattern (`<__value__>...`) for matching tags +- Access nesting info with `useMark()` hook +- Optimize with `React.memo` for better performance diff --git a/packages/website/src/content/docs/guides/overlay-customization.md b/packages/website/src/content/docs/guides/overlay-customization.md new file mode 100644 index 00000000..af29f000 --- /dev/null +++ b/packages/website/src/content/docs/guides/overlay-customization.md @@ -0,0 +1,736 @@ +--- +title: 🚧 Overlay Customization +description: Custom autocomplete overlays for Markput - trigger characters, suggestions, positioning, useOverlay hook, and styling +keywords: [overlay, autocomplete, suggestions, trigger characters, useOverlay hook, positioning, custom UI] +--- + +The overlay system provides autocomplete, suggestions, and contextual menus when users type trigger characters. Markput includes a default Suggestions component, but you can fully customize it to match your needs. + +## Overview + +Overlays appear when users type a trigger character (e.g., `@`, `/`, `#`): + +``` +User types '@' + ↓ +Overlay appears with suggestions + ↓ +User selects 'Alice' + ↓ +Text becomes '@[Alice]' +``` + +## Default Suggestions Overlay + +Markput includes a built-in Suggestions component: + +```tsx +import {MarkedInput} from 'rc-marked-input' +import {useState} from 'react' + +function BasicSuggestions() { + const [value, setValue] = useState('Type @ to mention someone') + + return ( + {props.value}} + options={[ + { + markup: '@[__value__]', + slotProps: { + overlay: { + trigger: '@', + data: ['Alice', 'Bob', 'Charlie', 'Diana'], + }, + }, + }, + ]} + /> + ) +} +``` + +**Features:** + +- Keyboard navigation (↑↓) +- Filtering as you type +- Enter to select +- Esc to close +- Click to select + +## The useOverlay Hook + +Build custom overlays with the `useOverlay()` hook: + +```tsx +import {useOverlay} from 'rc-marked-input' + +function CustomOverlay() { + const overlay = useOverlay() + + return
Custom overlay
+} +``` + +### useOverlay API + +| Property | Type | Description | +| ---------- | -------------- | -------------------------------------- | +| `style` | `{left, top}` | Absolute position for overlay | +| `close()` | `function` | Close the overlay | +| `select()` | `function` | Insert a mark | +| `match` | `OverlayMatch` | Match details (value, source, trigger) | +| `ref` | `RefObject` | Ref for outside click detection | + +**Complete interface:** + +```tsx +interface OverlayHandler { + style: { + left: number // X coordinate + top: number // Y coordinate + } + close: () => void + select: (value: {value: string; meta?: string}) => void + match: { + value: string // Typed text after trigger + source: string // Full matched text including trigger + trigger: string // The trigger character + span: string // Text node content + node: Node // DOM node + index: number // Position in text + option: Option // Matched option config + } + ref: RefObject +} +``` + +## Custom Overlay Examples + +### Example 1: Simple List + +```tsx +import {useOverlay} from 'rc-marked-input' + +function SimpleListOverlay() { + const {select} = useOverlay() + + const items = ['Apple', 'Banana', 'Cherry'] + + return ( +
    + {items.map(item => ( +
  • select({value: item})}> + {item} +
  • + ))} +
+ ) +} + +// Usage +; +``` + +### Example 2: Positioned Overlay + +Position the overlay at the caret: + +```tsx +function PositionedOverlay() { + const {style, select} = useOverlay() + + const items = ['Item 1', 'Item 2', 'Item 3'] + + return ( +
+ {items.map(item => ( +
select({value: item})} style={{padding: '8px 12px', cursor: 'pointer'}}> + {item} +
+ ))} +
+ ) +} +``` + +### Example 3: Filtered Suggestions + +Filter based on typed text: + +```tsx +function FilteredOverlay() { + const {select, match, close} = useOverlay() + + const allItems = ['Alice', 'Bob', 'Charlie', 'Diana'] + + // Filter items based on typed text + const filtered = allItems.filter(item => item.toLowerCase().includes(match.value.toLowerCase())) + + if (filtered.length === 0) { + return ( +
+
No results
+
+ ) + } + + return ( +
    + {filtered.map(item => ( +
  • select({value: item})}> + {item} +
  • + ))} +
+ ) +} +``` + +### Example 4: With Metadata + +Include metadata when selecting: + +```tsx +function UserOverlay() { + const {select} = useOverlay() + + const users = [ + {id: '1', name: 'Alice', avatar: '👩'}, + {id: '2', name: 'Bob', avatar: '👨'}, + {id: '3', name: 'Charlie', avatar: '🧑'}, + ] + + return ( +
+ {users.map(user => ( +
+ select({ + value: user.name, + meta: user.id, // Store user ID in metadata + }) + } + className="user-item" + > + {user.avatar} + {user.name} +
+ ))} +
+ ) +} + +// Usage with markup that includes metadata +; +``` + +### Example 5: Keyboard Navigation + +Add keyboard support: + +```tsx +import {useOverlay} from 'rc-marked-input' +import {useState, useEffect} from 'react' + +function KeyboardOverlay() { + const {select, close, ref} = useOverlay() + const [selected, setSelected] = useState(0) + + const items = ['Alice', 'Bob', 'Charlie'] + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelected(prev => (prev + 1) % items.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelected(prev => (prev - 1 + items.length) % items.length) + } else if (e.key === 'Enter') { + e.preventDefault() + select({value: items[selected]}) + } else if (e.key === 'Escape') { + e.preventDefault() + close() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [selected, items, select, close]) + + return ( +
+ {items.map((item, index) => ( +
select({value: item})} className={index === selected ? 'selected' : ''}> + {item} +
+ ))} +
+ ) +} +``` + +## Outside Click Detection + +Use the `ref` to detect clicks outside the overlay: + +```tsx +function ClickOutsideOverlay() { + const {select, ref} = useOverlay() + + const items = ['Item 1', 'Item 2'] + + return ( +
+ {items.map(item => ( +
select({value: item})}> + {item} +
+ ))} +
+ ) +} +``` + +**How it works:** + +- Markput tracks clicks +- If click is outside elements with `ref`, overlay closes +- Always attach `ref` to your root overlay element + +## Trigger Configuration + +### Single Trigger + +```tsx +options={[ + { + markup: '@[__value__]', + slotProps: { + overlay: { + trigger: '@', + data: ['Alice', 'Bob'] + } + } + } +]} +``` + +### Multiple Triggers + +Different triggers for different mark types: + +```tsx +options={[ + { + markup: '@[__value__](user)', + slotProps: { + overlay: { trigger: '@', data: users } + } + }, + { + markup: '#[__value__](hashtag)', + slotProps: { + overlay: { trigger: '#', data: hashtags } + } + }, + { + markup: '/[__value__](command)', + slotProps: { + overlay: { trigger: '/', data: commands } + } + } +]} +``` + +### Multi-Character Triggers + +```tsx +options={[ + { + markup: '{{__value__}}', + slotProps: { + overlay: { + trigger: '{{', + data: ['name', 'email', 'date'] + } + } + } +]} +``` + +## Per-Option Custom Overlays + +Use different overlay components for different triggers: + +```tsx +import {MarkedInput} from 'rc-marked-input' + +function UserOverlay() { + const {select} = useOverlay() + return ( +
+
select({value: 'Alice'})}>👩 Alice
+
select({value: 'Bob'})}>👨 Bob
+
+ ) +} + +function CommandOverlay() { + const {select} = useOverlay() + return ( +
+
select({value: 'heading'})}>📝 Heading
+
select({value: 'bold'})}>🔤 Bold
+
+ ) +} + +function Editor() { + const [value, setValue] = useState('') + + return ( + {props.value}} + options={[ + { + markup: '@[__value__]', + slots: {overlay: UserOverlay}, // Custom overlay for @ + slotProps: {overlay: {trigger: '@'}}, + }, + { + markup: '/[__value__]', + slots: {overlay: CommandOverlay}, // Custom overlay for / + slotProps: {overlay: {trigger: '/'}}, + }, + ]} + /> + ) +} +``` + +## Overlay with Data Loading + +Load data asynchronously: + +```tsx +import {useOverlay} from 'rc-marked-input' +import {useState, useEffect} from 'react' + +function AsyncOverlay() { + const {select, match} = useOverlay() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + // Fetch users based on typed text + fetch(`/api/users?q=${match.value}`) + .then(res => res.json()) + .then(data => { + setUsers(data) + setLoading(false) + }) + }, [match.value]) + + if (loading) { + return
Loading...
+ } + + if (users.length === 0) { + return
No users found
+ } + + return ( +
+ {users.map(user => ( +
select({value: user.name, meta: user.id})}> + {user.name} +
+ ))} +
+ ) +} +``` + +## Controlling Overlay Visibility + +Use `showOverlayOn` prop: + +```tsx + +``` + +**Options:** + +- `"change"` - Show when text changes (default) +- `"selectionChange"` - Show when cursor moves +- `["change", "selectionChange"]` - Both events +- `"none"` - Manual control only + +## Complete Examples + +### Example: Rich User Selector + +```tsx +import {useOverlay} from 'rc-marked-input' +import {useState, useEffect} from 'react' + +function RichUserOverlay() { + const {select, match, style, ref} = useOverlay() + const [selected, setSelected] = useState(0) + + const users = [ + {id: '1', name: 'Alice Johnson', avatar: '👩', role: 'Designer'}, + {id: '2', name: 'Bob Smith', avatar: '👨', role: 'Developer'}, + {id: '3', name: 'Charlie Brown', avatar: '🧑', role: 'Manager'}, + ] + + const filtered = users.filter(u => u.name.toLowerCase().includes(match.value.toLowerCase())) + + useEffect(() => { + setSelected(0) + }, [match.value]) + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelected(prev => (prev + 1) % filtered.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelected(prev => (prev - 1 + filtered.length) % filtered.length) + } else if (e.key === 'Enter' && filtered[selected]) { + e.preventDefault() + select({ + value: filtered[selected].name, + meta: filtered[selected].id, + }) + } + } + + window.addEventListener('keydown', handleKey) + return () => window.removeEventListener('keydown', handleKey) + }, [selected, filtered, select]) + + return ( +
+ {filtered.length === 0 ? ( +
No users found
+ ) : ( + filtered.map((user, index) => ( +
select({value: user.name, meta: user.id})} + style={{ + padding: '12px 16px', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '12px', + background: index === selected ? '#f5f5f5' : 'transparent', + }} + > + {user.avatar} +
+
{user.name}
+
{user.role}
+
+
+ )) + )} +
+ ) +} +``` + +### Example: Notion-style Slash Commands + +```tsx +function CommandOverlay() { + const {select, match, style, ref} = useOverlay() + const [selected, setSelected] = useState(0) + + const commands = [ + {value: 'h1', label: 'Heading 1', icon: '📝', description: 'Large heading'}, + {value: 'h2', label: 'Heading 2', icon: '📄', description: 'Medium heading'}, + {value: 'bold', label: 'Bold', icon: '🔤', description: 'Make text bold'}, + {value: 'italic', label: 'Italic', icon: '📐', description: 'Italicize text'}, + {value: 'code', label: 'Code', icon: '💻', description: 'Code block'}, + ] + + const filtered = commands.filter( + cmd => + cmd.label.toLowerCase().includes(match.value.toLowerCase()) || + cmd.value.toLowerCase().includes(match.value.toLowerCase()) + ) + + return ( +
+ {filtered.map((cmd, index) => ( +
select({value: cmd.value, meta: cmd.label})} + style={{ + padding: '10px 14px', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '12px', + background: index === selected ? '#f0f0f0' : 'transparent', + }} + > + {cmd.icon} +
+
{cmd.label}
+
{cmd.description}
+
+
+ ))} +
+ ) +} +``` + +## Best Practices + +### ✅ Do + +```tsx +// Attach ref for outside click detection +
overlay content
+ +// Position overlay at caret +
+ +// Filter based on match.value +const filtered = items.filter(item => + item.toLowerCase().includes(match.value.toLowerCase()) +) + +// Handle empty results +{filtered.length === 0 &&
No results
} + +// Add keyboard navigation +useEffect(() => { + const handleKey = (e) => { /* handle arrow keys */ } + window.addEventListener('keydown', handleKey) + return () => window.removeEventListener('keydown', handleKey) +}, []) +``` + +### ❌ Don't + +```tsx +// Don't forget ref +
overlay
// Won't close on outside click + +// Don't use fixed positioning without coordinates +
// Bad UX + +// Don't forget to handle empty states +{items.map(item => ...)} // What if items is empty? + +// Don't create memory leaks +useEffect(() => { + window.addEventListener('keydown', handler) + // Missing cleanup! +}, []) +``` + +## TypeScript Support + +Type your custom overlays: + +```tsx +import {useOverlay} from 'rc-marked-input' +import type {OverlayHandler} from 'rc-marked-input' + +function TypedOverlay() { + const overlay: OverlayHandler = useOverlay() + + const handleSelect = (value: string) => { + overlay.select({value, meta: 'optional'}) + } + + return
{/* overlay content */}
+} +``` + +**Key Takeaways:** + +- Use `useOverlay()` hook for custom overlays +- Position with `style.left` and `style.top` +- Attach `ref` for outside click detection +- Use `select()` to insert marks +- Add keyboard navigation for better UX + +**Try it live:** [CodeSandbox - Custom Overlay](https://codesandbox.io/s/custom-overlay-1m5ctx) diff --git a/packages/website/src/content/docs/guides/slots-customization.md b/packages/website/src/content/docs/guides/slots-customization.md new file mode 100644 index 00000000..3282904f --- /dev/null +++ b/packages/website/src/content/docs/guides/slots-customization.md @@ -0,0 +1,1095 @@ +--- +title: 🚧 Slots Customization +description: Customize Markput components with slots pattern - replace container, text rendering, styling without forking +keywords: [slots pattern, component customization, container, rendering, styling, slotProps] +--- + +Markput uses the **slots pattern** (popularized by Material-UI) to give you fine-grained control over internal components. This guide covers how to customize the container and text rendering without losing built-in functionality. + +## What are Slots? + +Slots are a component customization pattern that separates structure from styling and behavior. Instead of wrapping or forking components, you customize them through props: + +```tsx + +``` + +**Key Concepts:** + +- **`slots`** - Replace the default component entirely +- **`slotProps`** - Pass props to the default (or custom) component + +## Available Slots + +Markput exposes two slots: + +| Slot | Default Component | Purpose | +| ----------- | ----------------- | ----------------------- | +| `container` | `
` | Root editable container | +| `span` | `` | Plain text segments | + +**What's NOT a slot:** + +- Mark components (use `Mark` prop) +- Overlay components (use `Overlay` prop) + +## Using slotProps (Customize Defaults) + +The simplest way to customize slots is through `slotProps`. This passes props to the default components without replacing them. + +### Basic Styling + +```tsx +import {MarkedInput} from 'rc-marked-input' + +function StyledEditor() { + const [value, setValue] = useState('') + + return ( + + ) +} +``` + +### CSS Classes + +```tsx + +``` + +```css +/* styles.css */ +.editor-container { + border: 1px solid #ddd; + border-radius: 4px; + padding: 16px; + font-family: 'Inter', sans-serif; +} + +.editor-container:focus { + outline: 2px solid #2196f3; + border-color: transparent; +} + +.editor-text { + color: #333; + letter-spacing: 0.01em; +} +``` + +### Event Handlers + +Add event handlers through `slotProps`: + +```tsx +function EditorWithEvents() { + const [value, setValue] = useState('') + const [isFocused, setIsFocused] = useState(false) + + return ( + { + console.log('Editor focused') + setIsFocused(true) + }, + onBlur: e => { + console.log('Editor blurred') + setIsFocused(false) + }, + onKeyDown: e => { + if (e.key === 's' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + console.log('Save triggered') + } + }, + onPaste: e => { + console.log('Pasted:', e.clipboardData.getData('text')) + }, + style: { + outline: isFocused ? '2px solid blue' : 'none', + }, + }, + }} + /> + ) +} +``` + +### Accessibility Attributes + +Improve accessibility with ARIA attributes: + +```tsx + + +

+ Type @ to mention someone +

+``` + +### Data Attributes + +Add custom data attributes for testing or analytics: + +```tsx + +``` + +## Using slots (Replace Components) + +For deeper customization, replace the default components entirely with `slots`. + +### Custom Container Component + +Replace the container with a custom component: + +```tsx +import {forwardRef} from 'react' +import type {HTMLAttributes} from 'react' + +const CustomContainer = forwardRef>((props, ref) => { + return ( +
+ ) +}) + +function Editor() { + return ( + + ) +} +``` + +**Important:** Custom slot components MUST: + +1. Accept all props with spread (`{...props}`) +2. Forward the ref (`forwardRef`) +3. Be typed correctly for TypeScript + +### Custom Span Component + +Replace text spans with custom rendering: + +```tsx +const CustomSpan = forwardRef< + HTMLSpanElement, + HTMLAttributes +>((props, ref) => { + return ( + + ) +}) + + +``` + +### Combining slots and slotProps + +Use both together - `slots` to replace components, `slotProps` to pass additional props: + +```tsx +const CustomContainer = forwardRef>( + (props, ref) => ( +
+ ) +) + + console.log('Focused') + } + }} +/> +``` + +The props from `slotProps.container` will be passed to your `CustomContainer` component. + +## Styling Approaches + +### Approach 1: Inline Styles + +Good for dynamic styles based on state: + +```tsx +function ThemedEditor() { + const [theme, setTheme] = useState('light') + + const containerStyle = { + backgroundColor: theme === 'light' ? '#fff' : '#1e1e1e', + color: theme === 'light' ? '#000' : '#fff', + border: `1px solid ${theme === 'light' ? '#ddd' : '#444'}`, + } + + return ( + + ) +} +``` + +### Approach 2: CSS Classes + +Good for static styles and media queries: + +```tsx + +``` + +```css +.editor-modern { + border: none; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px; + border-radius: 16px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} + +.editor-modern:focus { + box-shadow: 0 10px 60px rgba(0, 0, 0, 0.3); +} + +@media (max-width: 768px) { + .editor-modern { + padding: 12px; + border-radius: 8px; + } +} +``` + +### Approach 3: CSS-in-JS + +Good for component libraries and scoped styles: + +```tsx +import {styled} from '@mui/material/styles' + +const StyledContainer = styled('div')(({theme}) => ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(2), + backgroundColor: theme.palette.background.paper, + '&:focus': { + outline: `2px solid ${theme.palette.primary.main}`, + outlineOffset: 2, + }, +})) + +const StyledSpan = styled('span')(({theme}) => ({ + color: theme.palette.text.primary, + fontSize: theme.typography.body1.fontSize, +})) + +function MuiEditor() { + return ( + + ) +} +``` + +### Approach 4: Tailwind CSS + +Good for utility-first styling: + +```tsx +const TailwindContainer = forwardRef>( + (props, ref) => ( +
+ ) +) + + +``` + +## Common Use Cases + +### Use Case 1: Placeholder Text + +Show placeholder when editor is empty: + +```tsx +const ContainerWithPlaceholder = forwardRef & {isEmpty?: boolean}>( + ({isEmpty, ...props}, ref) => ( +
+ {isEmpty && ( +
+ Type @ to mention someone... +
+ )} +
+ ) +) + +function EditorWithPlaceholder() { + const [value, setValue] = useState('') + + return ( + + ) +} +``` + +### Use Case 2: Character Counter + +Add a character count overlay: + +```tsx +const ContainerWithCounter = forwardRef< + HTMLDivElement, + HTMLAttributes & {charCount?: number; maxChars?: number} +>(({charCount = 0, maxChars = 500, ...props}, ref) => ( +
+
+
maxChars ? '#f44336' : '#999', + pointerEvents: 'none', + }} + > + {charCount} / {maxChars} +
+
+)) + +function EditorWithCounter() { + const [value, setValue] = useState('') + + return ( + + ) +} +``` + +### Use Case 3: Custom Focus Behavior + +Highlight container on focus: + +```tsx +const FocusableContainer = forwardRef>( + (props, ref) => { + const [focused, setFocused] = useState(false) + + return ( +
{ + setFocused(true) + props.onFocus?.(e) + }} + onBlur={(e) => { + setFocused(false) + props.onBlur?.(e) + }} + style={{ + ...props.style, + border: focused ? '2px solid #2196f3' : '1px solid #ddd', + boxShadow: focused ? '0 0 0 3px rgba(33, 150, 243, 0.1)' : 'none', + transition: 'all 0.2s ease' + }} + /> + ) + } +) + + +``` + +### Use Case 4: Line Numbers + +Add line numbers for multi-line content: + +```tsx +const ContainerWithLineNumbers = forwardRef & {lineCount?: number}>( + ({lineCount = 1, ...props}, ref) => ( +
+
+ {Array.from({length: lineCount}, (_, i) => ( +
{i + 1}
+ ))} +
+
+
+ ) +) + +function EditorWithLineNumbers() { + const [value, setValue] = useState('') + const lineCount = value.split('\n').length + + return ( + + ) +} +``` + +### Use Case 5: Syntax Highlighting for Text + +Custom text rendering with highlighting: + +```tsx +const HighlightedSpan = forwardRef< + HTMLSpanElement, + HTMLAttributes +>((props, ref) => { + const text = props.children as string + + // Highlight specific patterns + const isUrl = /^https?:\/\//.test(text) + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) + + let style = { ...props.style } + if (isUrl) { + style.color = '#2196f3' + style.textDecoration = 'underline' + } else if (isEmail) { + style.color = '#4caf50' + } + + return +}) + + +``` + +## Integration with UI Libraries + +### Material-UI (MUI) + +```tsx +import {Paper, useTheme} from '@mui/material' +import {styled} from '@mui/material/styles' + +const MuiContainer = styled(Paper)(({theme}) => ({ + padding: theme.spacing(2), + minHeight: 120, + border: `1px solid ${theme.palette.divider}`, + '&:focus-within': { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 2px ${theme.palette.primary.main}25`, + }, +})) + +function MuiEditor() { + return ( + + ) +} +``` + +### Chakra UI + +```tsx +import { Box } from '@chakra-ui/react' +import { forwardRef } from 'react' + +const ChakraContainer = forwardRef((props, ref) => ( + +)) + + +``` + +### Ant Design + +```tsx +import { Input } from 'antd' +import { forwardRef } from 'react' + +const AntContainer = forwardRef((props, ref) => ( +
+)) + + +``` + +## TypeScript Usage + +### Typing Custom Slot Components + +```tsx +import {forwardRef} from 'react' +import type {HTMLAttributes, CSSProperties} from 'react' + +// Type container with custom props +interface CustomContainerProps extends HTMLAttributes { + variant?: 'outlined' | 'filled' + error?: boolean +} + +const TypedContainer = forwardRef( + ({variant = 'outlined', error = false, ...props}, ref) => { + const style: CSSProperties = { + ...props.style, + border: error ? '2px solid red' : '1px solid #ddd', + backgroundColor: variant === 'filled' ? '#f5f5f5' : 'transparent', + } + + return
+ } +) + +// Usage with type safety +function TypedEditor() { + return ( + + ) +} +``` + +### Generic Slot Props + +```tsx +import type {MarkedInputProps} from 'rc-marked-input' + +interface EditorProps { + containerClass?: string + spanClass?: string +} + +function ConfigurableEditor({containerClass, spanClass}: EditorProps) { + const slotProps: MarkedInputProps['slotProps'] = { + container: { + className: containerClass, + }, + span: { + className: spanClass, + }, + } + + return +} +``` + +## Performance Considerations + +### Memoize Custom Components + +Prevent unnecessary re-renders: + +```tsx +import { memo, forwardRef } from 'react' + +const MemoizedContainer = memo( + forwardRef>( + (props, ref) => ( +
+ ) + ) +) + + +``` + +### Avoid Inline Function Creation + +```tsx +// ❌ Bad - creates new function each render +; console.log(e.key), + }, + }} +/> + +// ✅ Good - stable function reference +function Editor() { + const handleKeyDown = useCallback((e: KeyboardEvent) => { + console.log(e.key) + }, []) + + return ( + + ) +} +``` + +### Memoize slotProps Object + +```tsx +function Editor() { + const slotProps = useMemo( + () => ({ + container: { + className: 'editor', + style: {padding: '16px'}, + }, + span: { + className: 'text', + }, + }), + [] + ) // Only created once + + return +} +``` + +## Complete Examples + +### Example 1: GitHub-Style Editor + +```tsx +import {MarkedInput} from 'rc-marked-input' +import {useState, forwardRef} from 'react' +import type {HTMLAttributes} from 'react' + +const GitHubContainer = forwardRef>((props, ref) => ( +
+)) + +function GitHubEditor() { + const [value, setValue] = useState('') + + return ( +
+
+
+ Write a comment +
+ @{value}} + slots={{ + container: GitHubContainer, + }} + slotProps={{ + container: { + 'aria-label': 'Comment body', + }, + }} + options={[ + { + markup: '@[__value__]', + slotProps: { + overlay: {trigger: '@', data: ['octocat', 'github', 'copilot']}, + }, + }, + ]} + /> +
+
+ ) +} +``` + +### Example 2: Notion-Style Editor + +```tsx +const NotionContainer = forwardRef>((props, ref) => { + const [placeholder, setPlaceholder] = useState("Type '/' for commands") + + return ( +
setPlaceholder('')} + onBlur={e => { + if (!e.currentTarget.textContent) { + setPlaceholder("Type '/' for commands") + } + props.onBlur?.(e) + }} + > + {!props.children && ( +
+ {placeholder} +
+ )} +
+ ) +}) + +function NotionEditor() { + const [value, setValue] = useState('') + + return ( + ( + + {value} + + )} + slots={{ + container: NotionContainer, + }} + /> + ) +} +``` + +## Best Practices + +### ✅ Do + +```tsx +// Always forward refs +const CustomContainer = forwardRef((props, ref) =>
) + +// Spread all props +const CustomContainer = forwardRef((props, ref) => ( +
+)) + +// Preserve existing style +const CustomContainer = forwardRef((props, ref) => ( +
+)) + +// Memoize stable components +const StableContainer = memo(forwardRef((props, ref) =>
)) + +// Type custom components properly +const TypedContainer = forwardRef>((props, ref) => ( +
+)) +``` + +### ❌ Don't + +```tsx +// Don't forget forwardRef +const Bad = (props) =>
// Missing ref! + +// Don't forget to spread props +const Bad = forwardRef((props, ref) => ( +
// Lost all props! +)) + +// Don't override style completely +const Bad = forwardRef((props, ref) => ( +
// Lost original style! +)) + +// Don't use inline components +
// Creates new component each render! + }} +/> + +// Don't forget TypeScript types +const Bad = forwardRef((props, ref) => ( // Any types! +
+)) +``` diff --git a/packages/website/src/content/docs/index.mdx b/packages/website/src/content/docs/index.mdx new file mode 100644 index 00000000..0f5a417c --- /dev/null +++ b/packages/website/src/content/docs/index.mdx @@ -0,0 +1,40 @@ +--- +title: Welcome +description: Markput - lightweight React component library for @mentions, hashtags, slash commands, and custom markup editors +template: splash +keywords: [Markput, React component, text editor, marks, mentions, hashtags, slash commands, markup] +hero: + title: Build your own editor with Markput + tagline: A React component that lets you combine editable text with any component using custom markup. + image: + file: ../../assets/houston.webp + actions: + - text: Get Started + link: /introduction/getting-started/ + icon: right-arrow + variant: primary + - text: View on GitHub + link: https://github.com/Nowely/marked-input + icon: external + variant: minimal +--- + +import {Card, CardGrid} from '@astrojs/starlight/components' + + + + Add, edit, remove, and visualize marks with ease. + + + Support for nested marks and complex text structures. + + + Support for custom syntax patterns (e.g., HTML, markdown). + + + Select text across multiple marks seamlessly. + + + Lightweight with zero external dependencies. + + diff --git a/packages/website/src/content/docs/introduction/getting-started.mdx b/packages/website/src/content/docs/introduction/getting-started.mdx new file mode 100644 index 00000000..b4426562 --- /dev/null +++ b/packages/website/src/content/docs/introduction/getting-started.mdx @@ -0,0 +1,138 @@ +--- +title: Getting Started +description: Install Markput and build your first @mention editor with autocomplete in minutes +keywords: [installation, setup, quick start, react component, mentions editor, autocomplete] +--- + +import {Tabs, TabItem, Aside} from '@astrojs/starlight/components' +import CodePreview from '../../../components/demos/CodePreview.astro' +import {Step1Demo} from '../../../components/demos/Step1Demo' +import {Step2Demo} from '../../../components/demos/Step2Demo' +import {Step3Demo} from '../../../components/demos/Step3Demo' +import step1DemoRaw from '../../../components/demos/Step1Demo.tsx?raw' +import step2DemoRaw from '../../../components/demos/Step2Demo.tsx?raw' +import step3DemoRaw from '../../../components/demos/Step3Demo.tsx?raw' + +Build interactive text with custom markup - quick start in minutes. + +## Installation + +Install Markput using your preferred package manager: + + + + ```bash + npm install rc-marked-input + ``` + + + ```bash + pnpm add rc-marked-input + ``` + + + ```bash + yarn add rc-marked-input + ``` + + + ```bash + bun add rc-marked-input + ``` + + + +**Requirements**: React 17 or later. + +## Your First Editor + +Let's build a marked text editor with autocomplete in three steps. + +### Step 1: Render Marks + +Here's a basic editor rendering marked text. Try clicking the highlighted text: + + + + + +**How it works:** + +Markput uses a special **markup syntax** to represent interactive elements as plain text: + +- **Markup** — a text pattern that encodes structured data: `@[__value__](__meta__)` +- **Value** — the visible text shown to users (e.g., `World`) +- **Meta** — hidden metadata for your app (e.g., `123` - user ID) +- **Mark** — a React component that renders the markup visually + +When Markput encounters `@[World](123)` in the text: + +1. **Parses** the markup and extracts: `value="World"`, `meta="123"` +2. **Renders** your `Mark` component with these props: `{props.value}` +3. **Preserves** the original text as a simple string — easy to save or send to any backend + +Since `Mark` is a regular React component, you can style it, add click handlers (like the `onClick` that shows an alert), or use any React features. + +### Step 2: Add Autocomplete + +Add the `options` prop to enable autocomplete suggestions: + + + + + +**How it works:** + +The `options` prop configures autocomplete behavior: + +- **`markup`** — the pattern for inserted text (`__value__` inserts plain text) +- **`slotProps.overlay`** — configuration for the built-in `Suggestions` component: + - **`trigger`** — character that opens the overlay (here: `@`) + - **`data`** — array of suggestions to display + +When you type the trigger character `@`: + +1. Markput **detects** the trigger and shows the built-in `Suggestions` component +2. As you type, suggestions are **filtered** based on your input +3. When you select an item (e.g., `'Alice'`), Markput **inserts** the text + +Keyboard navigation is built-in (↑↓ to navigate, Enter to select, Esc to close). + +### Step 3: Custom Overlay + +The built-in `Suggestions` component is convenient, but sometimes you need full control over the UI. The `Overlay` prop lets you render a completely custom component, and the `useOverlay` hook provides all the state and actions you need. + +Here's a custom mention UI that fetches GitHub users and displays avatars: + + + + + +**How it works:** + +The `useOverlay` hook returns an object with everything you need to build a custom overlay: + +- **`match`** — current search state with `match.value` containing what the user typed after the trigger +- **`select`** — function to insert markup: `select({value: string, meta?: string})` +- **`close`** — function to dismiss the overlay without selecting +- **`style`** — object with `top` and `left` coordinates for positioning near the cursor +- **`ref`** — React ref to attach to your overlay element for proper event handling + +The hook handles all the complexity: detecting triggers, tracking the search query, and positioning the overlay. When you call `select({value, meta})`, it inserts the markup and closes the overlay. + +In this example, we fetch GitHub users and store the username as `value` and avatar URL as `meta`. The `Mark` component then uses `meta` to display the avatar. You can use any UI library or custom component. Markput doesn't impose styling constraints. + + + +## Try It Live + +Explore these interactive examples on CodeSandbox: + +- [Static Marks](https://codesandbox.io/s/marked-input-x5wx6k) — Basic example with clickable marks +- [Configured Component](https://codesandbox.io/s/configured-marked-input-305v6m) — Using `createMarkedInput` factory +- [Dynamic Marks](https://codesandbox.io/s/dynamic-mark-w2nj82) — Editable and removable marks with `useMark` +- [Custom Overlay](https://codesandbox.io/s/custom-overlay-1m5ctx) — Building your own suggestion UI diff --git a/packages/website/src/content/docs/introduction/why-markput.md b/packages/website/src/content/docs/introduction/why-markput.md new file mode 100644 index 00000000..1b50e00d --- /dev/null +++ b/packages/website/src/content/docs/introduction/why-markput.md @@ -0,0 +1,52 @@ +--- +title: Why Markput? +description: Lightweight React library for building custom markup text editors with plain text storage and full component control +keywords: [markput, react mentions, marks, custom markup, text editor, slash commands, autocomplete, typescript] +--- + +Markput (marked input) is a React component library for building editors with **custom markup**. It transforms plain text patterns into interactive React components, giving you full control over rendering and behavior. + +**The Problem**: Building custom text editors usually means choosing between: + +- **Simple but limited** (basic input + regex) +- **Powerful but complex** (Draft.js, Slate with steep learning curves) + +**Our Philosophy**: You shouldn't have to choose. Markput combines: + +- **Simple API**: Define patterns like `@[__value__](__meta__)`, pass components - done. +- **No framework overhead**: Your React components work as-is, no adapters. +- **Debuggable state**: Plain text strings, not complex JSON schemas. +- **Scale naturally**: Start with @mentions, add nested formatting later - same API. + +## Features + +- **Component-First**: Marks are your components - full control, no constraints. +- **Flexible Patterns**: Custom markup syntax - markdown, HTML-like, or your own. +- **Dynamic Marks**: Interactive marks with editing, removing, focusing, and custom actions. +- **Overlay System**: Built-in suggestions or fully custom overlays. +- **Nested Marks**: Hierarchical structures - marks inside marks. +- **Cross Selection**: Select text across multiple marks - seamless text highlighting. +- **Zero Dependency**: Lightweight, no external dependencies. +- **Plain Text State**: Simple string storage - easy to save and version. +- **TypeScript-First**: Full type safety included. + +## Use Cases + +**Ideal for:** + +- **Social features** - @mentions, #hashtags, /commands +- **Markdown/HTML formatting** - Bold, italic, links, code, custom tags +- **Custom markup** - Templates, BBCode, domain-specific languages +- **Lightweight editors** - Plain text + markup patterns approach + +**Can be built (requires work):** + +- **WYSIWYG editors** - Rich documents with tables and images + _(Markput supports this, but requires custom implementation)_ + +**Not ideal for:** + +- **Plain text only** - Use native `