Improve mentions keyboard accessibility (#10173)

* Improve keyboard accessibility

* Add check for up down keys

* Add newline check for triggering

* Allow keyboard insertion of users

* Clear positional node errors on safari

* Add a little sanity check to please automated code checkers

* Use active instead of dashed

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
ian
2021-12-02 00:14:09 +08:00
committed by GitHub
parent 59c45c92e2
commit 32e0396b3e
4 changed files with 239 additions and 29 deletions

View File

@@ -10,6 +10,7 @@ import logger from '../logger';
import { userName } from '../utils/user-name';
import { uniq } from 'lodash';
import env from '../env';
import validateUUID from 'uuid-validate';
export class ActivityService extends ItemsService {
notificationsService: NotificationsService;
@@ -66,10 +67,13 @@ export class ActivityService extends ItemsService {
let comment = data.comment;
for (const mention of mentions) {
comment = comment.replace(mention, userPreviews[mention.substring(1)] ?? '@Unknown User');
const uuid = mention.substring(1);
// We only match on UUIDs in the first place. This is just an extra sanity check
if (validateUUID(uuid) === false) continue;
comment = comment.replace(new RegExp(mention, 'gm'), userPreviews[uuid] ?? '@Unknown User');
}
comment = `> ${comment}`;
comment = `> ${comment.replace(/\n+/gm, '\n> ')}`;
const message = `
Hello ${userName(user)},

View File

@@ -10,7 +10,7 @@
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watch, onMounted } from 'vue';
import { defineComponent, PropType, ref, watch, onMounted, onUnmounted } from 'vue';
import { position } from 'caret-pos';
export default defineComponent({
@@ -36,11 +36,14 @@ export default defineComponent({
required: true,
},
},
emits: ['update:modelValue', 'trigger', 'deactivate'],
emits: ['update:modelValue', 'trigger', 'deactivate', 'up', 'down', 'enter'],
setup(props, { emit }) {
const input = ref<HTMLDivElement>();
let hasTriggered = false;
let matchedPositions: number[] = [];
let previousInnerTextLength = 0;
let previousCaretPos = 0;
watch(
() => props.modelValue,
@@ -48,7 +51,7 @@ export default defineComponent({
if (!input.value) return;
if (newText !== input.value.innerText) {
parseHTML(newText);
parseHTML(newText, true);
}
}
);
@@ -57,19 +60,140 @@ export default defineComponent({
if (props.modelValue && props.modelValue !== input.value!.innerText) {
parseHTML(props.modelValue);
}
if (input.value) {
input.value.addEventListener('click', checkClick);
input.value.addEventListener('keydown', checkKeyDown);
input.value.addEventListener('keyup', checkKeyUp);
}
});
onUnmounted(() => {
if (input.value) {
input.value.removeEventListener('click', checkClick);
input.value.removeEventListener('keydown', checkKeyDown);
input.value.removeEventListener('keyup', checkKeyUp);
}
});
return { processText, input };
function checkKeyDown(event: any) {
const caretPos = window.getSelection()?.rangeCount ? position(input.value as Element).pos : 0;
if (event.code === 'Enter') {
event.preventDefault();
if (hasTriggered) {
emit('enter');
} else {
parseHTML(
input.value!.innerText.substring(0, caretPos) +
(caretPos === input.value!.innerText.length && input.value!.innerText.charAt(caretPos - 1) !== '\n'
? '\n\n'
: '\n') +
input.value!.innerText.substring(caretPos),
true
);
position(input.value!, caretPos + 1);
}
} else if (event.code === 'ArrowUp' && !event.shiftKey) {
if (hasTriggered) {
event.preventDefault();
emit('up');
}
} else if (event.code === 'ArrowDown' && !event.shiftKey) {
if (hasTriggered) {
event.preventDefault();
emit('down');
}
} else if (event.code === 'ArrowLeft' && !event.shiftKey) {
const checkCaretPos = matchedPositions.indexOf(caretPos - 1);
if (checkCaretPos !== -1 && checkCaretPos % 2 === 1) {
event.preventDefault();
position(input.value!, matchedPositions[checkCaretPos - 1] - 1);
}
} else if (event.code === 'ArrowRight' && !event.shiftKey) {
const checkCaretPos = matchedPositions.indexOf(caretPos + 1);
if (checkCaretPos !== -1 && checkCaretPos % 2 === 0) {
event.preventDefault();
position(input.value!, matchedPositions[checkCaretPos + 1] + 1);
}
} else if (event.code === 'Backspace') {
const checkCaretPos = matchedPositions.indexOf(caretPos - 1);
if (checkCaretPos !== -1 && checkCaretPos % 2 === 1) {
event.preventDefault();
const newCaretPos = matchedPositions[checkCaretPos - 1];
parseHTML(
(input.value!.innerText.substring(0, newCaretPos) + input.value!.innerText.substring(caretPos)).replaceAll(
String.fromCharCode(160),
' '
),
true
);
position(input.value!, newCaretPos);
emit('update:modelValue', input.value!.innerText);
}
} else if (event.code === 'Delete') {
const checkCaretPos = matchedPositions.indexOf(caretPos + 1);
if (checkCaretPos !== -1 && checkCaretPos % 2 === 0) {
event.preventDefault();
parseHTML(
(
input.value!.innerText.substring(0, caretPos) +
input.value!.innerText.substring(matchedPositions[checkCaretPos + 1])
).replaceAll(String.fromCharCode(160), ' '),
true
);
position(input.value!, caretPos);
emit('update:modelValue', input.value!.innerText);
}
}
}
function checkKeyUp(event: any) {
const caretPos = window.getSelection()?.rangeCount ? position(input.value as Element).pos : 0;
if ((event.code === 'ArrowUp' || event.code === 'ArrowDown') && !event.shiftKey) {
const checkCaretPos = matchedPositions.indexOf(caretPos);
if (checkCaretPos !== -1 && checkCaretPos % 2 === 1) {
position(input.value!, matchedPositions[checkCaretPos] + 1);
} else if (checkCaretPos !== -1 && checkCaretPos % 2 === 0) {
position(input.value!, matchedPositions[checkCaretPos] - 1);
}
}
}
function checkClick(event: any) {
const caretPos = window.getSelection()?.rangeCount ? position(input.value as Element).pos : 0;
const checkCaretPos = matchedPositions.indexOf(caretPos);
if (checkCaretPos !== -1) {
if (checkCaretPos % 2 === 0) {
position(input.value!, caretPos - 1);
} else {
position(input.value!, caretPos + 1);
}
event.preventDefault();
}
}
function processText(event: KeyboardEvent) {
const input = event.target as HTMLDivElement;
const caretPos = position(input).pos;
const caretPos = window.getSelection()?.rangeCount ? position(input).pos : 0;
const text = input.innerText ?? '';
let endPos = text.indexOf(' ', caretPos);
if (endPos == -1) endPos = text.length;
if (endPos === -1) endPos = text.indexOf('\n', caretPos);
if (endPos === -1) endPos = text.length;
const result = /\S+$/.exec(text.slice(0, endPos));
let word = result ? result[0] : null;
if (word) word = word.replace(/[\s'";:,./?\\-]$/, '');
@@ -89,38 +213,87 @@ export default defineComponent({
emit('update:modelValue', input.innerText);
}
function parseHTML(innerText?: string) {
function parseHTML(innerText?: string, isDirectInput = false) {
if (!input.value) return;
let newHTML = innerText ?? input.value.innerHTML ?? '';
if (input.value.innerText === '\n') {
input.value.innerText = '';
}
const caretPos = window.getSelection()?.rangeCount ? position(input.value).pos : 0;
if (innerText !== undefined) {
input.value.innerText = innerText;
hasTriggered = false;
}
let newHTML = input.value.innerText;
const caretPos = isDirectInput
? previousCaretPos
: window.getSelection()?.rangeCount
? position(input.value).pos
: 0;
let lastMatchIndex = 0;
const matches = newHTML.match(new RegExp(`${props.captureGroup}(?!</mark>)`, 'gi'));
matchedPositions = [];
if (matches) {
for (const match of matches ?? []) {
newHTML = newHTML.replace(
new RegExp(`(${match})(?!</mark>)`),
`&nbsp;<mark class="preview" data-preview="${
props.items[match.substring(props.triggerCharacter.length)]
}" contenteditable="false">${match}</mark>&nbsp;`
);
let replaceSpaceBefore = '';
let replaceSpaceAfter = '';
let addSpaceBefore = '';
let addSpaceAfter = '';
let htmlMatchIndex = newHTML.indexOf(match, lastMatchIndex);
const charCodeBefore = newHTML.charCodeAt(htmlMatchIndex - 1);
const charCodeAfter = newHTML.charCodeAt(htmlMatchIndex + match.length);
if (charCodeBefore === 32) {
replaceSpaceBefore = ' ';
addSpaceBefore = '&nbsp;';
} else if (charCodeBefore !== 160) {
addSpaceBefore = '&nbsp;';
}
if (charCodeAfter === 32) {
replaceSpaceAfter = ' ';
addSpaceAfter = '&nbsp;';
} else if (charCodeAfter !== 160) {
addSpaceAfter = '&nbsp;';
}
let searchString = replaceSpaceBefore + match + replaceSpaceAfter;
let replacementString = `${addSpaceBefore}<mark class="preview" data-preview="${
props.items[match.substring(props.triggerCharacter.length)]
}" contenteditable="false">${match}</mark>${addSpaceAfter}`;
newHTML = newHTML.replace(new RegExp(`(${searchString})(?!</mark>)`), replacementString);
lastMatchIndex = htmlMatchIndex + replacementString.length - searchString.length;
}
}
if (input.value.innerHTML !== newHTML) {
if (input.value.innerHTML !== newHTML.replaceAll(String.fromCharCode(160), '&nbsp;')) {
input.value.innerHTML = newHTML;
const delta = newHTML.length - input.value.innerHTML.length;
const delta = input.value.innerText.length - previousInnerTextLength;
const newPosition = caretPos + delta;
if (newPosition >= newHTML.length || newPosition < 0) {
position(input.value, newHTML.length - 1);
if (newPosition > input.value.innerText.length || newPosition < 0) {
position(input.value, input.value.innerText.length);
} else {
position(input.value, caretPos + delta);
position(input.value, newPosition);
}
}
lastMatchIndex = 0;
for (const match of matches ?? []) {
let matchIndex = input.value.innerText.indexOf(match, lastMatchIndex);
matchedPositions.push(matchIndex, matchIndex + match.length);
lastMatchIndex = matchIndex + match.length;
}
previousInnerTextLength = input.value.innerText.length;
previousCaretPos = caretPos;
}
},
});
@@ -144,7 +317,7 @@ export default defineComponent({
&.multiline {
height: var(--input-height-tall);
overflow-y: auto;
white-space: pre-line;
white-space: pre-wrap;
}
&:hover {

View File

@@ -10,11 +10,21 @@
:items="userPreviews"
@trigger="triggerSearch"
@deactivate="showMentionDropDown = false"
@up="pressedUp"
@down="pressedDown"
@enter="pressedEnter"
/>
</template>
<v-list>
<v-list-item v-for="user in searchResult" id="suggestions" :key="user.id" clickable @click="insertUser(user)">
<v-list-item
v-for="(user, index) in searchResult"
id="suggestions"
:key="user.id"
clickable
:active="index === selectedKeyboardIndex"
@click="insertUser(user)"
>
<v-list-item-icon>
<v-avatar x-small>
<img v-if="user.avatar" :src="avatarSource(user.avatar)" />
@@ -123,6 +133,7 @@ export default defineComponent({
);
let triggerCaretPosition = 0;
let selectedKeyboardIndex = ref<number>(0);
let cancelToken: CancelTokenSource | null = null;
@@ -199,26 +210,30 @@ export default defineComponent({
triggerSearch,
insertUser,
userPreviews,
selectedKeyboardIndex,
pressedUp,
pressedDown,
pressedEnter,
};
function insertUser(user: Record<string, any>) {
const text = newCommentContent.value;
const text = newCommentContent.value?.replaceAll(String.fromCharCode(160), ' ');
if (!text) return;
let countBefore = triggerCaretPosition - 1;
let countAfter = triggerCaretPosition;
if (text.charAt(countBefore) !== ' ') {
while (countBefore >= 0 && text.charAt(countBefore) !== ' ') {
if (text.charAt(countBefore) !== ' ' && text.charAt(countBefore) !== '\n') {
while (countBefore >= 0 && text.charAt(countBefore) !== ' ' && text.charAt(countBefore) !== '\n') {
countBefore--;
}
}
while (countAfter < text.length && text.charAt(countAfter) !== ' ') {
while (countAfter < text.length && text.charAt(countAfter) !== ' ' && text.charAt(countAfter) !== '\n') {
countAfter++;
}
const before = text.substring(0, countBefore);
const before = text.substring(0, countBefore + (text.charAt(countBefore) === '\n' ? 1 : 0));
const after = text.substring(countAfter);
newCommentContent.value = before + ' @' + user.id + after;
@@ -229,6 +244,7 @@ export default defineComponent({
showMentionDropDown.value = true;
loadUsers(searchQuery);
selectedKeyboardIndex.value = 0;
}
function avatarSource(url: string) {
@@ -267,6 +283,23 @@ export default defineComponent({
saving.value = false;
}
}
function pressedUp() {
if (selectedKeyboardIndex.value > 0) {
selectedKeyboardIndex.value--;
}
}
function pressedDown() {
if (selectedKeyboardIndex.value < searchResult.value.length - 1) {
selectedKeyboardIndex.value++;
}
}
function pressedEnter() {
insertUser(searchResult.value[selectedKeyboardIndex.value]);
showMentionDropDown.value = false;
}
},
});
</script>

View File

@@ -109,7 +109,7 @@ export default defineComponent({
return {
...comment,
display: newCommentText,
display: newCommentText.replaceAll('\n', '<br>'),
};
});