From 62fa10c4cb29ae136cdcd8ac19224fe3393b864f Mon Sep 17 00:00:00 2001 From: Nowely Date: Tue, 18 Nov 2025 22:57:39 +0300 Subject: [PATCH 1/9] Add SingleContentEditable stories to Storybook ## Summary - Introduced a new Storybook story for the `MarkedInput` component showcasing a single contentEditable approach. - Implemented a `CustomContainer` component to handle content editing with native browser support. - Added utility functions for managing selection and converting HTML to plain text with markup. - Demonstrated both controlled and uncontrolled editing approaches, highlighting the benefits of using MutationObserver for a smoother editing experience. ## Key Changes 1. **Custom Components**: Created `CustomContainer`, `PlainTextSpan`, and `HTMLMark` for rendering editable content. 2. **Utility Functions**: Added functions for saving and restoring selection, and converting HTML to plain text. 3. **Story Examples**: Provided examples of controlled vs uncontrolled editing, emphasizing the advantages of the uncontrolled approach. ## Benefits - Enhanced user experience for editing content with a more natural cursor behavior. - Clear demonstration of the component's capabilities in Storybook. --- .../stories/SingleContentEditable.stories.tsx | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 packages/storybook/stories/SingleContentEditable.stories.tsx diff --git a/packages/storybook/stories/SingleContentEditable.stories.tsx b/packages/storybook/stories/SingleContentEditable.stories.tsx new file mode 100644 index 00000000..aca04fab --- /dev/null +++ b/packages/storybook/stories/SingleContentEditable.stories.tsx @@ -0,0 +1,423 @@ +import type {Meta, StoryObj} from '@storybook/react-vite' +import {MarkedInput} from 'rc-marked-input' +import type {MarkProps} from 'rc-marked-input' +import {forwardRef, useState, useCallback, useEffect, type HTMLAttributes, type ReactNode} from 'react' +import {Text} from '../assets/Text' + +export default { + title: 'MarkedInput/Single ContentEditable', + tags: ['autodocs'], + component: MarkedInput, +} satisfies Meta + +type Story = StoryObj> + +// ============================================================================ +// Custom Components for Single ContentEditable Approach +// ============================================================================ + +/** + * 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. + */ +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' + +/** + * 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 TextSpanProps extends HTMLAttributes { + children?: ReactNode +} + +const PlainTextSpan = forwardRef(({children, ...props}, ref) => { + // Just render text as plain node (without span wrapper) + // Browser will handle editing through parent contentEditable + return <>{children} +}) +PlainTextSpan.displayName = 'PlainTextSpan' + +/** + * HTMLMark - Renders marks as HTML elements + * + * Uses native tag with data attributes for metadata. + * Visual styling similar to Obsidian's internal links. + */ +const HTMLMark = ({value, meta, children}: MarkProps) => { + return ( + { + e.currentTarget.style.backgroundColor = '#d0e8ff' + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = '#e8f3ff' + }} + > + {children || value} + + ) +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * 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 + */ +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('') +} + +/** + * Saves current selection (cursor position) in the contentEditable + * + * @param container - The contentEditable element + * @returns Selection state or null + */ +function saveSelection(container: HTMLElement | null) { + if (!container) return null + + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return null + + const range = selection.getRangeAt(0) + + // Calculate offset from container start + const preSelectionRange = range.cloneRange() + preSelectionRange.selectNodeContents(container) + preSelectionRange.setEnd(range.startContainer, range.startOffset) + + const start = preSelectionRange.toString().length + const end = start + range.toString().length + + return {start, end} +} + +/** + * Restores selection (cursor position) in the contentEditable + * + * @param container - The contentEditable element + * @param saved - Saved selection state + */ +function restoreSelection(container: HTMLElement | null, saved: {start: number; end: number} | null) { + if (!container || !saved) return + + const selection = window.getSelection() + if (!selection) return + + let charIndex = 0 + let foundStart = false + let foundEnd = false + const range = document.createRange() + range.setStart(container, 0) + range.collapse(true) + + const nodeStack: Node[] = [container] + + while (!foundEnd && nodeStack.length > 0) { + const node = nodeStack.pop()! + + if (node.nodeType === Node.TEXT_NODE) { + const textLength = node.textContent?.length || 0 + + if (!foundStart && charIndex + textLength >= saved.start) { + range.setStart(node, saved.start - charIndex) + foundStart = true + } + + if (foundStart && charIndex + textLength >= saved.end) { + range.setEnd(node, saved.end - charIndex) + foundEnd = true + } + + charIndex += textLength + } else { + // Add child nodes to stack in reverse order + const children = Array.from(node.childNodes).reverse() + nodeStack.push(...children) + } + } + + try { + selection.removeAllRanges() + selection.addRange(range) + } catch (e) { + // Silently fail - cursor will be placed at end + console.warn('Could not restore selection:', e) + } +} + +// ============================================================================ +// 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('Hello @[John](id:123) and @[World](greeting)!') + const containerRef = useState(null)[1] + + // Manual input handler to force React re-renders (this causes cursor reset!) + const handleInput = useCallback( + (e: React.FormEvent) => { + const html = e.currentTarget.innerHTML + const plainText = htmlToPlainText(html) + setValue(plainText) + }, + [] + ) + + return ( + <> +
+

❌ Controlled (React-managed)

+

+ ⚠️ Try typing - cursor will reset! This shows the problem. +

+
+ + {}} // Not used - we use manual onInput instead + Mark={HTMLMark} + slots={{ + container: CustomContainer, + span: PlainTextSpan, + }} + slotProps={{ + container: { + ref: containerRef, + onInput: handleInput, + } as any, + }} + /> + + + +
+ ⚠️ Problems with this approach: +
    +
  • React re-renders DOM on every keystroke
  • +
  • Cursor resets to beginning or end
  • +
  • Unusable for actual editing
  • +
  • Conflicts with MarkedInput's internal systems
  • +
+

+ 👉 See Uncontrolled story below for the working solution! +

+
+ + ) + }, +} + +/** + * 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 initialValue = 'Hello @[John](id:123) and @[World](greeting)! Try editing - cursor stays in place!' + const [value, setValue] = useState(initialValue) + const [container, setContainer] = useState(null) + + useEffect(() => { + if (!container) return + + const observer = new MutationObserver(() => { + const html = container.innerHTML + const plainText = htmlToPlainText(html) + setValue(plainText) + }) + + observer.observe(container, { + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true, + }) + + return () => observer.disconnect() + }, [container]) + + return ( + <> +
+

✅ Uncontrolled (MutationObserver)

+

+ ✨ Try typing - cursor stays in place! This is the working solution. +

+
+ + + + + +
+ ✅ Why this works: +
    +
  • defaultValue - React doesn't control content
  • +
  • MutationObserver - tracks DOM changes efficiently
  • +
  • No re-renders - cursor position preserved
  • +
  • Native editing - browser handles everything
  • +
+

+ 🎯 Use this approach for production! +

+
+ +
+ 💡 Try these actions: +
    +
  • Type text before/after/inside marks
  • +
  • Edit text inside marks (changes textContent)
  • +
  • Delete marks with Backspace/Delete
  • +
  • Navigate with arrow keys
  • +
  • Copy/paste text
  • +
+
+ + ) + }, +} From 24c9b617b4201a62deb0846b6b6229a431d338e4 Mon Sep 17 00:00:00 2001 From: Nowely Date: Wed, 19 Nov 2025 13:37:01 +0300 Subject: [PATCH 2/9] Remove unused selection management functions from SingleContentEditable stories --- .../stories/SingleContentEditable.stories.tsx | 79 ------------------- 1 file changed, 79 deletions(-) diff --git a/packages/storybook/stories/SingleContentEditable.stories.tsx b/packages/storybook/stories/SingleContentEditable.stories.tsx index aca04fab..d3d785f2 100644 --- a/packages/storybook/stories/SingleContentEditable.stories.tsx +++ b/packages/storybook/stories/SingleContentEditable.stories.tsx @@ -179,85 +179,6 @@ function htmlToPlainText(html: string): string { return Array.from(temp.childNodes).map(processNode).join('') } -/** - * Saves current selection (cursor position) in the contentEditable - * - * @param container - The contentEditable element - * @returns Selection state or null - */ -function saveSelection(container: HTMLElement | null) { - if (!container) return null - - const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return null - - const range = selection.getRangeAt(0) - - // Calculate offset from container start - const preSelectionRange = range.cloneRange() - preSelectionRange.selectNodeContents(container) - preSelectionRange.setEnd(range.startContainer, range.startOffset) - - const start = preSelectionRange.toString().length - const end = start + range.toString().length - - return {start, end} -} - -/** - * Restores selection (cursor position) in the contentEditable - * - * @param container - The contentEditable element - * @param saved - Saved selection state - */ -function restoreSelection(container: HTMLElement | null, saved: {start: number; end: number} | null) { - if (!container || !saved) return - - const selection = window.getSelection() - if (!selection) return - - let charIndex = 0 - let foundStart = false - let foundEnd = false - const range = document.createRange() - range.setStart(container, 0) - range.collapse(true) - - const nodeStack: Node[] = [container] - - while (!foundEnd && nodeStack.length > 0) { - const node = nodeStack.pop()! - - if (node.nodeType === Node.TEXT_NODE) { - const textLength = node.textContent?.length || 0 - - if (!foundStart && charIndex + textLength >= saved.start) { - range.setStart(node, saved.start - charIndex) - foundStart = true - } - - if (foundStart && charIndex + textLength >= saved.end) { - range.setEnd(node, saved.end - charIndex) - foundEnd = true - } - - charIndex += textLength - } else { - // Add child nodes to stack in reverse order - const children = Array.from(node.childNodes).reverse() - nodeStack.push(...children) - } - } - - try { - selection.removeAllRanges() - selection.addRange(range) - } catch (e) { - // Silently fail - cursor will be placed at end - console.warn('Could not restore selection:', e) - } -} - // ============================================================================ // Story Examples // ============================================================================ From e4c748e135b135181a23259422d696a2f3f853ed Mon Sep 17 00:00:00 2001 From: Nowely Date: Wed, 19 Nov 2025 13:42:14 +0300 Subject: [PATCH 3/9] Add SingleContentEditable story to Storybook ## Summary - Introduced a new story for the `MarkedInput` component demonstrating a single contentEditable approach. - Implemented `CustomContainer`, `PlainTextSpan`, and `HTMLMark` components for enhanced editing capabilities. - Added utility functions for converting HTML to plain text with markup. - Showcased both controlled and uncontrolled editing methods, emphasizing the advantages of using MutationObserver for a smoother user experience. ## Key Changes 1. **Custom Components**: Created components to manage editable content effectively. 2. **Utility Functions**: Added functions for HTML conversion and text processing. 3. **Story Examples**: Provided examples illustrating the differences between controlled and uncontrolled editing approaches. ## Benefits - Improved user experience with natural cursor behavior during editing. - Clear demonstration of the component's functionality in Storybook. --- .../SingleContentEditable.stories.tsx | 79 ++++++++----------- 1 file changed, 31 insertions(+), 48 deletions(-) rename packages/storybook/stories/{ => experimental}/SingleContentEditable.stories.tsx (79%) diff --git a/packages/storybook/stories/SingleContentEditable.stories.tsx b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx similarity index 79% rename from packages/storybook/stories/SingleContentEditable.stories.tsx rename to packages/storybook/stories/experimental/SingleContentEditable.stories.tsx index d3d785f2..c1b65766 100644 --- a/packages/storybook/stories/SingleContentEditable.stories.tsx +++ b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx @@ -2,10 +2,10 @@ import type {Meta, StoryObj} from '@storybook/react-vite' import {MarkedInput} from 'rc-marked-input' import type {MarkProps} from 'rc-marked-input' import {forwardRef, useState, useCallback, useEffect, type HTMLAttributes, type ReactNode} from 'react' -import {Text} from '../assets/Text' +import {Text} from '../../assets/Text' export default { - title: 'MarkedInput/Single ContentEditable', + title: 'Experimental/Single ContentEditable', tags: ['autodocs'], component: MarkedInput, } satisfies Meta @@ -59,10 +59,10 @@ const CustomContainer = forwardRef { + onFocus={e => { e.currentTarget.style.borderColor = '#5b9dd9' }} - onBlur={(e) => { + onBlur={e => { e.currentTarget.style.borderColor = '#e0e0e0' }} /> @@ -80,12 +80,11 @@ interface TextSpanProps extends HTMLAttributes { children?: ReactNode } -const PlainTextSpan = forwardRef(({children, ...props}, ref) => { +const PlainTextSpan = ({children}: TextSpanProps) => { // Just render text as plain node (without span wrapper) // Browser will handle editing through parent contentEditable return <>{children} -}) -PlainTextSpan.displayName = 'PlainTextSpan' +} /** * HTMLMark - Renders marks as HTML elements @@ -108,10 +107,10 @@ const HTMLMark = ({value, meta, children}: MarkProps) => { cursor: 'pointer', transition: 'background-color 0.15s', }} - onMouseEnter={(e) => { + onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#d0e8ff' }} - onMouseLeave={(e) => { + onMouseLeave={e => { e.currentTarget.style.backgroundColor = '#e8f3ff' }} > @@ -199,14 +198,11 @@ export const Controlled: Story = { const containerRef = useState(null)[1] // Manual input handler to force React re-renders (this causes cursor reset!) - const handleInput = useCallback( - (e: React.FormEvent) => { - const html = e.currentTarget.innerHTML - const plainText = htmlToPlainText(html) - setValue(plainText) - }, - [] - ) + const handleInput = useCallback((e: React.FormEvent) => { + const html = e.currentTarget.innerHTML + const plainText = htmlToPlainText(html) + setValue(plainText) + }, []) return ( <> @@ -236,12 +232,26 @@ export const Controlled: Story = { -
+
⚠️ Problems with this approach:
    -
  • React re-renders DOM on every keystroke
  • -
  • Cursor resets to beginning or end
  • -
  • Unusable for actual editing
  • +
  • + React re-renders DOM on every keystroke +
  • +
  • + Cursor resets to beginning or end +
  • +
  • + Unusable for actual editing +
  • Conflicts with MarkedInput's internal systems

@@ -293,9 +303,6 @@ export const Uncontrolled: Story = { <>

✅ Uncontrolled (MutationObserver)

-

- ✨ Try typing - cursor stays in place! This is the working solution. -

- -
- ✅ Why this works: -
    -
  • defaultValue - React doesn't control content
  • -
  • MutationObserver - tracks DOM changes efficiently
  • -
  • No re-renders - cursor position preserved
  • -
  • Native editing - browser handles everything
  • -
-

- 🎯 Use this approach for production! -

-
- -
- 💡 Try these actions: -
    -
  • Type text before/after/inside marks
  • -
  • Edit text inside marks (changes textContent)
  • -
  • Delete marks with Backspace/Delete
  • -
  • Navigate with arrow keys
  • -
  • Copy/paste text
  • -
-
) }, From 6e0d624829d370f7a5579a897bb94d1b97af6194 Mon Sep 17 00:00:00 2001 From: Nowely Date: Wed, 19 Nov 2025 13:48:47 +0300 Subject: [PATCH 4/9] Remove warning messages from Controlled story in SingleContentEditable component --- .../SingleContentEditable.stories.tsx | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx index c1b65766..41db3473 100644 --- a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx +++ b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx @@ -208,9 +208,6 @@ export const Controlled: Story = { <>

❌ Controlled (React-managed)

-

- ⚠️ Try typing - cursor will reset! This shows the problem. -

- -
- ⚠️ Problems with this approach: -
    -
  • - React re-renders DOM on every keystroke -
  • -
  • - Cursor resets to beginning or end -
  • -
  • - Unusable for actual editing -
  • -
  • Conflicts with MarkedInput's internal systems
  • -
-

- 👉 See Uncontrolled story below for the working solution! -

-
) }, From 16ce84c891b38415ad31e4bd28222fc0fbde283f Mon Sep 17 00:00:00 2001 From: Nowely Date: Wed, 19 Nov 2025 14:05:45 +0300 Subject: [PATCH 5/9] Add controlled and uncontrolled MarkedInput components to SingleContentEditable stories ## Summary - Introduced `MarkedInputControlled` and `MarkedInputUncontrolled` components to demonstrate controlled and uncontrolled contentEditable behavior. - Enhanced the editing experience by addressing cursor position issues in controlled components and utilizing MutationObserver in uncontrolled components. - Updated story examples to utilize the new components, improving clarity on their usage and benefits. ## Key Changes 1. **New Components**: Added `MarkedInputControlled` for controlled editing and `MarkedInputUncontrolled` for uncontrolled editing. 2. **Story Updates**: Refactored story examples to use the new components, showcasing their functionality and differences. ## Benefits - Improved user experience with better cursor management during editing. - Clearer demonstration of controlled vs uncontrolled editing approaches in Storybook. --- .../SingleContentEditable.stories.tsx | 169 +++++++++++------- 1 file changed, 108 insertions(+), 61 deletions(-) diff --git a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx index 41db3473..3293e044 100644 --- a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx +++ b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx @@ -178,6 +178,110 @@ function htmlToPlainText(html: string): string { return Array.from(temp.childNodes).map(processNode).join('') } +// ============================================================================ +// Wrapper Components for MarkedInput +// ============================================================================ + +interface MarkedInputControlledProps { + onValueChange: (value: string) => void +} + +/** + * MarkedInputControlled - 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 + */ +const MarkedInputControlled = ({onValueChange}: MarkedInputControlledProps) => { + 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, + }} + /> + ) +} + +interface MarkedInputUncontrolledProps { + onValueChange: (value: string) => void +} + +/** + * MarkedInputUncontrolled - 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! + */ +const MarkedInputUncontrolled = ({onValueChange}: MarkedInputUncontrolledProps) => { + 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 ( + + ) +} + // ============================================================================ // Story Examples // ============================================================================ @@ -194,15 +298,7 @@ function htmlToPlainText(html: string): string { */ export const Controlled: Story = { render: () => { - const [value, setValue] = useState('Hello @[John](id:123) and @[World](greeting)!') - const containerRef = useState(null)[1] - - // Manual input handler to force React re-renders (this causes cursor reset!) - const handleInput = useCallback((e: React.FormEvent) => { - const html = e.currentTarget.innerHTML - const plainText = htmlToPlainText(html) - setValue(plainText) - }, []) + const [value, setValue] = useState('') return ( <> @@ -210,22 +306,7 @@ export const Controlled: Story = {

❌ Controlled (React-managed)

- {}} // Not used - we use manual onInput instead - Mark={HTMLMark} - slots={{ - container: CustomContainer, - span: PlainTextSpan, - }} - slotProps={{ - container: { - ref: containerRef, - onInput: handleInput, - } as any, - }} - /> + @@ -246,28 +327,7 @@ export const Controlled: Story = { */ export const Uncontrolled: Story = { render: () => { - const initialValue = 'Hello @[John](id:123) and @[World](greeting)! Try editing - cursor stays in place!' - const [value, setValue] = useState(initialValue) - const [container, setContainer] = useState(null) - - useEffect(() => { - if (!container) return - - const observer = new MutationObserver(() => { - const html = container.innerHTML - const plainText = htmlToPlainText(html) - setValue(plainText) - }) - - observer.observe(container, { - characterData: true, - characterDataOldValue: true, - childList: true, - subtree: true, - }) - - return () => observer.disconnect() - }, [container]) + const [value, setValue] = useState('') return ( <> @@ -275,20 +335,7 @@ export const Uncontrolled: Story = {

✅ Uncontrolled (MutationObserver)

- + From 1a34d3d94681915d3a342e1d7f28ebc46a81a40a Mon Sep 17 00:00:00 2001 From: Nowely Date: Wed, 19 Nov 2025 14:08:35 +0300 Subject: [PATCH 6/9] Refactor MarkedInput components to SingleEditable naming convention ## Summary - Renamed `MarkedInputControlled` and `MarkedInputUncontrolled` components to `SingleEditableControlled` and `SingleEditableUncontrolled` for clarity and consistency. - Updated associated interfaces and story examples to reflect the new naming, enhancing the overall readability and understanding of the components. ## Key Changes 1. **Component Renaming**: Changed component names to better represent their functionality as single editable components. 2. **Interface Updates**: Adjusted interfaces to match the new component names. 3. **Story Adjustments**: Modified story examples to utilize the renamed components, ensuring consistency across the documentation. ## Benefits - Improved clarity in component naming, making it easier for developers to understand their purpose. - Enhanced documentation and story examples for better user experience in Storybook. --- .../SingleContentEditable.stories.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx index 3293e044..7c8ad060 100644 --- a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx +++ b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx @@ -179,15 +179,15 @@ function htmlToPlainText(html: string): string { } // ============================================================================ -// Wrapper Components for MarkedInput +// Single Editable Components // ============================================================================ -interface MarkedInputControlledProps { +interface SingleEditableControlledProps { onValueChange: (value: string) => void } /** - * MarkedInputControlled - Encapsulates controlled contentEditable logic + * SingleEditableControlled - Encapsulates controlled contentEditable logic * * Manages its own state and re-renders on every change. * Notifies parent component via onValueChange callback. @@ -197,7 +197,7 @@ interface MarkedInputControlledProps { * - Cursor position resets * - Editing experience is broken */ -const MarkedInputControlled = ({onValueChange}: MarkedInputControlledProps) => { +const SingleEditableControlled = ({onValueChange}: SingleEditableControlledProps) => { const [value, setValue] = useState('Hello @[John](id:123) and @[World](greeting)!') const containerRef = useState(null)[1] @@ -228,12 +228,12 @@ const MarkedInputControlled = ({onValueChange}: MarkedInputControlledProps) => { ) } -interface MarkedInputUncontrolledProps { +interface SingleEditableUncontrolledProps { onValueChange: (value: string) => void } /** - * MarkedInputUncontrolled - Encapsulates uncontrolled contentEditable logic with MutationObserver + * SingleEditableUncontrolled - Encapsulates uncontrolled contentEditable logic with MutationObserver * * Manages its own state and tracks changes via MutationObserver. * Cursor stays in place naturally. @@ -241,7 +241,7 @@ interface MarkedInputUncontrolledProps { * * This is the recommended approach for single contentEditable! */ -const MarkedInputUncontrolled = ({onValueChange}: MarkedInputUncontrolledProps) => { +const SingleEditableUncontrolled = ({onValueChange}: SingleEditableUncontrolledProps) => { const initialValue = 'Hello @[John](id:123) and @[World](greeting)! Try editing - cursor stays in place!' const [container, setContainer] = useState(null) @@ -306,7 +306,7 @@ export const Controlled: Story = {

❌ Controlled (React-managed)

- + @@ -335,7 +335,7 @@ export const Uncontrolled: Story = {

✅ Uncontrolled (MutationObserver)

- + From 83f0661ca1d4a7e89073237b9894baae69b493de Mon Sep 17 00:00:00 2001 From: Nowely Date: Wed, 19 Nov 2025 16:10:39 +0300 Subject: [PATCH 7/9] Add SingleEditable components for controlled and uncontrolled editing ## Summary - Introduced `SingleEditableControlled` and `SingleEditableUncontrolled` components to manage contentEditable behavior effectively. - Implemented a `CustomContainer`, `PlainTextSpan`, and `HTMLMark` for enhanced editing capabilities. - Added utility functions for converting HTML to plain text with markup. ## Key Changes 1. **New Components**: Created `SingleEditableControlled` for controlled editing and `SingleEditableUncontrolled` for uncontrolled editing. 2. **Custom Components**: Developed supporting components to facilitate editing and rendering of content. 3. **Utility Functions**: Included functions for HTML conversion and text processing. ## Benefits - Improved user experience with better cursor management during editing. - Clear demonstration of controlled vs uncontrolled editing approaches in Storybook. --- .../SingleContentEditable.stories.tsx | 275 +----------------- .../SingleEditableControlled.tsx | 50 ++++ .../components/CustomContainer.tsx | 55 ++++ .../components/HTMLMark.tsx | 34 +++ .../components/PlainTextSpan.tsx | 17 ++ .../components/index.ts | 3 + .../SingleEditableControlled/index.ts | 1 + .../utils/htmlToPlainText.ts | 54 ++++ .../SingleEditableControlled/utils/index.ts | 1 + .../SingleEditableUncontrolled.tsx | 58 ++++ .../components/CustomContainer.tsx | 55 ++++ .../components/HTMLMark.tsx | 34 +++ .../components/PlainTextSpan.tsx | 17 ++ .../components/index.ts | 3 + .../SingleEditableUncontrolled/index.ts | 1 + .../utils/htmlToPlainText.ts | 54 ++++ .../SingleEditableUncontrolled/utils/index.ts | 1 + 17 files changed, 441 insertions(+), 272 deletions(-) create mode 100644 packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableControlled/components/CustomContainer.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableControlled/components/HTMLMark.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableControlled/components/PlainTextSpan.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableControlled/components/index.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableControlled/index.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableControlled/utils/htmlToPlainText.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableControlled/utils/index.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableUncontrolled/SingleEditableUncontrolled.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableUncontrolled/components/CustomContainer.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableUncontrolled/components/HTMLMark.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableUncontrolled/components/PlainTextSpan.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableUncontrolled/components/index.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableUncontrolled/index.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableUncontrolled/utils/htmlToPlainText.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableUncontrolled/utils/index.ts diff --git a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx index 7c8ad060..93020377 100644 --- a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx +++ b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx @@ -1,8 +1,9 @@ import type {Meta, StoryObj} from '@storybook/react-vite' import {MarkedInput} from 'rc-marked-input' -import type {MarkProps} from 'rc-marked-input' -import {forwardRef, useState, useCallback, useEffect, type HTMLAttributes, type ReactNode} from 'react' +import {useState} from 'react' import {Text} from '../../assets/Text' +import {SingleEditableControlled} from './SingleEditableControlled' +import {SingleEditableUncontrolled} from './SingleEditableUncontrolled' export default { title: 'Experimental/Single ContentEditable', @@ -12,276 +13,6 @@ export default { type Story = StoryObj> -// ============================================================================ -// Custom Components for Single ContentEditable Approach -// ============================================================================ - -/** - * 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. - */ -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' - -/** - * 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 TextSpanProps extends HTMLAttributes { - children?: ReactNode -} - -const PlainTextSpan = ({children}: TextSpanProps) => { - // Just render text as plain node (without span wrapper) - // Browser will handle editing through parent contentEditable - return <>{children} -} - -/** - * HTMLMark - Renders marks as HTML elements - * - * Uses native tag with data attributes for metadata. - * Visual styling similar to Obsidian's internal links. - */ -const HTMLMark = ({value, meta, children}: MarkProps) => { - return ( - { - e.currentTarget.style.backgroundColor = '#d0e8ff' - }} - onMouseLeave={e => { - e.currentTarget.style.backgroundColor = '#e8f3ff' - }} - > - {children || value} - - ) -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * 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 - */ -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('') -} - -// ============================================================================ -// Single Editable Components -// ============================================================================ - -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 - */ -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, - }} - /> - ) -} - -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! - */ -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 ( - - ) -} - // ============================================================================ // Story Examples // ============================================================================ diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx b/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx new file mode 100644 index 00000000..f9d62f64 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx @@ -0,0 +1,50 @@ +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/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' From 822a9d4027ddbc394e3ef5524cc6eb024a1ad434 Mon Sep 17 00:00:00 2001 From: Nowely Date: Fri, 21 Nov 2025 13:54:18 +0300 Subject: [PATCH 8/9] Add SingleEditableMarkdown component for markdown editing - Introduced `SingleEditableMarkdown`, an uncontrolled markdown editor that supports various formatting options including bold, italic, code, links, headings, and blockquotes. - Implemented a `MarkdownContainer` for contentEditable functionality, ensuring smooth editing with a MutationObserver. - Added utility functions for converting HTML to markdown format and parsing markdown into structured tokens for rendering. ## Key Changes 1. **New Component**: Created `SingleEditableMarkdown` for markdown editing with real-time preview. 2. **Supporting Components**: Developed `MarkdownContainer`, `MarkdownText`, and `MarkdownMark` for rendering and editing markdown content. 3. **Utility Functions**: Included `htmlToMarkdown` and `parseMarkdown` for handling markdown conversion. ## Benefits - Enhanced user experience with native markdown editing capabilities. - Clear demonstration of markdown features in Storybook. --- .../SingleContentEditable.stories.tsx | 33 ++++ .../SingleEditableMarkdown.tsx | 143 +++++++++++++++ .../components/MarkdownContainer.tsx | 55 ++++++ .../components/MarkdownMark.tsx | 136 ++++++++++++++ .../components/MarkdownText.tsx | 15 ++ .../components/index.ts | 3 + .../SingleEditableMarkdown/index.ts | 1 + .../utils/htmlToMarkdown.ts | 101 +++++++++++ .../SingleEditableMarkdown/utils/index.ts | 2 + .../utils/markdownToHtml.ts | 166 ++++++++++++++++++ 10 files changed, 655 insertions(+) create mode 100644 packages/storybook/stories/experimental/SingleEditableMarkdown/SingleEditableMarkdown.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownContainer.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownMark.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownText.tsx create mode 100644 packages/storybook/stories/experimental/SingleEditableMarkdown/components/index.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableMarkdown/index.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableMarkdown/utils/htmlToMarkdown.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableMarkdown/utils/index.ts create mode 100644 packages/storybook/stories/experimental/SingleEditableMarkdown/utils/markdownToHtml.ts diff --git a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx index 93020377..5d55cfa2 100644 --- a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx +++ b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx @@ -4,6 +4,7 @@ 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', @@ -73,3 +74,35 @@ export const Uncontrolled: Story = { ) }, } + +/** + * 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/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..d9e2a4b9 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/components/MarkdownContainer.tsx @@ -0,0 +1,55 @@ +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..0b6a3620 --- /dev/null +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/htmlToMarkdown.ts @@ -0,0 +1,101 @@ +/** + * 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 +} From 6256a585119f2a168d03ce346f8b24ef2b943500 Mon Sep 17 00:00:00 2001 From: Nowely Date: Fri, 21 Nov 2025 14:29:08 +0300 Subject: [PATCH 9/9] Refactor MarkdownContainer and SingleEditableControlled components for improved readability ## Summary - Cleaned up the formatting of the `handleInput` function in `SingleEditableControlled` for better readability. - Removed unnecessary line breaks in the `MarkdownContainer` component to streamline the code structure. - Simplified the return statement in the `htmlToMarkdown` utility function for clarity. ## Key Changes 1. **Code Formatting**: Enhanced the readability of the `handleInput` function by adjusting its structure. 2. **Component Cleanup**: Removed extraneous line breaks in `MarkdownContainer` to improve code flow. 3. **Utility Function Simplification**: Streamlined the return statement in `htmlToMarkdown` for better clarity. ## Benefits - Improved code maintainability and readability for future development. - Enhanced clarity in component functionality and utility operations. --- .../SingleContentEditable.stories.tsx | 1 - .../SingleEditableControlled.tsx | 15 ++-- .../components/MarkdownContainer.tsx | 80 +++++++++---------- .../utils/htmlToMarkdown.ts | 5 +- 4 files changed, 49 insertions(+), 52 deletions(-) diff --git a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx index 5d55cfa2..0041d7b5 100644 --- a/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx +++ b/packages/storybook/stories/experimental/SingleContentEditable.stories.tsx @@ -105,4 +105,3 @@ export const Markdown: Story = { ) }, } - diff --git a/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx b/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx index f9d62f64..20ccd5d1 100644 --- a/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx +++ b/packages/storybook/stories/experimental/SingleEditableControlled/SingleEditableControlled.tsx @@ -22,12 +22,15 @@ export const SingleEditableControlled = ({onValueChange}: SingleEditableControll 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]) + const handleInput = useCallback( + (e: React.FormEvent) => { + const html = e.currentTarget.innerHTML + const plainText = htmlToPlainText(html) + setValue(plainText) + onValueChange(plainText) + }, + [onValueChange] + ) return ( >( - (props, ref) => { - const handlePaste = useCallback((e: React.ClipboardEvent) => { - // Prevent default paste behavior (which might include HTML) - e.preventDefault() +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') + // Get only plain text from clipboard + const text = e.clipboardData.getData('text/plain') - // Insert plain text at cursor position - document.execCommand('insertText', false, text) - }, []) + // Insert plain text at cursor position + document.execCommand('insertText', false, text) + }, []) - return ( -
        { - e.currentTarget.style.borderColor = '#5b9dd9' - }} - onBlur={e => { - e.currentTarget.style.borderColor = '#e0e0e0' - }} - /> - ) - }, -) + return ( +
        { + e.currentTarget.style.borderColor = '#5b9dd9' + }} + onBlur={e => { + e.currentTarget.style.borderColor = '#e0e0e0' + }} + /> + ) +}) MarkdownContainer.displayName = 'MarkdownContainer' diff --git a/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/htmlToMarkdown.ts b/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/htmlToMarkdown.ts index 0b6a3620..98cde777 100644 --- a/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/htmlToMarkdown.ts +++ b/packages/storybook/stories/experimental/SingleEditableMarkdown/utils/htmlToMarkdown.ts @@ -94,8 +94,5 @@ export function htmlToMarkdown(html: string): string { return '' } - return Array.from(temp.childNodes) - .map(processNode) - .join('') - .trim() + return Array.from(temp.childNodes).map(processNode).join('').trim() }