summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkPostForm.vue
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components/MkPostForm.vue')
-rw-r--r--packages/frontend/src/components/MkPostForm.vue195
1 files changed, 128 insertions, 67 deletions
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index b3bcfcc137..d709286041 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<header :class="$style.header">
<div :class="$style.headerLeft">
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
- <button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu">
+ <button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" class="_button" @click="openAccountMenu">
<img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
</button>
</div>
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.headerRightButtonText">{{ targetChannel.name }}</span>
</button>
</template>
- <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null || visibility === 'specified'" @click="toggleLocalOnly">
+ <button v-if="visibility !== 'specified'" v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null" @click="toggleLocalOnly">
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
<span v-else><i class="ti ti-rocket-off"></i></span>
</button>
@@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.visibleUsers">
<span v-for="u in visibleUsers" :key="u.id" :class="$style.visibleUser">
<MkAcct :user="u"/>
- <button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button>
+ <button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u.id)"><i class="ti ti-x"></i></button>
</span>
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
</div>
@@ -77,7 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
<div v-if="targetChannel" :class="$style.colorBar" :style="{ background: targetChannel.color }"></div>
- <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
+ <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"></textarea>
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
@@ -108,13 +108,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</footer>
<datalist id="hashtags">
- <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/>
+ <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"></option>
</datalist>
</div>
</template>
<script lang="ts" setup>
-import { watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted } from 'vue';
+import { watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted, onBeforeUnmount } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
@@ -227,6 +227,10 @@ const targetChannel = shallowRef(props.channel);
const serverDraftId = ref<string | null>(null);
const postFormActions = getPluginHandlers('post_form_action');
+let textAutocomplete: Autocomplete | null = null;
+let cwAutocomplete: Autocomplete | null = null;
+let hashtagAutocomplete: Autocomplete | null = null;
+
const uploader = useUploader({
multiple: true,
});
@@ -329,8 +333,8 @@ const canSaveAsServerDraft = computed((): boolean => {
return canPost.value && (textLength.value > 0 || files.value.length > 0 || poll.value != null);
});
-const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags'));
-const hashtags = computed(store.makeGetterSetter('postFormHashtags'));
+const withHashtags = store.model('postFormWithHashtags');
+const hashtags = store.model('postFormHashtags');
watch(text, () => {
checkMissingMention();
@@ -476,6 +480,7 @@ function togglePoll() {
}
function addTag(tag: string) {
+ if (textareaEl.value == null) return;
insertTextAtCursor(textareaEl.value, ` #${tag} `);
}
@@ -486,7 +491,7 @@ function focus() {
}
}
-function chooseFileFromPc(ev: MouseEvent) {
+function chooseFileFromPc(ev: PointerEvent) {
if (props.mock) return;
os.chooseFileFromPc({ multiple: true }).then(files => {
@@ -495,7 +500,7 @@ function chooseFileFromPc(ev: MouseEvent) {
});
}
-function chooseFileFromDrive(ev: MouseEvent) {
+function chooseFileFromDrive(ev: PointerEvent) {
if (props.mock) return;
chooseDriveFile({ multiple: true }).then(driveFiles => {
@@ -503,18 +508,18 @@ function chooseFileFromDrive(ev: MouseEvent) {
});
}
-function detachFile(id) {
+function detachFile(id: Misskey.entities.DriveFile['id']) {
files.value = files.value.filter(x => x.id !== id);
}
-function updateFileSensitive(file, sensitive) {
+function updateFileSensitive(file: Misskey.entities.DriveFile, isSensitive: boolean) {
if (props.mock) {
- emit('fileChangeSensitive', file.id, sensitive);
+ emit('fileChangeSensitive', file.id, isSensitive);
}
- files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = sensitive;
+ files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = isSensitive;
}
-function updateFileName(file, name) {
+function updateFileName(file: Misskey.entities.DriveFile, name: Misskey.entities.DriveFile['name']) {
files.value[files.value.findIndex(x => x.id === file.id)].name = name;
}
@@ -528,7 +533,6 @@ function setVisibility() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
currentVisibility: visibility.value,
isSilenced: $i.isSilenced,
- localOnly: localOnly.value,
anchorElement: visibilityButton.value,
...(replyTargetNote.value ? { isReplyVisibilitySpecified: replyTargetNote.value.visibility === 'specified' } : {}),
}, {
@@ -704,8 +708,8 @@ function addVisibleUser() {
});
}
-function removeVisibleUser(user) {
- visibleUsers.value = erase(user, visibleUsers.value);
+function removeVisibleUser(id: string) {
+ visibleUsers.value = visibleUsers.value.filter(u => u.id !== id);
}
function clear() {
@@ -742,7 +746,8 @@ const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]';
async function onPaste(ev: ClipboardEvent) {
if (props.mock) return;
- if (!ev.clipboardData) return;
+ if (ev.clipboardData == null) return;
+ if (textareaEl.value == null) return;
let pastedFiles: File[] = [];
for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) {
@@ -767,39 +772,42 @@ async function onPaste(ev: ClipboardEvent) {
if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) {
ev.preventDefault();
- os.confirm({
+ const { canceled } = await os.confirm({
type: 'info',
text: i18n.ts.quoteQuestion,
- }).then(({ canceled }) => {
- if (canceled) {
- insertTextAtCursor(textareaEl.value, paste);
- return;
- }
-
- quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null;
});
+
+ if (canceled) {
+ insertTextAtCursor(textareaEl.value, paste);
+ return;
+ }
+
+ quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null;
}
if (paste.length > 1000) {
ev.preventDefault();
- os.confirm({
+
+ const { canceled } = await os.confirm({
type: 'info',
text: i18n.ts.attachAsFileQuestion,
- }).then(({ canceled }) => {
- if (canceled) {
- insertTextAtCursor(textareaEl.value, paste);
- return;
- }
-
- const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0');
- const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' });
- uploader.addFiles([file]);
});
+
+ if (canceled) {
+ insertTextAtCursor(textareaEl.value, paste);
+ return;
+ }
+
+ const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0');
+ const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' });
+ uploader.addFiles([file]);
}
}
-function onDragover(ev) {
- if (!ev.dataTransfer.items[0]) return;
+function onDragover(ev: DragEvent) {
+ if (ev.dataTransfer == null) return;
+ if (ev.dataTransfer.items[0] == null) return;
+
const isFile = ev.dataTransfer.items[0].kind === 'file';
if (isFile || checkDragDataType(ev, ['driveFiles'])) {
ev.preventDefault();
@@ -852,13 +860,32 @@ function onDrop(ev: DragEvent): void {
//#endregion
}
+type StoredDrafts = {
+ [key: string]: {
+ updatedAt: string;
+ data: {
+ text: string;
+ useCw: boolean;
+ cw: string | null;
+ visibility: 'public' | 'home' | 'followers' | 'specified';
+ localOnly: boolean;
+ files: Misskey.entities.DriveFile[];
+ poll: PollEditorModelValue | null;
+ visibleUserIds?: string[];
+ quoteId: string | null;
+ reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
+ scheduledAt: number | null;
+ };
+ };
+};
+
function saveDraft() {
if (props.instant || props.mock) return;
- const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
+ const draftsData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as StoredDrafts;
- draftData[draftKey.value] = {
- updatedAt: new Date(),
+ draftsData[draftKey.value] = {
+ updatedAt: new Date().toISOString(),
data: {
text: text.value,
useCw: useCw.value,
@@ -874,15 +901,15 @@ function saveDraft() {
},
};
- miLocalStorage.setItem('drafts', JSON.stringify(draftData));
+ miLocalStorage.setItem('drafts', JSON.stringify(draftsData));
}
function deleteDraft() {
- const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
+ const draftsData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as StoredDrafts;
- delete draftData[draftKey.value];
+ delete draftsData[draftKey.value];
- miLocalStorage.setItem('drafts', JSON.stringify(draftData));
+ miLocalStorage.setItem('drafts', JSON.stringify(draftsData));
}
async function saveServerDraft(options: {
@@ -924,8 +951,8 @@ async function uploadFiles() {
}
}
-async function post(ev?: MouseEvent) {
- if (ev) {
+async function post(ev?: PointerEvent) {
+ if (ev != null) {
const el = (ev.currentTarget ?? ev.target) as HTMLElement | null;
if (el && prefer.s.animation) {
@@ -999,7 +1026,7 @@ async function post(ev?: MouseEvent) {
channelId: targetChannel.value ? targetChannel.value.id : undefined,
poll: poll.value,
cw: useCw.value ? cw.value ?? '' : null,
- localOnly: localOnly.value,
+ localOnly: visibility.value === 'specified' ? false : localOnly.value,
visibility: visibility.value,
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined,
reactionAcceptance: reactionAcceptance.value,
@@ -1138,11 +1165,12 @@ function cancel() {
function insertMention() {
os.selectUser({ localOnly: localOnly.value, includeSelf: true }).then(user => {
+ if (textareaEl.value == null) return;
insertTextAtCursor(textareaEl.value, '@' + Misskey.acct.toString(user) + ' ');
});
}
-async function insertEmoji(ev: MouseEvent) {
+async function insertEmoji(ev: PointerEvent) {
textAreaReadOnly.value = true;
const target = ev.currentTarget ?? ev.target;
if (target == null) return;
@@ -1166,21 +1194,45 @@ async function insertEmoji(ev: MouseEvent) {
},
() => {
textAreaReadOnly.value = false;
- nextTick(() => focus());
+ nextTick(() => {
+ if (textareaEl.value) {
+ textareaEl.value.focus();
+ textareaEl.value.setSelectionRange(pos, posEnd);
+ }
+ });
},
);
}
-async function insertMfmFunction(ev: MouseEvent) {
+async function insertMfmFunction(ev: PointerEvent) {
if (textareaEl.value == null) return;
+ let pos = textareaEl.value.selectionStart ?? 0;
+ let posEnd = textareaEl.value.selectionEnd ?? text.value.length;
mfmFunctionPicker(
ev.currentTarget ?? ev.target,
- textareaEl.value,
- text,
+ (tag) => {
+ if (pos === posEnd) {
+ text.value = `${text.value.substring(0, pos)}$[${tag} ]${text.value.substring(pos)}`;
+ pos += tag.length + 3;
+ posEnd = pos;
+ } else {
+ text.value = `${text.value.substring(0, pos)}$[${tag} ${text.value.substring(pos, posEnd)}]${text.value.substring(posEnd)}`;
+ pos += tag.length + 3;
+ posEnd = pos;
+ }
+ },
+ () => {
+ nextTick(() => {
+ if (textareaEl.value) {
+ textareaEl.value.focus();
+ textareaEl.value.setSelectionRange(pos, posEnd);
+ }
+ });
+ },
);
}
-function showActions(ev: MouseEvent) {
+function showActions(ev: PointerEvent) {
os.popupMenu(postFormActions.map(action => ({
text: action.title,
action: () => {
@@ -1198,7 +1250,7 @@ function showActions(ev: MouseEvent) {
const postAccount = ref<Misskey.entities.UserDetailed | null>(null);
-async function openAccountMenu(ev: MouseEvent) {
+async function openAccountMenu(ev: PointerEvent) {
if (props.mock) return;
function showDraftsDialog(scheduled: boolean) {
@@ -1288,12 +1340,12 @@ async function openAccountMenu(ev: MouseEvent) {
}, { type: 'divider' }, ...items], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
-function showPerUploadItemMenu(item: UploaderItem, ev: MouseEvent) {
+function showPerUploadItemMenu(item: UploaderItem, ev: PointerEvent) {
const menu = uploader.getMenu(item);
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
-function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) {
+function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: PointerEvent) {
const menu = uploader.getMenu(item);
os.contextMenu(menu, ev);
}
@@ -1360,16 +1412,15 @@ onMounted(() => {
});
}
- // TODO: detach when unmount
- if (textareaEl.value) new Autocomplete(textareaEl.value, text);
- if (cwInputEl.value) new Autocomplete(cwInputEl.value, cw);
- if (hashtagsInputEl.value) new Autocomplete(hashtagsInputEl.value, hashtags);
+ if (textareaEl.value) textAutocomplete = new Autocomplete(textareaEl.value, text);
+ if (cwInputEl.value) cwAutocomplete = new Autocomplete(cwInputEl.value, cw);
+ if (hashtagsInputEl.value) hashtagAutocomplete = new Autocomplete(hashtagsInputEl.value, hashtags);
nextTick(() => {
// 書きかけの投稿を復元
if (!props.instant && !props.mention && !props.specified && !props.mock) {
- const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey.value];
- if (draft) {
+ const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey.value] as StoredDrafts[string] | undefined;
+ if (draft != null) {
text.value = draft.data.text;
useCw.value = draft.data.useCw;
cw.value = draft.data.cw;
@@ -1420,6 +1471,19 @@ onMounted(() => {
});
});
+onBeforeUnmount(() => {
+ uploader.abortAll();
+ if (textAutocomplete) {
+ textAutocomplete.detach();
+ }
+ if (cwAutocomplete) {
+ cwAutocomplete.detach();
+ }
+ if (hashtagAutocomplete) {
+ hashtagAutocomplete.detach();
+ }
+});
+
async function canClose() {
if (!uploader.allItemsUploaded.value) {
const { canceled } = await os.confirm({
@@ -1469,9 +1533,6 @@ defineExpose({
padding: 8px;
}
-.account {
-}
-
.avatar {
display: block;
width: 28px;