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
77 changes: 77 additions & 0 deletions packages/super-editor/src/components/SuperInput.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>First</p>' }), h(SuperInput, { modelValue: '<p>Second</p>' })]);
},
});

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: '<p>Hello</p>' } });
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: '<p>Hello</p>' } });
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();
});
});
24 changes: 20 additions & 4 deletions packages/super-editor/src/components/SuperInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -54,19 +55,32 @@ 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,
...props.options,
});
};

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 = () => {
Expand All @@ -81,11 +95,13 @@ onBeforeUnmount(() => {
editor.value?.destroy();
editor.value = null;
});

defineExpose({ focus });
</script>

<template>
<div class="super-editor super-input" :class="{ 'super-input-active': isFocused }" @click.stop.prevent="handleFocus">
<div id="currentContent" style="display: none" v-html="modelValue"></div>
<div ref="contentElem" style="display: none" v-html="modelValue"></div>
<div ref="editorElem" class="editor-element super-editor__element"></div>
</div>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,15 @@ const InternalDropdownStub = defineComponent({
},
});

let commentInputFocusSpies;

const CommentInputStub = defineComponent({
name: 'CommentInputStub',
props: ['users', 'config', 'comment'],
setup() {
setup(_, { expose }) {
const focusSpy = vi.fn();
commentInputFocusSpies.push(focusSpy);
expose({ focus: focusSpy });
return () => h('div', { class: 'comment-input-stub' });
},
});
Expand Down Expand Up @@ -215,6 +220,7 @@ const mountDialog = async ({ baseCommentOverrides = {}, extraComments = [], prop
describe('CommentDialog.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
commentInputFocusSpies = [];
});

it('focuses the comment on mount and adds replies', async () => {
Expand Down Expand Up @@ -311,6 +317,42 @@ describe('CommentDialog.vue', () => {
expect(baseComment.setIsInternal).toHaveBeenCalledWith({ isInternal: false, superdoc: superdocStub });
});

it('prepopulates edit text from a ref-based commentText value', async () => {
const baseCommentWithRef = {
commentText: { value: '<p>Ref text</p>' },
};

const { wrapper, superdocStub } = await mountDialog({
baseCommentOverrides: baseCommentWithRef,
});

const header = wrapper.findComponent(CommentHeaderStub);
header.vm.$emit('overflow-select', 'edit');

expect(commentsStoreStub.currentCommentText.value).toBe('<p>Ref text</p>');
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;
Expand Down
70 changes: 53 additions & 17 deletions packages/superdoc/src/components/CommentsLayer/CommentDialog.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
<script setup>
import { computed, toRefs, ref, getCurrentInstance, onMounted, nextTick } from 'vue';
import { computed, ref, getCurrentInstance, onMounted, nextTick, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useCommentsStore } from '@superdoc/stores/comments-store';
import { useSuperdocStore } from '@superdoc/stores/superdoc-store';
import { SuperInput } from '@superdoc/super-editor';
import { superdocIcons } from '@superdoc/icons.js';
import useSelection from '@superdoc/helpers/use-selection';
import useComment from '@superdoc/components/CommentsLayer/use-comment';
import Avatar from '@superdoc/components/general/Avatar.vue';
import InternalDropdown from './InternalDropdown.vue';
import CommentHeader from './CommentHeader.vue';
import CommentInput from './CommentInput.vue';
Expand All @@ -29,9 +24,6 @@ const props = defineProps({
});

const { proxy } = getCurrentInstance();
const role = proxy.$superdoc.config.role;
const commentCreator = props.comment.email;

const superdocStore = useSuperdocStore();
const commentsStore = useCommentsStore();

Expand All @@ -50,11 +42,28 @@ const {
isCommentHighlighted,
} = storeToRefs(commentsStore);

const { activeZoom } = storeToRefs(superdocStore);

const isInternal = ref(true);
const isFocused = ref(false);
const commentInput = ref(null);
const editCommentInputs = ref(new Map());

const setEditCommentInputRef = (commentId) => (el) => {
if (!commentId) return;
if (el) {
editCommentInputs.value.set(commentId, el);
if (editingCommentId.value === commentId) {
nextTick(() => {
focusEditInput(commentId);
});
}
} else {
editCommentInputs.value.delete(commentId);
}
};

const focusEditInput = (commentId) => {
const input = editCommentInputs.value.get(commentId);
input?.focus?.();
};
const commentDialogElement = ref(null);

const isActiveComment = computed(() => activeComment.value === props.comment.commentId);
Expand Down Expand Up @@ -145,9 +154,7 @@ const isInternalDropdownDisabled = computed(() => {
return getConfig.value.readOnly;
});

const isEditingThisComment = computed(() => (comment) => {
return editingCommentId.value === comment.commentId;
});
const isEditingThisComment = computed(() => (comment) => editingCommentId.value === comment.commentId);

const shouldShowInternalExternal = computed(() => {
if (!proxy.$superdoc.config.isInternal) return false;
Expand Down Expand Up @@ -258,10 +265,13 @@ const handleResolve = () => {
const handleOverflowSelect = (value, comment) => {
switch (value) {
case 'edit':
currentCommentText.value = comment.commentText;
currentCommentText.value = comment?.commentText?.value ?? comment?.commentText ?? '';
activeComment.value = comment.commentId;
editingCommentId.value = comment.commentId;
commentsStore.setActiveComment(proxy.$superdoc, activeComment.value);
nextTick(() => {
focusEditInput(comment.commentId);
});
break;
case 'delete':
deleteComment({ superdoc: proxy.$superdoc, commentId: comment.commentId });
Expand Down Expand Up @@ -347,6 +357,26 @@ onMounted(() => {
emit('ready', { commentId, elementRef: commentDialogElement });
});
});

watch(
showInputSection,
(isVisible) => {
if (!isVisible) return;
nextTick(() => {
commentInput.value?.focus?.();
});
},
{ immediate: true },
);

watch(editingCommentId, (commentId) => {
if (!commentId) return;
const entry = comments.value.find((comment) => comment.commentId === commentId);
if (!entry || entry.trackedChange) return;
nextTick(() => {
focusEditInput(commentId);
});
});
</script>

<template>
Expand Down Expand Up @@ -408,7 +438,13 @@ onMounted(() => {
}}
</div>
<div v-else class="comment-editing">
<CommentInput :users="usersFiltered" :config="getConfig" :include-header="false" :comment="comment" />
<CommentInput
:ref="setEditCommentInputRef(comment.commentId)"
:users="usersFiltered"
:config="getConfig"
:include-header="false"
:comment="comment"
/>
<div class="comment-footer">
<button class="sd-button" @click.stop.prevent="handleCancel(comment)">Cancel</button>
<button class="sd-button primary" @click.stop.prevent="handleCommentUpdate(comment)">Update</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup>
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { SuperInput } from '@superdoc/super-editor';
import { useSuperdocStore } from '@stores/superdoc-store';
Expand Down Expand Up @@ -32,8 +33,15 @@ const props = defineProps({
const superdocStore = useSuperdocStore();
const commentsStore = useCommentsStore();
const { currentCommentText } = storeToRefs(commentsStore);
const inputRef = ref(null);

const handleFocusChange = (focused) => emit('focus', focused);

const focus = () => {
inputRef.value?.focus?.();
};

defineExpose({ focus });
</script>

<template>
Expand All @@ -42,6 +50,7 @@ const handleFocusChange = (focused) => emit('focus', focused);

<div class="comment-entry" :class="{ 'sd-input-active': isFocused }">
<SuperInput
ref="inputRef"
class="superdoc-field"
placeholder="Add a comment"
v-model="currentCommentText"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ watch(activeComment, (newVal, oldVal) => {
verticalOffset.value = selectionTop - renderedTop;

setTimeout(() => {
renderedItem.elementRef.value?.scrollIntoView({
renderedItem.elementRef?.value?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
Expand Down