diff --git a/packages/common/core/index.ts b/packages/common/core/index.ts index 234ff880..c96f32b4 100644 --- a/packages/common/core/index.ts +++ b/packages/common/core/index.ts @@ -48,6 +48,7 @@ export {FocusController} from './src/features/focus' export {KeyDownController} from './src/features/input' export {SystemListenerController} from './src/features/events' export {TextSelectionController} from './src/features/selection' +export {ContentEditableController} from './src/features/editable' export {Caret, TriggerFinder} from './src/features/caret' // Feature Management diff --git a/packages/common/core/src/features/coreFeatures.ts b/packages/common/core/src/features/coreFeatures.ts index 363d1a3c..4176c74c 100644 --- a/packages/common/core/src/features/coreFeatures.ts +++ b/packages/common/core/src/features/coreFeatures.ts @@ -9,6 +9,7 @@ export const createCoreFeatures = (store: Store): FeatureManager => { .register(asFeature('system', store.controllers.system)) .register(asFeature('focus', store.controllers.focus)) .register(asFeature('textSelection', store.controllers.textSelection)) + .register(asFeature('contentEditable', store.controllers.contentEditable)) return manager } \ No newline at end of file diff --git a/packages/common/core/src/features/editable/ContentEditableController.ts b/packages/common/core/src/features/editable/ContentEditableController.ts new file mode 100644 index 00000000..c0260270 --- /dev/null +++ b/packages/common/core/src/features/editable/ContentEditableController.ts @@ -0,0 +1,32 @@ +import type {Store} from '../store/Store' + +export class ContentEditableController { + #unsubscribe?: () => void + + constructor(private store: Store) {} + + enable() { + if (this.#unsubscribe) return + + this.#unsubscribe = this.store.state.readOnly.on(() => this.sync()) + this.sync() + } + + disable() { + this.#unsubscribe?.() + this.#unsubscribe = undefined + } + + sync() { + const container = this.store.refs.container + if (!container) return + + const readOnly = this.store.state.readOnly.get() + const value = readOnly ? 'false' : 'true' + const children = container.children + + for (let i = 0; i < children.length; i += 2) { + ;(children[i] as HTMLElement).contentEditable = value + } + } +} \ No newline at end of file diff --git a/packages/common/core/src/features/editable/index.ts b/packages/common/core/src/features/editable/index.ts new file mode 100644 index 00000000..4c505d8b --- /dev/null +++ b/packages/common/core/src/features/editable/index.ts @@ -0,0 +1 @@ +export {ContentEditableController} from './ContentEditableController' \ No newline at end of file diff --git a/packages/common/core/src/features/lifecycle/Lifecycle.ts b/packages/common/core/src/features/lifecycle/Lifecycle.ts index 6913926a..d6424750 100644 --- a/packages/common/core/src/features/lifecycle/Lifecycle.ts +++ b/packages/common/core/src/features/lifecycle/Lifecycle.ts @@ -73,6 +73,7 @@ export class Lifecycle { * since focus recovery requires the new DOM to be committed. */ recoverFocus() { + this.store.controllers.contentEditable.sync() if (!this.store.state.Mark.get()) return this.store.controllers.focus.recover() } diff --git a/packages/common/core/src/features/store/Store.ts b/packages/common/core/src/features/store/Store.ts index 3722e4db..3f6e60f3 100644 --- a/packages/common/core/src/features/store/Store.ts +++ b/packages/common/core/src/features/store/Store.ts @@ -2,6 +2,7 @@ import {defineState, defineEvents, type UseHookFactory, type StateObject} from ' import {KeyGenerator} from '../../shared/classes/KeyGenerator' import {NodeProxy} from '../../shared/classes/NodeProxy' import type {MarkputHandler, MarkputState, OverlayMatch} from '../../shared/types' +import {ContentEditableController} from '../editable' import {SystemListenerController} from '../events' import {FocusController} from '../focus' import {KeyDownController} from '../input' @@ -44,6 +45,7 @@ export class Store { keydown: new KeyDownController(this), system: new SystemListenerController(this), textSelection: new TextSelectionController(this), + contentEditable: new ContentEditableController(this), } readonly lifecycle = new Lifecycle(this) diff --git a/packages/react/markput/src/components/TextSpan.tsx b/packages/react/markput/src/components/TextSpan.tsx index fa6705f0..e6ca34c3 100644 --- a/packages/react/markput/src/components/TextSpan.tsx +++ b/packages/react/markput/src/components/TextSpan.tsx @@ -9,7 +9,6 @@ export const TextSpan = () => { const store = useStore() const ref = useRef(null) - const readOnly = store.state.readOnly.use() const slots = store.state.slots.use() const slotProps = store.state.slotProps.use() const SpanComponent = useMemo(() => resolveSlot('span', slots), [slots]) @@ -25,5 +24,5 @@ export const TextSpan = () => { } }, [token.content]) - return + return } \ No newline at end of file diff --git a/packages/react/markput/src/lib/hooks/useCoreFeatures.ts b/packages/react/markput/src/lib/hooks/useCoreFeatures.ts index 30f74463..3dac17d2 100644 --- a/packages/react/markput/src/lib/hooks/useCoreFeatures.ts +++ b/packages/react/markput/src/lib/hooks/useCoreFeatures.ts @@ -17,12 +17,12 @@ export function useCoreFeatures(store: Store, ref: React.Ref | u const Mark = store.state.Mark.use() const coreOptions = store.state.options.use() const options = Mark ? coreOptions : undefined + const tokens = store.state.tokens.use() useEffect(() => { store.lifecycle.syncParser(value, options) }, [value, options]) - const tokens = store.state.tokens.use() useEffect(() => { store.lifecycle.recoverFocus() }, [tokens]) diff --git a/packages/react/storybook/src/pages/Base/Base.spec.tsx b/packages/react/storybook/src/pages/Base/Base.spec.tsx index 9a097c49..497f7b70 100644 --- a/packages/react/storybook/src/pages/Base/Base.spec.tsx +++ b/packages/react/storybook/src/pages/Base/Base.spec.tsx @@ -20,7 +20,7 @@ describe(`Component: MarkedInput`, () => { const {container} = await render() const [span] = container.querySelectorAll('span') - await expect.element(span).toHaveTextContent('') + await expect.element(span).toBeInTheDocument() await userEvent.type(span, '@[[mark](1)') diff --git a/packages/react/storybook/src/pages/Slots/slots.spec.tsx b/packages/react/storybook/src/pages/Slots/slots.spec.tsx index 38e57c24..700f8ce9 100644 --- a/packages/react/storybook/src/pages/Slots/slots.spec.tsx +++ b/packages/react/storybook/src/pages/Slots/slots.spec.tsx @@ -96,11 +96,11 @@ describe('Slots API', () => { describe('Span slot', () => { it('should use default span component when no slot is provided', async () => { - const {container} = await render() + await render() - const textSpan = container.querySelector('span[contenteditable]') as HTMLElement + const textSpan = page.getByText('Hello world') await expect.element(textSpan).toBeInTheDocument() - await expect.element(textSpan!).toHaveTextContent('Hello world') + await expect.element(textSpan).toHaveAttribute('contenteditable') }) it('should use custom component from slots.span', async () => { @@ -120,7 +120,7 @@ describe('Slots API', () => { }) it('should pass slotProps.span to the span component', async () => { - const {container} = await render( + await render( { /> ) - const textSpan = container.querySelector('span[contenteditable]') as HTMLElement + const textSpan = page.getByText('Hello world') await expect.element(textSpan).toHaveClass('custom-span-class') await expect.element(textSpan).toHaveAttribute('data-span-custom', 'span-value') }) it('should merge style from slotProps.span', async () => { - const {container} = await render( + await render( { /> ) - const textSpan = container.querySelector('span[contenteditable]') as HTMLElement + const textSpan = page.getByText('Hello world') await expect.element(textSpan).toHaveStyle({fontWeight: 'bold', fontSize: '16px'}) }) }) @@ -246,17 +246,19 @@ describe('Slots API', () => { describe('Span contentEditable attribute', () => { it('should have contentEditable="true" by default on editable span', async () => { - const {container} = await render() + await render() - const textSpan = container.querySelector('span[contenteditable="true"]') as HTMLElement + const textSpan = page.getByText('Hello world') await expect.element(textSpan).toBeInTheDocument() + await expect.element(textSpan).toHaveAttribute('contenteditable') }) it('should have contentEditable="false" when readOnly is true', async () => { - const {container} = await render() + await render() - const textSpan = container.querySelector('span[contenteditable="false"]') as HTMLElement + const textSpan = page.getByText('Hello world') await expect.element(textSpan).toBeInTheDocument() + await expect.element(textSpan).toHaveAttribute('contenteditable', 'false') }) it('should maintain contentEditable on span with custom slot', async () => { @@ -265,15 +267,14 @@ describe('Slots API', () => { await render() const span = page.getByTestId('custom-span') - await expect.element(span).toHaveAttribute('contenteditable', 'true') + await expect.element(span).toHaveAttribute('contenteditable') }) - it('should respect suppressContentEditableWarning when set', async () => { - const {container} = await render() + it('should set contentEditable imperatively via core controller', async () => { + await render() - const textSpan = container.querySelector('span[contenteditable]') as HTMLElement - // Should not throw warning during render - await expect.element(textSpan).toBeInTheDocument() + const textSpan = page.getByText('Hello world') + await expect.element(textSpan).toHaveAttribute('contenteditable') }) }) @@ -388,10 +389,10 @@ describe('Slots API', () => { ) const article = container.querySelector('article') as HTMLElement - const div = container.querySelector('div[contenteditable]') as HTMLElement - await expect.element(article).toBeInTheDocument() - await expect.element(div).toBeInTheDocument() + const textDiv = page.getByText('Hello world') + await expect.element(textDiv).toBeInTheDocument() + await expect.element(textDiv).toHaveAttribute('contenteditable') }) }) diff --git a/packages/vue/markput/src/components/TextSpan.vue b/packages/vue/markput/src/components/TextSpan.vue index e54ab14c..aec4079b 100644 --- a/packages/vue/markput/src/components/TextSpan.vue +++ b/packages/vue/markput/src/components/TextSpan.vue @@ -1,5 +1,4 @@