diff --git a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx new file mode 100644 index 00000000..0041d7b5 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx @@ -0,0 +1,107 @@ +import type {Meta, StoryObj} from '@storybook/react-vite' +import {MarkedInput} from 'rc-marked-input' +import {useState} from 'react' +import {Text} from '../../assets/Text' +import {SingleEditableControlled} from './SingleEditableControlled' +import {SingleEditableUncontrolled} from './SingleEditableUncontrolled' +import {SingleEditableMarkdown} from './SingleEditableMarkdown' + +export default { + title: 'Experimental/Single ContentEditable', + tags: ['autodocs'], + component: MarkedInput, +} satisfies Meta + +type Story = StoryObj> + +// ============================================================================ +// Story Examples +// ============================================================================ + +/** + * Controlled approach (React manages content via `value` prop) + * + * ⚠️ This demonstrates the PROBLEM with controlled contentEditable: + * - React re-renders DOM on every change + * - Cursor position resets + * - Editing experience is broken + * + * This story shows WHY we need the uncontrolled approach. + */ +export const Controlled: Story = { + render: () => { + const [value, setValue] = useState('') + + return ( + <> +
+

❌ Controlled (React-managed)

+
+ + + + + + ) + }, +} + +/** + * Uncontrolled approach with MutationObserver (WORKING SOLUTION ✅) + * + * This demonstrates the SOLUTION: + * - React doesn't manage content (uses `defaultValue`) + * - MutationObserver tracks changes instead + * - Cursor stays in place naturally + * - Smooth, native editing experience + * + * This is the recommended approach for single contentEditable! + */ +export const Uncontrolled: Story = { + render: () => { + const [value, setValue] = useState('') + + return ( + <> +
+

✅ Uncontrolled (MutationObserver)

+
+ + + + + + ) + }, +} + +/** + * Markdown Editor + * + * Uncontrolled markdown editor with support for: + * - **bold** text + * - *italic* text + * - `code` formatting + * - [links](url) + * - # headings + * - > blockquotes + * + * Uses the same uncontrolled approach with MutationObserver for smooth editing! + */ +export const Markdown: Story = { + render: () => { + const [value, setValue] = useState('') + + return ( + <> +
+

📝 Markdown (Uncontrolled)

+
+ + + + + + ) + }, +} diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx b/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx new file mode 100644 index 00000000..20ccd5d1 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx @@ -0,0 +1,53 @@ +import {useCallback, useState} from 'react' +import {MarkedInput} from 'rc-marked-input' +import {CustomContainer, PlainTextSpan, HTMLMark} from './components' +import {htmlToPlainText} from './utils' + +interface SingleEditableControlledProps { + onValueChange: (value: string) => void +} + +/** + * SingleEditableControlled - Encapsulates controlled contentEditable logic + * + * Manages its own state and re-renders on every change. + * Notifies parent component via onValueChange callback. + * + * ⚠️ This demonstrates the PROBLEM with controlled contentEditable: + * - React re-renders DOM on every change + * - Cursor position resets + * - Editing experience is broken + */ +export const SingleEditableControlled = ({onValueChange}: SingleEditableControlledProps) => { + const [value, setValue] = useState('Hello @[John](id:123) and @[World](greeting)!') + const containerRef = useState(null)[1] + + const handleInput = useCallback( + (e: React.FormEvent) => { + const html = e.currentTarget.innerHTML + const plainText = htmlToPlainText(html) + setValue(plainText) + onValueChange(plainText) + }, + [onValueChange] + ) + + return ( + {}} // Not used - we use manual onInput instead + Mark={HTMLMark} + slots={{ + container: CustomContainer, + span: PlainTextSpan, + }} + slotProps={{ + container: { + ref: containerRef, + onInput: handleInput, + } as any, + }} + /> + ) +} diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/components/CustomContainer.tsx b/packages/storybook/stories/experimental/SingleEditableControlled/components/CustomContainer.tsx new file mode 100644 index 00000000..0a8db800 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableControlled/components/CustomContainer.tsx @@ -0,0 +1,55 @@ +import {forwardRef, useCallback, type HTMLAttributes} from 'react' + +/** + * CustomContainer - Container with single contentEditable + * + * This is the key component for Obsidian-like approach: + * - Only this container has contentEditable={true} + * - All children (text and marks) are rendered inside + * - Browser handles all editing natively + * + * Important: We prevent the default 'input' event from reaching MarkedInput's + * internal handler, and instead handle it ourselves in the story component. + */ +export const CustomContainer = forwardRef>((props, ref) => { + const handlePaste = useCallback((e: React.ClipboardEvent) => { + // Prevent default paste behavior (which might include HTML) + e.preventDefault() + + // Get only plain text from clipboard + const text = e.clipboardData.getData('text/plain') + + // Insert plain text at cursor position + document.execCommand('insertText', false, text) + }, []) + + return ( +
{ + e.currentTarget.style.borderColor = '#5b9dd9' + }} + onBlur={e => { + e.currentTarget.style.borderColor = '#e0e0e0' + }} + /> + ) +}) +CustomContainer.displayName = 'CustomContainer' diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/components/HTMLMark.tsx b/packages/storybook/stories/experimental/SingleEditableControlled/components/HTMLMark.tsx new file mode 100644 index 00000000..bfeb16fa --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableControlled/components/HTMLMark.tsx @@ -0,0 +1,34 @@ +import {type MarkProps} from 'rc-marked-input' + +/** + * HTMLMark - Renders marks as HTML elements + * + * Uses native tag with data attributes for metadata. + * Visual styling similar to Obsidian's internal links. + */ +export const HTMLMark = ({value, meta, children}: MarkProps) => { + return ( + { + e.currentTarget.style.backgroundColor = '#d0e8ff' + }} + onMouseLeave={e => { + e.currentTarget.style.backgroundColor = '#e8f3ff' + }} + > + {children || value} + + ) +} diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/components/PlainTextSpan.tsx b/packages/storybook/stories/experimental/SingleEditableControlled/components/PlainTextSpan.tsx new file mode 100644 index 00000000..45c5343d --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableControlled/components/PlainTextSpan.tsx @@ -0,0 +1,17 @@ +import {type HTMLAttributes, type ReactNode} from 'react' + +/** + * PlainTextSpan - Renders text without contentEditable + * + * Since parent Container has contentEditable, we don't need it here. + * Text is rendered as plain text node or non-editable span. + */ +interface PlainTextSpanProps extends HTMLAttributes { + children?: ReactNode +} + +export const PlainTextSpan = ({children}: PlainTextSpanProps) => { + // Just render text as plain node (without span wrapper) + // Browser will handle editing through parent contentEditable + return <>{children} +} diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/components/index.ts b/packages/storybook/stories/experimental/SingleEditableControlled/components/index.ts new file mode 100644 index 00000000..6ce6dcce --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableControlled/components/index.ts @@ -0,0 +1,3 @@ +export {CustomContainer} from './CustomContainer' +export {PlainTextSpan} from './PlainTextSpan' +export {HTMLMark} from './HTMLMark' diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/index.ts b/packages/storybook/stories/experimental/SingleEditableControlled/index.ts new file mode 100644 index 00000000..b46bb9a3 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableControlled/index.ts @@ -0,0 +1 @@ +export {SingleEditableControlled} from './SingleEditableControlled' diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/utils/htmlToPlainText.ts b/packages/storybook/stories/experimental/SingleEditableControlled/utils/htmlToPlainText.ts new file mode 100644 index 00000000..96ba4d9f --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableControlled/utils/htmlToPlainText.ts @@ -0,0 +1,54 @@ +/** + * Converts HTML from contentEditable container back to plain text with markup + * + * This function walks through DOM nodes and reconstructs the original text format: + * - Text nodes → plain text + * - elements → @[value](meta) format + * - Nested content → recursive processing + * + * @param html - innerHTML from the contentEditable container + * @returns Plain text with markup annotations + */ +export function htmlToPlainText(html: string): string { + // Create temporary element to parse HTML + const temp = document.createElement('div') + temp.innerHTML = html + + // Walk through nodes and convert to text + function processNode(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || '' + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement + + // Handle elements - convert back to markup + if (el.tagName === 'MARK') { + // Read current text content (allows editing inside marks) + const value = el.textContent || '' + const meta = el.dataset.meta || '' + return `@[${value}](${meta})` + } + + // Handle line breaks + if (el.tagName === 'BR') { + return '\n' + } + + // Handle div/p as line breaks (browser might insert them) + if (el.tagName === 'DIV' || el.tagName === 'P') { + const childText = Array.from(el.childNodes).map(processNode).join('') + // Add newline before div/p content (except first one) + return childText + } + + // For other elements, process children + return Array.from(el.childNodes).map(processNode).join('') + } + + return '' + } + + return Array.from(temp.childNodes).map(processNode).join('') +} diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/utils/index.ts b/packages/storybook/stories/experimental/SingleEditableControlled/utils/index.ts new file mode 100644 index 00000000..0216794f --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableControlled/utils/index.ts @@ -0,0 +1 @@ +export {htmlToPlainText} from './htmlToPlainText' diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/SingleEditableMarkdown.tsx b/packages/storybook/stories/experimental/SingleEditableMarkdown/SingleEditableMarkdown.tsx new file mode 100644 index 00000000..90804fb9 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/SingleEditableMarkdown.tsx @@ -0,0 +1,143 @@ +import {useEffect, useState} from 'react' +import type {Markup} from 'rc-marked-input' +import {MarkedInput} from 'rc-marked-input' +import {MarkdownContainer, MarkdownText, MarkdownMark} from './components' +import {htmlToMarkdown} from './utils' + +// Markdown markup patterns +const BoldMarkup: Markup = '**__nested__**' +const ItalicMarkup: Markup = '*__nested__*' +const CodeMarkup: Markup = '`__nested__`' +const LinkMarkup: Markup = '[__value__](__meta__)' +const HeadingMarkup: Markup = '# __value__\n' +const BlockquoteMarkup: Markup = '> __value__' + +const MARKDOWN_OPTIONS = [ + { + markup: BoldMarkup, + slotProps: { + mark: ({value, children}: any) => ({ + value, + children, + type: 'bold', + }), + }, + }, + { + markup: ItalicMarkup, + slotProps: { + mark: ({value, children}: any) => ({ + value, + children, + type: 'italic', + }), + }, + }, + { + markup: CodeMarkup, + slotProps: { + mark: ({value, children}: any) => ({ + value, + children, + type: 'code', + }), + }, + }, + { + markup: LinkMarkup, + slotProps: { + mark: ({value, children}: any) => ({ + value, + children, + type: 'link', + }), + }, + }, + { + markup: HeadingMarkup, + slotProps: { + mark: ({value, children}: any) => ({ + value, + children, + type: 'heading', + }), + }, + }, + { + markup: BlockquoteMarkup, + slotProps: { + mark: ({value, children}: any) => ({ + value, + children, + type: 'blockquote', + }), + }, + }, +] + +interface SingleEditableMarkdownProps { + onValueChange?: (value: string) => void +} + +/** + * SingleEditableMarkdown - Markdown editor using uncontrolled contentEditable with MutationObserver + * + * Features: + * - Native markdown editing with **bold**, *italic*, `code`, [link](url) + * - Cursor stays in place naturally (uncontrolled approach) + * - Real-time preview and conversion + * - MutationObserver tracks changes without React re-renders + * + * This is the markdown version of SingleEditableUncontrolled! + */ +export const SingleEditableMarkdown = ({onValueChange}: SingleEditableMarkdownProps) => { + const initialValue = `# Welcome to Markdown Editor + +Try editing this text with **bold**, *italic*, \`code\`, and [links](https://example.com). + +> This is a blockquote +> You can add multiple lines + +- List item 1 +- List item 2 +- List item 3` + + const [container, setContainer] = useState(null) + + useEffect(() => { + if (!container) return + + const observer = new MutationObserver(() => { + const html = container.innerHTML + const markdown = htmlToMarkdown(html) + onValueChange?.(markdown) + }) + + observer.observe(container, { + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true, + }) + + return () => observer.disconnect() + }, [container, onValueChange]) + + return ( + + ) +} diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownContainer.tsx b/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownContainer.tsx new file mode 100644 index 00000000..f69e713b --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownContainer.tsx @@ -0,0 +1,53 @@ +import {forwardRef, useCallback, type HTMLAttributes} from 'react' + +/** + * MarkdownContainer - Container with contentEditable for markdown + * + * Similar to CustomContainer but optimized for markdown editing: + * - Only this container has contentEditable={true} + * - Renders markdown content with formatting preserved + * - Browser handles all editing natively + */ +export const MarkdownContainer = forwardRef>((props, ref) => { + const handlePaste = useCallback((e: React.ClipboardEvent) => { + // Prevent default paste behavior (which might include HTML) + e.preventDefault() + + // Get only plain text from clipboard + const text = e.clipboardData.getData('text/plain') + + // Insert plain text at cursor position + document.execCommand('insertText', false, text) + }, []) + + return ( +
{ + e.currentTarget.style.borderColor = '#5b9dd9' + }} + onBlur={e => { + e.currentTarget.style.borderColor = '#e0e0e0' + }} + /> + ) +}) +MarkdownContainer.displayName = 'MarkdownContainer' diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownMark.tsx b/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownMark.tsx new file mode 100644 index 00000000..1f9d0e65 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownMark.tsx @@ -0,0 +1,136 @@ +import {type MarkProps} from 'rc-marked-input' + +/** + * MarkdownMark - Renders markdown formatting marks with distinct visual styles + * + * Supports: **bold**, *italic*, `code`, [link](url), # headings, > blockquotes + * Visual styling for better readability and distinction while editing. + * + * The type prop is passed through slotProps.mark from each option configuration, + * allowing different visual rendering for each markdown type. + */ +export const MarkdownMark = ({value, children, type = 'text'}: MarkProps & {type?: string}) => { + // Use the type passed through slotProps.mark + const markType = type + + // Default styling + const baseStyle = { + padding: '0 2px', + borderRadius: '2px', + transition: 'background-color 0.15s', + } + + switch (markType) { + case 'bold': + return ( + + {children || value} + + ) + + case 'italic': + return ( + + {children || value} + + ) + + case 'code': + return ( + + {children || value} + + ) + + case 'link': + return ( + { + if (!e.ctrlKey && !e.metaKey && !e.shiftKey) { + e.preventDefault() + } + }} + onMouseEnter={e => { + e.currentTarget.style.backgroundColor = 'rgba(3, 102, 214, 0.1)' + }} + onMouseLeave={e => { + e.currentTarget.style.backgroundColor = 'transparent' + }} + > + {children || value} + + ) + + case 'heading': + return ( + + {children || value} + + ) + + case 'blockquote': + return ( + + {children || value} + + ) + + default: + return ( + + {children || value} + + ) + } +} diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownText.tsx b/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownText.tsx new file mode 100644 index 00000000..43cc295c --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownText.tsx @@ -0,0 +1,15 @@ +import {type HTMLAttributes, type ReactNode} from 'react' + +/** + * MarkdownText - Renders markdown text content + * + * Since parent Container has contentEditable, we render text as-is. + * Browser will handle editing through parent contentEditable. + */ +interface MarkdownTextProps extends HTMLAttributes { + children?: ReactNode +} + +export const MarkdownText = ({children}: MarkdownTextProps) => { + return <>{children} +} diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/components/index.ts b/packages/storybook/stories/experimental/SingleEditableMarkdown/components/index.ts new file mode 100644 index 00000000..0c789d4c --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/components/index.ts @@ -0,0 +1,3 @@ +export {MarkdownContainer} from './MarkdownContainer' +export {MarkdownText} from './MarkdownText' +export {MarkdownMark} from './MarkdownMark' diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/index.ts b/packages/storybook/stories/experimental/SingleEditableMarkdown/index.ts new file mode 100644 index 00000000..0affbcec --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/index.ts @@ -0,0 +1 @@ +export {SingleEditableMarkdown} from './SingleEditableMarkdown' diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/htmlToMarkdown.ts b/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/htmlToMarkdown.ts new file mode 100644 index 00000000..98cde777 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/htmlToMarkdown.ts @@ -0,0 +1,98 @@ +/** + * Converts HTML from contentEditable container back to markdown format + * + * This function walks through DOM nodes and reconstructs the original markdown format: + * - Text nodes → plain text + * - elements → **text** format + * - elements → *text* format + * - elements → `code` format + * - elements → [text](url) format + * - Preserves line breaks and structure + * + * @param html - innerHTML from the contentEditable container + * @returns Markdown text with formatting + */ +export function htmlToMarkdown(html: string): string { + // Create temporary element to parse HTML + const temp = document.createElement('div') + temp.innerHTML = html + + // Walk through nodes and convert to markdown + function processNode(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || '' + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement + + // Handle elements → **text** + if (el.tagName === 'STRONG' || el.tagName === 'B') { + const content = Array.from(el.childNodes).map(processNode).join('') + return `**${content}**` + } + + // Handle elements → *text* + if (el.tagName === 'EM' || el.tagName === 'I') { + const content = Array.from(el.childNodes).map(processNode).join('') + return `*${content}*` + } + + // Handle elements → `code` + if (el.tagName === 'CODE') { + const content = Array.from(el.childNodes).map(processNode).join('') + return `\`${content}\`` + } + + // Handle elements → [text](url) + if (el.tagName === 'A') { + const content = Array.from(el.childNodes).map(processNode).join('') + const href = el.getAttribute('href') || '' + return `[${content}](${href})` + } + + // Handle

-

elements → # text + if (el.tagName.match(/^H[1-6]$/)) { + const level = parseInt(el.tagName[1]) + const content = Array.from(el.childNodes).map(processNode).join('') + return `${'#'.repeat(level)} ${content}` + } + + // Handle
elements → > text + if (el.tagName === 'BLOCKQUOTE') { + const content = Array.from(el.childNodes).map(processNode).join('') + return `> ${content}` + } + + // Handle
  • elements → - text + if (el.tagName === 'LI') { + const content = Array.from(el.childNodes).map(processNode).join('') + return `- ${content}` + } + + // Handle line breaks + if (el.tagName === 'BR') { + return '\n' + } + + // Handle div/p as line breaks (browser might insert them) + if (el.tagName === 'DIV' || el.tagName === 'P') { + const childText = Array.from(el.childNodes).map(processNode).join('') + return childText + } + + // Handle
      /
        elements + if (el.tagName === 'UL' || el.tagName === 'OL') { + const childText = Array.from(el.childNodes).map(processNode).join('') + return childText + } + + // For other elements, process children + return Array.from(el.childNodes).map(processNode).join('') + } + + return '' + } + + return Array.from(temp.childNodes).map(processNode).join('').trim() +} diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/index.ts b/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/index.ts new file mode 100644 index 00000000..b12028f4 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/index.ts @@ -0,0 +1,2 @@ +export {htmlToMarkdown} from './htmlToMarkdown' +export {parseMarkdown, type MarkdownToken} from './markdownToHtml' diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/markdownToHtml.ts b/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/markdownToHtml.ts new file mode 100644 index 00000000..8c5219c9 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/markdownToHtml.ts @@ -0,0 +1,166 @@ +/** + * Parses markdown text and returns structured data for rendering + * + * This simple parser handles: + * - **bold** text + * - *italic* text + * - `code` blocks + * - [link](url) syntax + * - # heading text + * - > blockquote text + * - Line breaks + * + * Returns array of tokens for rendering + * + * @param markdown - Markdown text + * @returns Array of parsed tokens + */ +export interface MarkdownToken { + type: 'text' | 'bold' | 'italic' | 'code' | 'link' | 'heading' | 'blockquote' | 'break' + content?: string + url?: string + level?: number // for headings +} + +export function parseMarkdown(markdown: string): MarkdownToken[] { + const tokens: MarkdownToken[] = [] + let i = 0 + const text = markdown + + while (i < text.length) { + // Check for heading + if (text[i] === '#' && (i === 0 || text[i - 1] === '\n')) { + let level = 0 + while (i < text.length && text[i] === '#') { + level++ + i++ + } + + if (text[i] === ' ') { + i++ // skip space + let content = '' + while (i < text.length && text[i] !== '\n') { + content += text[i] + i++ + } + tokens.push({type: 'heading', content: content.trim(), level}) + if (text[i] === '\n') { + tokens.push({type: 'break'}) + i++ + } + continue + } + } + + // Check for blockquote + if (text[i] === '>' && (i === 0 || text[i - 1] === '\n')) { + i++ // skip > + if (text[i] === ' ') i++ // skip space + let content = '' + while (i < text.length && text[i] !== '\n') { + content += text[i] + i++ + } + tokens.push({type: 'blockquote', content}) + if (text[i] === '\n') { + tokens.push({type: 'break'}) + i++ + } + continue + } + + // Check for line breaks + if (text[i] === '\n') { + tokens.push({type: 'break'}) + i++ + continue + } + + // Check for bold **text** + if (text[i] === '*' && text[i + 1] === '*') { + i += 2 // skip ** + let content = '' + while (i < text.length && !(text[i] === '*' && text[i + 1] === '*')) { + content += text[i] + i++ + } + if (i < text.length) i += 2 // skip closing ** + tokens.push({type: 'bold', content}) + continue + } + + // Check for italic *text* + if (text[i] === '*') { + i++ // skip * + let content = '' + while (i < text.length && text[i] !== '*') { + content += text[i] + i++ + } + if (i < text.length) i++ // skip closing * + tokens.push({type: 'italic', content}) + continue + } + + // Check for code `text` + if (text[i] === '`') { + i++ // skip ` + let content = '' + while (i < text.length && text[i] !== '`') { + content += text[i] + i++ + } + if (i < text.length) i++ // skip closing ` + tokens.push({type: 'code', content}) + continue + } + + // Check for link [text](url) + if (text[i] === '[') { + i++ // skip [ + let linkText = '' + while (i < text.length && text[i] !== ']') { + linkText += text[i] + i++ + } + if (i < text.length) i++ // skip ] + + if (text[i] === '(') { + i++ // skip ( + let url = '' + while (i < text.length && text[i] !== ')') { + url += text[i] + i++ + } + if (i < text.length) i++ // skip ) + tokens.push({type: 'link', content: linkText, url}) + continue + } else { + // Not a valid link, treat as text + tokens.push({type: 'text', content: `[${linkText}]`}) + continue + } + } + + // Regular text + let content = '' + while ( + i < text.length && + text[i] !== '*' && + text[i] !== '`' && + text[i] !== '[' && + text[i] !== '\n' && + text[i] !== '#' && + text[i] !== '>' + ) { + content += text[i] + i++ + } + + if (content) { + tokens.push({type: 'text', content}) + } + } + + return tokens +} diff --git a/packages/storybook/stories/experimental/SingleEditableUncontrolled/SingleEditableUncontrolled.tsx b/packages/storybook/stories/experimental/SingleEditableUncontrolled/SingleEditableUncontrolled.tsx new file mode 100644 index 00000000..843047cd --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableUncontrolled/SingleEditableUncontrolled.tsx @@ -0,0 +1,58 @@ +import {useEffect, useState} from 'react' +import {MarkedInput} from 'rc-marked-input' +import {CustomContainer, PlainTextSpan, HTMLMark} from './components' +import {htmlToPlainText} from './utils' + +interface SingleEditableUncontrolledProps { + onValueChange: (value: string) => void +} + +/** + * SingleEditableUncontrolled - Encapsulates uncontrolled contentEditable logic with MutationObserver + * + * Manages its own state and tracks changes via MutationObserver. + * Cursor stays in place naturally. + * Notifies parent component via onValueChange callback. + * + * This is the recommended approach for single contentEditable! + */ +export const SingleEditableUncontrolled = ({onValueChange}: SingleEditableUncontrolledProps) => { + const initialValue = 'Hello @[John](id:123) and @[World](greeting)! Try editing - cursor stays in place!' + const [container, setContainer] = useState(null) + + useEffect(() => { + if (!container) return + + const observer = new MutationObserver(() => { + const html = container.innerHTML + const plainText = htmlToPlainText(html) + onValueChange(plainText) + }) + + observer.observe(container, { + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true, + }) + + return () => observer.disconnect() + }, [container, onValueChange]) + + return ( + + ) +} diff --git a/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/CustomContainer.tsx b/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/CustomContainer.tsx new file mode 100644 index 00000000..0a8db800 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/CustomContainer.tsx @@ -0,0 +1,55 @@ +import {forwardRef, useCallback, type HTMLAttributes} from 'react' + +/** + * CustomContainer - Container with single contentEditable + * + * This is the key component for Obsidian-like approach: + * - Only this container has contentEditable={true} + * - All children (text and marks) are rendered inside + * - Browser handles all editing natively + * + * Important: We prevent the default 'input' event from reaching MarkedInput's + * internal handler, and instead handle it ourselves in the story component. + */ +export const CustomContainer = forwardRef>((props, ref) => { + const handlePaste = useCallback((e: React.ClipboardEvent) => { + // Prevent default paste behavior (which might include HTML) + e.preventDefault() + + // Get only plain text from clipboard + const text = e.clipboardData.getData('text/plain') + + // Insert plain text at cursor position + document.execCommand('insertText', false, text) + }, []) + + return ( +
        { + e.currentTarget.style.borderColor = '#5b9dd9' + }} + onBlur={e => { + e.currentTarget.style.borderColor = '#e0e0e0' + }} + /> + ) +}) +CustomContainer.displayName = 'CustomContainer' diff --git a/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/HTMLMark.tsx b/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/HTMLMark.tsx new file mode 100644 index 00000000..bfeb16fa --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/HTMLMark.tsx @@ -0,0 +1,34 @@ +import {type MarkProps} from 'rc-marked-input' + +/** + * HTMLMark - Renders marks as HTML elements + * + * Uses native tag with data attributes for metadata. + * Visual styling similar to Obsidian's internal links. + */ +export const HTMLMark = ({value, meta, children}: MarkProps) => { + return ( + { + e.currentTarget.style.backgroundColor = '#d0e8ff' + }} + onMouseLeave={e => { + e.currentTarget.style.backgroundColor = '#e8f3ff' + }} + > + {children || value} + + ) +} diff --git a/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/PlainTextSpan.tsx b/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/PlainTextSpan.tsx new file mode 100644 index 00000000..45c5343d --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/PlainTextSpan.tsx @@ -0,0 +1,17 @@ +import {type HTMLAttributes, type ReactNode} from 'react' + +/** + * PlainTextSpan - Renders text without contentEditable + * + * Since parent Container has contentEditable, we don't need it here. + * Text is rendered as plain text node or non-editable span. + */ +interface PlainTextSpanProps extends HTMLAttributes { + children?: ReactNode +} + +export const PlainTextSpan = ({children}: PlainTextSpanProps) => { + // Just render text as plain node (without span wrapper) + // Browser will handle editing through parent contentEditable + return <>{children} +} diff --git a/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/index.ts b/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/index.ts new file mode 100644 index 00000000..6ce6dcce --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableUncontrolled/components/index.ts @@ -0,0 +1,3 @@ +export {CustomContainer} from './CustomContainer' +export {PlainTextSpan} from './PlainTextSpan' +export {HTMLMark} from './HTMLMark' diff --git a/packages/storybook/stories/experimental/SingleEditableUncontrolled/index.ts b/packages/storybook/stories/experimental/SingleEditableUncontrolled/index.ts new file mode 100644 index 00000000..e7c73681 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableUncontrolled/index.ts @@ -0,0 +1 @@ +export {SingleEditableUncontrolled} from './SingleEditableUncontrolled' diff --git a/packages/storybook/stories/experimental/SingleEditableUncontrolled/utils/htmlToPlainText.ts b/packages/storybook/stories/experimental/SingleEditableUncontrolled/utils/htmlToPlainText.ts new file mode 100644 index 00000000..96ba4d9f --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableUncontrolled/utils/htmlToPlainText.ts @@ -0,0 +1,54 @@ +/** + * Converts HTML from contentEditable container back to plain text with markup + * + * This function walks through DOM nodes and reconstructs the original text format: + * - Text nodes → plain text + * - elements → @[value](meta) format + * - Nested content → recursive processing + * + * @param html - innerHTML from the contentEditable container + * @returns Plain text with markup annotations + */ +export function htmlToPlainText(html: string): string { + // Create temporary element to parse HTML + const temp = document.createElement('div') + temp.innerHTML = html + + // Walk through nodes and convert to text + function processNode(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || '' + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement + + // Handle elements - convert back to markup + if (el.tagName === 'MARK') { + // Read current text content (allows editing inside marks) + const value = el.textContent || '' + const meta = el.dataset.meta || '' + return `@[${value}](${meta})` + } + + // Handle line breaks + if (el.tagName === 'BR') { + return '\n' + } + + // Handle div/p as line breaks (browser might insert them) + if (el.tagName === 'DIV' || el.tagName === 'P') { + const childText = Array.from(el.childNodes).map(processNode).join('') + // Add newline before div/p content (except first one) + return childText + } + + // For other elements, process children + return Array.from(el.childNodes).map(processNode).join('') + } + + return '' + } + + return Array.from(temp.childNodes).map(processNode).join('') +} diff --git a/packages/storybook/stories/experimental/SingleEditableUncontrolled/utils/index.ts b/packages/storybook/stories/experimental/SingleEditableUncontrolled/utils/index.ts new file mode 100644 index 00000000..0216794f --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableUncontrolled/utils/index.ts @@ -0,0 +1 @@ +export {htmlToPlainText} from './htmlToPlainText'