Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/common/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/common/core/src/features/coreFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
1 change: 1 addition & 0 deletions packages/common/core/src/features/editable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {ContentEditableController} from './ContentEditableController'
1 change: 1 addition & 0 deletions packages/common/core/src/features/lifecycle/Lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
2 changes: 2 additions & 0 deletions packages/common/core/src/features/store/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions packages/react/markput/src/components/TextSpan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export const TextSpan = () => {
const store = useStore()
const ref = useRef<HTMLSpanElement>(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])
Expand All @@ -25,5 +24,5 @@ export const TextSpan = () => {
}
}, [token.content])

return <SpanComponent {...spanProps} ref={ref} contentEditable={!readOnly} suppressContentEditableWarning />
return <SpanComponent {...spanProps} ref={ref} />
}
2 changes: 1 addition & 1 deletion packages/react/markput/src/lib/hooks/useCoreFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ export function useCoreFeatures(store: Store, ref: React.Ref<MarkputHandler> | 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])
Expand Down
2 changes: 1 addition & 1 deletion packages/react/storybook/src/pages/Base/Base.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe(`Component: MarkedInput`, () => {
const {container} = await render(<Default defaultValue="" />)
const [span] = container.querySelectorAll('span')

await expect.element(span).toHaveTextContent('')
await expect.element(span).toBeInTheDocument()

await userEvent.type(span, '@[[mark](1)')

Expand Down
41 changes: 21 additions & 20 deletions packages/react/storybook/src/pages/Slots/slots.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MarkedInput Mark={TestMark} value="Hello world" />)
await render(<MarkedInput Mark={TestMark} value="Hello world" />)

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 () => {
Expand All @@ -120,7 +120,7 @@ describe('Slots API', () => {
})

it('should pass slotProps.span to the span component', async () => {
const {container} = await render(
await render(
<MarkedInput
Mark={TestMark}
value="Hello world"
Expand All @@ -133,13 +133,13 @@ describe('Slots API', () => {
/>
)

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(
<MarkedInput
Mark={TestMark}
value="Hello world"
Expand All @@ -151,7 +151,7 @@ describe('Slots API', () => {
/>
)

const textSpan = container.querySelector('span[contenteditable]') as HTMLElement
const textSpan = page.getByText('Hello world')
await expect.element(textSpan).toHaveStyle({fontWeight: 'bold', fontSize: '16px'})
})
})
Expand Down Expand Up @@ -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(<MarkedInput Mark={TestMark} value="Hello world" />)
await render(<MarkedInput Mark={TestMark} value="Hello world" />)

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(<MarkedInput Mark={TestMark} value="Hello world" readOnly={true} />)
await render(<MarkedInput Mark={TestMark} value="Hello world" readOnly={true} />)

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 () => {
Expand All @@ -265,15 +267,14 @@ describe('Slots API', () => {
await render(<MarkedInput Mark={TestMark} value="Hello world" slots={{span: CustomSpan}} />)

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(<MarkedInput Mark={TestMark} value="Hello world" />)
it('should set contentEditable imperatively via core controller', async () => {
await render(<MarkedInput Mark={TestMark} value="Hello world" />)

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')
})
})

Expand Down Expand Up @@ -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')
})
})

Expand Down
4 changes: 1 addition & 3 deletions packages/vue/markput/src/components/TextSpan.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script setup lang="ts">
import type {CoreSlotProps, CoreSlots} from '@markput/core'
import {inject, ref, computed, watch, onMounted} from 'vue'

import {useStore} from '../lib/hooks/useStore'
Expand All @@ -11,7 +10,6 @@ const tokenRef = inject(TOKEN_KEY)!
const token = tokenRef.value
const elRef = ref<HTMLSpanElement | null>(null)

const readOnly = store.state.readOnly.use()
const slots = store.state.slots.use()
const slotProps = store.state.slotProps.use()
const spanTag = computed(() => resolveSlot('span', slots.value))
Expand All @@ -38,5 +36,5 @@ watch(
</script>

<template>
<component :is="spanTag" :ref="(el: any) => (elRef = el)" v-bind="spanProps" :contenteditable="!readOnly" />
<component :is="spanTag" :ref="(el: any) => (elRef = el)" v-bind="spanProps" />
</template>