From c05f799733786330e7e564a26b120c8738dc62ed Mon Sep 17 00:00:00 2001 From: Nowely Date: Fri, 6 Mar 2026 18:12:37 +0300 Subject: [PATCH 1/4] feat(core): add ContentEditableController and integrate with core features - Introduced `ContentEditableController` to manage content editable states. - Registered `ContentEditableController` in core features and store. - Updated `Lifecycle` to synchronize content editable state during focus recovery. - Removed readOnly handling from `TextSpan` components in React and Vue, allowing for dynamic content editing. --- packages/common/core/index.ts | 1 + .../common/core/src/features/coreFeatures.ts | 1 + .../editable/ContentEditableController.ts | 32 +++++++++++++++++++ .../core/src/features/editable/index.ts | 1 + .../core/src/features/lifecycle/Lifecycle.ts | 1 + .../common/core/src/features/store/Store.ts | 2 ++ .../react/markput/src/components/TextSpan.tsx | 3 +- .../vue/markput/src/components/TextSpan.vue | 7 ++-- 8 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 packages/common/core/src/features/editable/ContentEditableController.ts create mode 100644 packages/common/core/src/features/editable/index.ts 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..a473e0c7 --- /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.sync() + this.#unsubscribe = this.store.state.readOnly.on(() => 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/vue/markput/src/components/TextSpan.vue b/packages/vue/markput/src/components/TextSpan.vue index e54ab14c..0e80ded1 100644 --- a/packages/vue/markput/src/components/TextSpan.vue +++ b/packages/vue/markput/src/components/TextSpan.vue @@ -11,9 +11,8 @@ const tokenRef = inject(TOKEN_KEY)! const token = tokenRef.value const elRef = ref(null) -const readOnly = store.state.readOnly.use() -const slots = store.state.slots.use() -const slotProps = store.state.slotProps.use() +const slots = store.state.slots.use() as unknown as Ref +const slotProps = store.state.slotProps.use() as unknown as Ref const spanTag = computed(() => resolveSlot('span', slots.value)) const spanProps = computed(() => resolveSlotProps('span', slotProps.value)) @@ -38,5 +37,5 @@ watch( From d41972ffb5632530e88d4903d939112dd96d90d4 Mon Sep 17 00:00:00 2001 From: Nowely Date: Fri, 6 Mar 2026 19:54:15 +0300 Subject: [PATCH 2/4] fix(editable): ensure sync is called when enabling ContentEditableController - Moved the `this.sync()` call to the beginning of the `enable` method to ensure synchronization occurs immediately when the controller is enabled. - Updated tests in `Base.spec.tsx` and `slots.spec.tsx` to check for the presence of elements and their attributes more reliably, improving test accuracy. --- .../editable/ContentEditableController.ts | 2 +- .../storybook/src/pages/Base/Base.spec.tsx | 2 +- .../storybook/src/pages/Slots/slots.spec.tsx | 41 ++++++++++--------- .../vue/markput/src/components/TextSpan.vue | 2 +- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/packages/common/core/src/features/editable/ContentEditableController.ts b/packages/common/core/src/features/editable/ContentEditableController.ts index a473e0c7..c0260270 100644 --- a/packages/common/core/src/features/editable/ContentEditableController.ts +++ b/packages/common/core/src/features/editable/ContentEditableController.ts @@ -8,8 +8,8 @@ export class ContentEditableController { enable() { if (this.#unsubscribe) return - this.sync() this.#unsubscribe = this.store.state.readOnly.on(() => this.sync()) + this.sync() } disable() { 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 0e80ded1..78f5e393 100644 --- a/packages/vue/markput/src/components/TextSpan.vue +++ b/packages/vue/markput/src/components/TextSpan.vue @@ -1,6 +1,6 @@