Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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<typeof MarkedInput>

type Story = StoryObj<Meta<typeof MarkedInput>>

// ============================================================================
// 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 (
<>
<div style={{marginBottom: '16px'}}>
<h3 style={{marginTop: 0}}>❌ Controlled (React-managed)</h3>
</div>

<SingleEditableControlled onValueChange={setValue} />

<Text label="Plain text value:" value={value} />
</>
)
},
}

/**
* 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 (
<>
<div style={{marginBottom: '16px'}}>
<h3 style={{marginTop: 0}}>✅ Uncontrolled (MutationObserver)</h3>
</div>

<SingleEditableUncontrolled onValueChange={setValue} />

<Text label="Plain text value:" value={value} />
</>
)
},
}

/**
* 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 (
<>
<div style={{marginBottom: '16px'}}>
<h3 style={{marginTop: 0}}>📝 Markdown (Uncontrolled)</h3>
</div>

<SingleEditableMarkdown onValueChange={setValue} />

<Text label="Markdown value:" value={value} />
</>
)
},
}
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null)[1]

const handleInput = useCallback(
(e: React.FormEvent<HTMLDivElement>) => {
const html = e.currentTarget.innerHTML
const plainText = htmlToPlainText(html)
setValue(plainText)
onValueChange(plainText)
},
[onValueChange]
)

return (
<MarkedInput
key="single-editable-controlled"
value={value}
onChange={() => {}} // Not used - we use manual onInput instead
Mark={HTMLMark}
slots={{
container: CustomContainer,
span: PlainTextSpan,
}}
slotProps={{
container: {
ref: containerRef,
onInput: handleInput,
} as any,
}}
/>
)
}
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, HTMLAttributes<HTMLDivElement>>((props, ref) => {
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
// 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 (
<div
{...props}
ref={ref}
contentEditable={true}
suppressContentEditableWarning
onPaste={handlePaste}
style={{
minHeight: '100px',
padding: '12px 16px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
fontSize: '14px',
lineHeight: '1.6',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
outline: 'none',
transition: 'border-color 0.2s',
whiteSpace: 'pre-wrap',
...props.style,
}}
onFocus={e => {
e.currentTarget.style.borderColor = '#5b9dd9'
}}
onBlur={e => {
e.currentTarget.style.borderColor = '#e0e0e0'
}}
/>
)
})
CustomContainer.displayName = 'CustomContainer'
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {type MarkProps} from 'rc-marked-input'

/**
* HTMLMark - Renders marks as HTML <mark> elements
*
* Uses native <mark> tag with data attributes for metadata.
* Visual styling similar to Obsidian's internal links.
*/
export const HTMLMark = ({value, meta, children}: MarkProps) => {
return (
<mark
data-value={value}
data-meta={meta}
style={{
backgroundColor: '#e8f3ff',
color: '#1e6bb8',
padding: '2px 4px',
borderRadius: '4px',
fontWeight: 500,
border: '1px solid #b3d9ff',
cursor: 'pointer',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => {
e.currentTarget.style.backgroundColor = '#d0e8ff'
}}
onMouseLeave={e => {
e.currentTarget.style.backgroundColor = '#e8f3ff'
}}
>
{children || value}
</mark>
)
}
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement> {
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}</>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {CustomContainer} from './CustomContainer'
export {PlainTextSpan} from './PlainTextSpan'
export {HTMLMark} from './HTMLMark'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {SingleEditableControlled} from './SingleEditableControlled'
Original file line number Diff line number Diff line change
@@ -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
* - <mark> 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 <mark> 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('')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {htmlToPlainText} from './htmlToPlainText'
Loading