diff --git a/packages/super-editor/src/components/SuperInput.test.ts b/packages/super-editor/src/components/SuperInput.test.ts new file mode 100644 index 0000000000..181753ae78 --- /dev/null +++ b/packages/super-editor/src/components/SuperInput.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent, h, nextTick } from 'vue'; + +const EditorConstructor = vi.hoisted(() => { + return vi.fn(function (options) { + this.options = options; + this.view = { focus: vi.fn() }; + this.state = { doc: { content: { size: 5 } } }; + this.commands = { setTextSelection: vi.fn() }; + this.destroy = vi.fn(); + }); +}); + +vi.mock('@superdoc/super-editor', () => ({ + Editor: EditorConstructor, +})); + +vi.mock('@extensions/index.js', () => ({ + getRichTextExtensions: () => [], + Placeholder: { options: { placeholder: '' } }, +})); + +import SuperInput from './SuperInput.vue'; + +const Wrapper = defineComponent({ + name: 'SuperInputWrapper', + setup() { + return () => + h('div', {}, [h(SuperInput, { modelValue: '
First
' }), h(SuperInput, { modelValue: 'Second
' })]); + }, +}); + +describe('SuperInput.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses the local content element for each instance', async () => { + mount(Wrapper); + await nextTick(); + + expect(EditorConstructor).toHaveBeenCalledTimes(2); + + const firstOptions = EditorConstructor.mock.calls[0][0]; + const secondOptions = EditorConstructor.mock.calls[1][0]; + + expect(firstOptions.content).toBeTruthy(); + expect(secondOptions.content).toBeTruthy(); + expect(typeof firstOptions.content).toBe('string'); + expect(typeof secondOptions.content).toBe('string'); + + expect(firstOptions.content).toContain('First'); + expect(secondOptions.content).toContain('Second'); + expect(secondOptions.content).not.toContain('First'); + }); + + it('moves cursor to the end on focus', async () => { + const wrapper = mount(SuperInput, { props: { modelValue: 'Hello
' } }); + await nextTick(); + + wrapper.vm.focus(); + const editorInstance = EditorConstructor.mock.results[0].value; + expect(editorInstance.commands.setTextSelection).toHaveBeenCalledWith({ from: 5, to: 5 }); + }); + + it('does not force cursor to end when wrapper is clicked', async () => { + const wrapper = mount(SuperInput, { props: { modelValue: 'Hello
' } }); + await nextTick(); + + await wrapper.trigger('click'); + + const editorInstance = EditorConstructor.mock.results[0].value; + expect(editorInstance.view.focus).toHaveBeenCalledTimes(1); + expect(editorInstance.commands.setTextSelection).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/components/SuperInput.vue b/packages/super-editor/src/components/SuperInput.vue index b9576a8966..4ddac41eac 100644 --- a/packages/super-editor/src/components/SuperInput.vue +++ b/packages/super-editor/src/components/SuperInput.vue @@ -30,6 +30,7 @@ const props = defineProps({ const editor = shallowRef(); const editorElem = ref(null); +const contentElem = ref(null); const isFocused = ref(false); const onTransaction = ({ editor, transaction }) => { @@ -54,9 +55,10 @@ const initEditor = async () => { props.options.onTransaction = onTransaction; props.options.onFocus = onFocus; props.options.onBlur = onBlur; + const initialHtml = props.modelValue ?? contentElem.value?.innerHTML ?? ''; editor.value = new Editor({ mode: 'text', - content: document.getElementById('currentContent'), + content: initialHtml, element: editorElem.value, extensions: getRichTextExtensions(), users: props.users, @@ -64,9 +66,21 @@ const initEditor = async () => { }); }; -const handleFocus = () => { +const focus = (options = {}) => { + const { moveCursorToEnd = true } = options; isFocused.value = true; - editor.value?.view?.focus(); + const instance = editor.value; + instance?.view?.focus(); + if (moveCursorToEnd) { + const docSize = instance?.state?.doc?.content?.size; + if (typeof docSize === 'number' && instance?.commands?.setTextSelection) { + instance.commands.setTextSelection({ from: docSize, to: docSize }); + } + } +}; + +const handleFocus = () => { + focus({ moveCursorToEnd: false }); }; const updateUsersState = () => { @@ -81,11 +95,13 @@ onBeforeUnmount(() => { editor.value?.destroy(); editor.value = null; }); + +defineExpose({ focus });Ref text
' }, + }; + + const { wrapper, superdocStub } = await mountDialog({ + baseCommentOverrides: baseCommentWithRef, + }); + + const header = wrapper.findComponent(CommentHeaderStub); + header.vm.$emit('overflow-select', 'edit'); + + expect(commentsStoreStub.currentCommentText.value).toBe('Ref text
'); + expect(typeof commentsStoreStub.currentCommentText.value).toBe('string'); + expect(commentsStoreStub.currentCommentText.value).not.toBe(baseCommentWithRef.commentText); + expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(superdocStub, 'comment-1'); + }); + + it('auto-focuses the edit input when entering edit mode', async () => { + const { wrapper } = await mountDialog(); + + const header = wrapper.findComponent(CommentHeaderStub); + header.vm.$emit('overflow-select', 'edit'); + await nextTick(); + + expect(commentInputFocusSpies.at(-1)).toHaveBeenCalled(); + }); + + it('auto-focuses the new comment input when active', async () => { + const { wrapper, baseComment } = await mountDialog(); + commentsStoreStub.activeComment.value = baseComment.commentId; + await nextTick(); + + expect(commentInputFocusSpies.at(-1)).toHaveBeenCalled(); + }); + it('emits dialog-exit when clicking outside active comment and no track changes highlighted', async () => { const { wrapper, baseComment } = await mountDialog(); commentsStoreStub.activeComment.value = baseComment.commentId; diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index 5444eb9c08..dc71241230 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -1,13 +1,8 @@ @@ -408,7 +438,13 @@ onMounted(() => { }}