feat: add a title input box in the editor (#5465)

#### What type of PR is this?

/area editor
/area ui
/kind feature

#### What this PR does / why we need it:

为默认编辑器添加标题输入框。

<img width="1665" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/df903e02-76b0-45fe-89d9-6ac81af8f041">


#### Which issue(s) this PR fixes:

Fixes #5427 

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
为默认编辑器添加标题输入框。
```
pull/5523/head
Ryan Wang 2024-03-18 12:38:07 +08:00 committed by GitHub
parent 5e073bfe3c
commit 581a738423
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 201 additions and 76 deletions

View File

@ -32,15 +32,6 @@ export default function useSlugify(
auto: Ref<boolean>, auto: Ref<boolean>,
formType: FormType formType: FormType
) { ) {
watch(
() => source.value,
() => {
if (auto.value) {
handleGenerateSlug(false, formType);
}
}
);
const handleGenerateSlug = (forceUpdate = false, formType: FormType) => { const handleGenerateSlug = (forceUpdate = false, formType: FormType) => {
const globalInfoStore = useGlobalInfoStore(); const globalInfoStore = useGlobalInfoStore();
const mode = globalInfoStore.globalInfo?.postSlugGenerationStrategy; const mode = globalInfoStore.globalInfo?.postSlugGenerationStrategy;
@ -60,6 +51,18 @@ export default function useSlugify(
target.value = Strategy[mode](source.value); target.value = Strategy[mode](source.value);
}; };
watch(
() => source.value,
() => {
if (auto.value) {
handleGenerateSlug(false, formType);
}
},
{
immediate: true,
}
);
return { return {
handleGenerateSlug, handleGenerateSlug,
}; };

View File

@ -21,10 +21,10 @@ import {
ref, ref,
toRef, toRef,
type ComputedRef, type ComputedRef,
watch,
} from "vue"; } from "vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import { cloneDeep } from "lodash-es";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { randomUUID } from "@/utils/id"; import { randomUUID } from "@/utils/id";
import { useContentCache } from "@/composables/use-content-cache"; import { useContentCache } from "@/composables/use-content-cache";
@ -69,7 +69,7 @@ const handleChangeEditorProvider = async (provider: EditorProvider) => {
}; };
// SinglePage form // SinglePage form
const initialFormState: SinglePageRequest = { const formState = ref<SinglePageRequest>({
page: { page: {
spec: { spec: {
title: "", title: "",
@ -101,13 +101,19 @@ const initialFormState: SinglePageRequest = {
content: "", content: "",
rawType: "HTML", rawType: "HTML",
}, },
}; });
const formState = ref<SinglePageRequest>(cloneDeep(initialFormState));
const saving = ref(false); const saving = ref(false);
const publishing = ref(false); const publishing = ref(false);
const settingModal = ref(false); const settingModal = ref(false);
const isTitleChanged = ref(false);
watch(
() => formState.value.page.spec.title,
(newValue, oldValue) => {
isTitleChanged.value = newValue !== oldValue;
}
);
const isUpdateMode = computed(() => { const isUpdateMode = computed(() => {
return !!formState.value.page.metadata.creationTimestamp; return !!formState.value.page.metadata.creationTimestamp;
}); });
@ -143,15 +149,23 @@ const handleSave = async (options?: { mute?: boolean }) => {
} }
if (isUpdateMode.value) { if (isUpdateMode.value) {
if (isTitleChanged.value) {
formState.value.page = (
await singlePageUpdateMutate(formState.value.page)
).data;
}
const { data } = await apiClient.singlePage.updateSinglePageContent({ const { data } = await apiClient.singlePage.updateSinglePageContent({
name: formState.value.page.metadata.name, name: formState.value.page.metadata.name,
content: formState.value.content, content: formState.value.content,
}); });
formState.value.page = data; formState.value.page = data;
isTitleChanged.value = false;
} else { } else {
// Clear new page content cache // Clear new page content cache
handleClearCache(); handleClearCache();
const { data } = await apiClient.singlePage.draftSinglePage({ const { data } = await apiClient.singlePage.draftSinglePage({
singlePageRequest: formState.value, singlePageRequest: formState.value,
}); });
@ -184,6 +198,12 @@ const handlePublish = async () => {
const { name: singlePageName } = formState.value.page.metadata; const { name: singlePageName } = formState.value.page.metadata;
const { permalink } = formState.value.page.status || {}; const { permalink } = formState.value.page.status || {};
if (isTitleChanged.value) {
formState.value.page = (
await singlePageUpdateMutate(formState.value.page)
).data;
}
await apiClient.singlePage.updateSinglePageContent({ await apiClient.singlePage.updateSinglePageContent({
name: singlePageName, name: singlePageName,
content: formState.value.content, content: formState.value.content,
@ -224,6 +244,7 @@ const handlePublishClick = () => {
if (isUpdateMode.value) { if (isUpdateMode.value) {
handlePublish(); handlePublish();
} else { } else {
// Set editor title to page
settingModal.value = true; settingModal.value = true;
} }
}; };
@ -477,6 +498,7 @@ async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
v-if="currentEditorProvider" v-if="currentEditorProvider"
v-model:raw="formState.content.raw" v-model:raw="formState.content.raw"
v-model:content="formState.content.content" v-model:content="formState.content.content"
v-model:title="formState.page.spec.title"
:upload-image="handleUploadImage" :upload-image="handleUploadImage"
class="h-full" class="h-full"
@update="handleSetContentCache" @update="handleSetContentCache"

View File

@ -255,6 +255,9 @@ watch(
formState.value = toRaw(value); formState.value = toRaw(value);
publishTime.value = toDatetimeLocal(formState.value.spec.publishTime); publishTime.value = toDatetimeLocal(formState.value.spec.publishTime);
} }
},
{
immediate: true,
} }
); );

View File

@ -21,8 +21,8 @@ import {
ref, ref,
toRef, toRef,
type ComputedRef, type ComputedRef,
watch,
} from "vue"; } from "vue";
import { cloneDeep } from "lodash-es";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
@ -80,7 +80,7 @@ interface PostRequestWithContent extends PostRequest {
} }
// Post form // Post form
const initialFormState: PostRequestWithContent = { const formState = ref<PostRequestWithContent>({
post: { post: {
spec: { spec: {
title: "", title: "",
@ -114,13 +114,19 @@ const initialFormState: PostRequestWithContent = {
content: "", content: "",
rawType: "HTML", rawType: "HTML",
}, },
}; });
const formState = ref<PostRequestWithContent>(cloneDeep(initialFormState));
const settingModal = ref(false); const settingModal = ref(false);
const saving = ref(false); const saving = ref(false);
const publishing = ref(false); const publishing = ref(false);
const isTitleChanged = ref(false);
watch(
() => formState.value.post.spec.title,
(newValue, oldValue) => {
isTitleChanged.value = newValue !== oldValue;
}
);
const isUpdateMode = computed(() => { const isUpdateMode = computed(() => {
return !!formState.value.post.metadata.creationTimestamp; return !!formState.value.post.metadata.creationTimestamp;
}); });
@ -155,15 +161,25 @@ const handleSave = async (options?: { mute?: boolean }) => {
} }
if (isUpdateMode.value) { if (isUpdateMode.value) {
// Save post title
if (isTitleChanged.value) {
formState.value.post = (
await postUpdateMutate(formState.value.post)
).data;
}
const { data } = await apiClient.post.updatePostContent({ const { data } = await apiClient.post.updatePostContent({
name: formState.value.post.metadata.name, name: formState.value.post.metadata.name,
content: formState.value.content, content: formState.value.content,
}); });
formState.value.post = data; formState.value.post = data;
isTitleChanged.value = false;
} else { } else {
// Clear new post content cache // Clear new post content cache
handleClearCache(); handleClearCache();
const { data } = await apiClient.post.draftPost({ const { data } = await apiClient.post.draftPost({
postRequest: formState.value, postRequest: formState.value,
}); });
@ -195,6 +211,12 @@ const handlePublish = async () => {
const { name: postName } = formState.value.post.metadata; const { name: postName } = formState.value.post.metadata;
const { permalink } = formState.value.post.status || {}; const { permalink } = formState.value.post.status || {};
if (isTitleChanged.value) {
formState.value.post = (
await postUpdateMutate(formState.value.post)
).data;
}
await apiClient.post.updatePostContent({ await apiClient.post.updatePostContent({
name: postName, name: postName,
content: formState.value.content, content: formState.value.content,
@ -240,6 +262,7 @@ const handlePublishClick = () => {
if (isUpdateMode.value) { if (isUpdateMode.value) {
handlePublish(); handlePublish();
} else { } else {
// Set editor title to post
settingModal.value = true; settingModal.value = true;
} }
}; };
@ -503,6 +526,7 @@ async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
v-if="currentEditorProvider" v-if="currentEditorProvider"
v-model:raw="formState.content.raw" v-model:raw="formState.content.raw"
v-model:content="formState.content.content" v-model:content="formState.content.content"
v-model:title="formState.post.spec.title"
:upload-image="handleUploadImage" :upload-image="handleUploadImage"
class="h-full" class="h-full"
@update="handleSetContentCache" @update="handleSetContentCache"

View File

@ -216,6 +216,9 @@ watch(
formState.value = toRaw(value); formState.value = toRaw(value);
publishTime.value = toDatetimeLocal(formState.value.spec.publishTime); publishTime.value = toDatetimeLocal(formState.value.spec.publishTime);
} }
},
{
immediate: true,
} }
); );

View File

@ -37,14 +37,20 @@ watch(
<editor-bubble-menu :editor="editor" /> <editor-bubble-menu :editor="editor" />
<editor-header :editor="editor" /> <editor-header :editor="editor" />
<div class="h-full flex flex-row w-full overflow-hidden"> <div class="h-full flex flex-row w-full overflow-hidden">
<editor-content <div class="overflow-y-auto flex-1 bg-white">
:editor="editor" <div v-if="$slots.content" class="editor-header-extra">
:style="contentStyles" <slot name="content" />
class="editor-content markdown-body flex-1 relative bg-white overflow-y-auto" </div>
/>
<editor-content
:editor="editor"
:style="contentStyles"
class="editor-content markdown-body relative"
/>
</div>
<div <div
v-if="$slots.extra" v-if="$slots.extra"
class="h-full hidden sm:!block w-72 flex-shrink-0" class="h-full hidden sm:!block w-72 flex-shrink-0 flex-none"
> >
<slot name="extra"></slot> <slot name="extra"></slot>
</div> </div>

View File

@ -30,15 +30,24 @@ export class SearchAndReplacePluginView {
public containerElement: HTMLElement; public containerElement: HTMLElement;
public init: boolean;
constructor({ view, editor, element }: SearchAndReplacePluginViewProps) { constructor({ view, editor, element }: SearchAndReplacePluginViewProps) {
this.editor = editor; this.editor = editor;
this.view = view; this.view = view;
this.containerElement = element; this.containerElement = element;
const { element: editorElement } = this.editor.options; this.init = false;
editorElement.insertAdjacentElement("afterbegin", this.containerElement);
} }
update() { update() {
const { parentElement: editorParentElement } = this.editor.options.element;
if (!this.init && editorParentElement) {
editorParentElement.insertAdjacentElement(
"afterbegin",
this.containerElement
);
this.init = true;
}
return false; return false;
} }

View File

@ -10,6 +10,11 @@
height: 48px; height: 48px;
} }
.editor-header-extra {
width: 100%;
padding: $editorVerticalPadding 20px;
}
.editor-content { .editor-content {
width: 100%; width: 100%;
position: relative; position: relative;
@ -78,7 +83,8 @@
} }
@media screen { @media screen {
.ProseMirror { .ProseMirror,
.editor-header-extra {
@media (min-width: 640px) { @media (min-width: 640px) {
padding: $editorVerticalPadding min($editorHorizontalPadding, 10%) !important; padding: $editorVerticalPadding min($editorHorizontalPadding, 10%) !important;
} }

View File

@ -98,12 +98,14 @@ import { onBeforeUnmount } from "vue";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import type { AxiosRequestConfig } from "axios"; import type { AxiosRequestConfig } from "axios";
import { getContents } from "./utils/attachment"; import { getContents } from "./utils/attachment";
import { nextTick } from "vue";
const { t } = useI18n(); const { t } = useI18n();
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
title?: string;
raw?: string; raw?: string;
content: string; content: string;
uploadImage?: ( uploadImage?: (
@ -112,6 +114,7 @@ const props = withDefaults(
) => Promise<Attachment>; ) => Promise<Attachment>;
}>(), }>(),
{ {
title: "",
raw: "", raw: "",
content: "", content: "",
uploadImage: undefined, uploadImage: undefined,
@ -119,6 +122,7 @@ const props = withDefaults(
); );
const emit = defineEmits<{ const emit = defineEmits<{
(event: "update:title", value: string): void;
(event: "update:raw", value: string): void; (event: "update:raw", value: string): void;
(event: "update:content", value: string): void; (event: "update:content", value: string): void;
(event: "update", value: string): void; (event: "update", value: string): void;
@ -404,7 +408,6 @@ onMounted(() => {
UiExtensionUpload, UiExtensionUpload,
ExtensionSearchAndReplace, ExtensionSearchAndReplace,
], ],
autofocus: "start",
parseOptions: { parseOptions: {
preserveWhitespace: true, preserveWhitespace: true,
}, },
@ -441,6 +444,26 @@ const currentLocale = i18n.global.locale.value as
| "en" | "en"
| "zh" | "zh"
| "en-US"; | "en-US";
function onTitleInput(event: Event) {
emit("update:title", (event.target as HTMLInputElement).value);
}
// Set focus
const editorTitleRef = ref();
onMounted(() => {
// if name is empty, it means the editor is in the creation mode
const urlParams = new URLSearchParams(window.location.search);
const name = urlParams.get("name");
if (!name) {
nextTick(() => {
editorTitleRef.value.focus();
});
} else {
editor.value?.commands.focus();
}
});
</script> </script>
<template> <template>
@ -452,6 +475,17 @@ const currentLocale = i18n.global.locale.value as
@close="handleCloseAttachmentSelectorModal" @close="handleCloseAttachmentSelectorModal"
/> />
<RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale"> <RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale">
<template #content>
<input
ref="editorTitleRef"
:value="title"
type="text"
:placeholder="$t('core.components.default_editor.title_placeholder')"
class="w-full border-x-0 !border-b border-t-0 !border-solid !border-gray-100 p-0 !py-2 text-4xl font-semibold placeholder:text-gray-300"
@input="onTitleInput"
@keydown.enter="() => editor?.commands.focus('start')"
/>
</template>
<template v-if="showSidebar" #extra> <template v-if="showSidebar" #extra>
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
element="div" element="div"

View File

@ -1414,6 +1414,7 @@ core:
toolbox: toolbox:
attachment: Attachment attachment: Attachment
show_hide_sidebar: Show/Hide Sidebar show_hide_sidebar: Show/Hide Sidebar
title_placeholder: Please enter the title
global_search: global_search:
placeholder: Enter keywords to search placeholder: Enter keywords to search
no_results: No search results no_results: No search results

View File

@ -436,7 +436,7 @@ core:
description: 该操作会将自定义页面恢复到被删除之前的状态。 description: 该操作会将自定义页面恢复到被删除之前的状态。
page_editor: page_editor:
title: 页面编辑 title: 页面编辑
untitled: Untitled page untitled: 未命名页面
comment: comment:
title: 评论 title: 评论
empty: empty:
@ -1362,6 +1362,7 @@ core:
toolbox: toolbox:
attachment: 选择附件 attachment: 选择附件
show_hide_sidebar: 显示 / 隐藏侧边栏 show_hide_sidebar: 显示 / 隐藏侧边栏
title_placeholder: 请输入标题
global_search: global_search:
placeholder: 输入关键词以搜索 placeholder: 输入关键词以搜索
no_results: 没有搜索结果 no_results: 没有搜索结果

View File

@ -1328,6 +1328,7 @@ core:
toolbox: toolbox:
attachment: 選擇附件 attachment: 選擇附件
show_hide_sidebar: 顯示 / 隱藏側邊欄 show_hide_sidebar: 顯示 / 隱藏側邊欄
title_placeholder: 請輸入標題
global_search: global_search:
placeholder: 輸入關鍵字以搜尋 placeholder: 輸入關鍵字以搜尋
no_results: 沒有搜尋結果 no_results: 沒有搜尋結果

View File

@ -13,7 +13,7 @@ import {
VSpace, VSpace,
} from "@halo-dev/components"; } from "@halo-dev/components";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue"; import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import { ref, toRef } from "vue"; import { ref, toRef, watch } from "vue";
import { useLocalStorage } from "@vueuse/core"; import { useLocalStorage } from "@vueuse/core";
import type { Post, Content, Snapshot } from "@halo-dev/api-client"; import type { Post, Content, Snapshot } from "@halo-dev/api-client";
import { randomUUID } from "@/utils/id"; import { randomUUID } from "@/utils/id";
@ -82,6 +82,14 @@ const content = ref<Content>({
}); });
const snapshot = ref<Snapshot>(); const snapshot = ref<Snapshot>();
const isTitleChanged = ref(false);
watch(
() => formState.value.spec.title,
(newValue, oldValue) => {
isTitleChanged.value = newValue !== oldValue;
}
);
// provide some data to editor // provide some data to editor
provide<ComputedRef<string | undefined>>( provide<ComputedRef<string | undefined>>(
"owner", "owner",
@ -166,21 +174,7 @@ useAutoSaveContent(currentCache, toRef(content.value, "raw"), async () => {
if (isUpdateMode.value) { if (isUpdateMode.value) {
handleSave({ mute: true }); handleSave({ mute: true });
} else { } else {
formState.value.metadata.annotations = { handleCreate();
...formState.value.metadata.annotations,
[contentAnnotations.CONTENT_JSON]: JSON.stringify(content.value),
};
// Set default title and slug
if (!formState.value.spec.title) {
formState.value.spec.title = t("core.post_editor.untitled");
}
if (!formState.value.spec.slug) {
formState.value.spec.slug = new Date().getTime().toString();
}
const { data: createdPost } = await apiClient.uc.post.createMyPost({
post: formState.value,
});
onCreatePostSuccess(createdPost);
} }
}); });
@ -271,16 +265,34 @@ async function handleSetEditorProviderFromRemote() {
} }
// Create post // Create post
const postCreationModal = ref(false);
function handleSaveClick() { function handleSaveClick() {
if (isUpdateMode.value) { if (isUpdateMode.value) {
handleSave({ mute: false }); handleSave({ mute: false });
} else { } else {
postCreationModal.value = true; handleCreate();
} }
} }
async function handleCreate() {
formState.value.metadata.annotations = {
...formState.value.metadata.annotations,
[contentAnnotations.CONTENT_JSON]: JSON.stringify(content.value),
};
// Set default title and slug
if (!formState.value.spec.title) {
formState.value.spec.title = t("core.post_editor.untitled");
}
if (!formState.value.spec.slug) {
formState.value.spec.slug = new Date().getTime().toString();
}
const { data: createdPost } = await apiClient.uc.post.createMyPost({
post: formState.value,
});
await onCreatePostSuccess(createdPost);
}
async function onCreatePostSuccess(data: Post) { async function onCreatePostSuccess(data: Post) {
formState.value = data; formState.value = data;
// Update route query params // Update route query params
@ -300,6 +312,17 @@ const { mutateAsync: handleSave, isLoading: isSaving } = useMutation({
mute: false, mute: false,
}, },
mutationFn: async () => { mutationFn: async () => {
// Update title
// TODO: needs retry
if (isTitleChanged.value) {
const { data: updatedPost } = await apiClient.uc.post.updateMyPost({
name: formState.value.metadata.name,
post: formState.value,
});
formState.value = updatedPost;
isTitleChanged.value = false;
}
// Snapshot always exists in update mode // Snapshot always exists in update mode
if (!snapshot.value) { if (!snapshot.value) {
return; return;
@ -345,6 +368,7 @@ function handlePublishClick() {
if (isUpdateMode.value) { if (isUpdateMode.value) {
handlePublish(); handlePublish();
} else { } else {
// Set editor title to post
postPublishModal.value = true; postPublishModal.value = true;
} }
} }
@ -396,24 +420,7 @@ async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
return; return;
} }
if (!isUpdateMode.value) { if (!isUpdateMode.value) {
formState.value.metadata.annotations = { await handleCreate();
...formState.value.metadata.annotations,
[contentAnnotations.CONTENT_JSON]: JSON.stringify(content.value),
};
if (!formState.value.spec.title) {
formState.value.spec.title = t("core.post_editor.untitled");
}
if (!formState.value.spec.slug) {
formState.value.spec.slug = new Date().getTime().toString();
}
const { data } = await apiClient.uc.post.createMyPost({
post: formState.value,
});
await onCreatePostSuccess(data);
} }
const { data } = await apiClient.uc.attachment.createAttachmentForPost( const { data } = await apiClient.uc.attachment.createAttachmentForPost(
@ -487,23 +494,17 @@ useSessionKeepAlive();
v-if="currentEditorProvider" v-if="currentEditorProvider"
v-model:raw="content.raw" v-model:raw="content.raw"
v-model:content="content.content" v-model:content="content.content"
v-model:title="formState.spec.title"
:upload-image="handleUploadImage" :upload-image="handleUploadImage"
class="h-full" class="h-full"
@update="handleSetContentCache" @update="handleSetContentCache"
/> />
</div> </div>
<PostCreationModal
v-if="postCreationModal"
:title="$t('core.uc_post.creation_modal.title')"
:content="content"
@close="postCreationModal = false"
@success="onCreatePostSuccess"
/>
<PostCreationModal <PostCreationModal
v-if="postPublishModal" v-if="postPublishModal"
:title="$t('core.uc_post.publish_modal.title')" :title="$t('core.uc_post.publish_modal.title')"
:post="formState"
:content="content" :content="content"
publish publish
@close="postPublishModal = false" @close="postPublishModal = false"

View File

@ -17,6 +17,7 @@ const props = withDefaults(
title: string; title: string;
content: Content; content: Content;
publish?: boolean; publish?: boolean;
post: Post;
}>(), }>(),
{ {
publish: false, publish: false,
@ -107,7 +108,17 @@ function onSubmit(data: PostFormState) {
centered centered
@close="emit('close')" @close="emit('close')"
> >
<PostSettingForm @submit="onSubmit" /> <PostSettingForm
:form-state="{
title: props.post.spec.title,
slug: props.post.spec.slug,
allowComment: props.post.spec.allowComment,
visible: props.post.spec.visible,
pinned: props.post.spec.pinned,
excerptAutoGenerate: props.post.spec.excerpt.autoGenerate,
}"
@submit="onSubmit"
/>
<template #footer> <template #footer>
<VSpace> <VSpace>