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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,14 +37,20 @@ watch(
<editor-bubble-menu :editor="editor" />
<editor-header :editor="editor" />
<div class="h-full flex flex-row w-full overflow-hidden">
<editor-content
:editor="editor"
:style="contentStyles"
class="editor-content markdown-body flex-1 relative bg-white overflow-y-auto"
/>
<div class="overflow-y-auto flex-1 bg-white">
<div v-if="$slots.content" class="editor-header-extra">
<slot name="content" />
</div>
<editor-content
:editor="editor"
:style="contentStyles"
class="editor-content markdown-body relative"
/>
</div>
<div
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>
</div>

View File

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

View File

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

View File

@ -98,12 +98,14 @@ import { onBeforeUnmount } from "vue";
import { usePermission } from "@/utils/permission";
import type { AxiosRequestConfig } from "axios";
import { getContents } from "./utils/attachment";
import { nextTick } from "vue";
const { t } = useI18n();
const { currentUserHasPermission } = usePermission();
const props = withDefaults(
defineProps<{
title?: string;
raw?: string;
content: string;
uploadImage?: (
@ -112,6 +114,7 @@ const props = withDefaults(
) => Promise<Attachment>;
}>(),
{
title: "",
raw: "",
content: "",
uploadImage: undefined,
@ -119,6 +122,7 @@ const props = withDefaults(
);
const emit = defineEmits<{
(event: "update:title", value: string): void;
(event: "update:raw", value: string): void;
(event: "update:content", value: string): void;
(event: "update", value: string): void;
@ -404,7 +408,6 @@ onMounted(() => {
UiExtensionUpload,
ExtensionSearchAndReplace,
],
autofocus: "start",
parseOptions: {
preserveWhitespace: true,
},
@ -441,6 +444,26 @@ const currentLocale = i18n.global.locale.value as
| "en"
| "zh"
| "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>
<template>
@ -452,6 +475,17 @@ const currentLocale = i18n.global.locale.value as
@close="handleCloseAttachmentSelectorModal"
/>
<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>
<OverlayScrollbarsComponent
element="div"

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ import {
VSpace,
} from "@halo-dev/components";
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 type { Post, Content, Snapshot } from "@halo-dev/api-client";
import { randomUUID } from "@/utils/id";
@ -82,6 +82,14 @@ const content = ref<Content>({
});
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<ComputedRef<string | undefined>>(
"owner",
@ -166,21 +174,7 @@ useAutoSaveContent(currentCache, toRef(content.value, "raw"), async () => {
if (isUpdateMode.value) {
handleSave({ mute: true });
} else {
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,
});
onCreatePostSuccess(createdPost);
handleCreate();
}
});
@ -271,16 +265,34 @@ async function handleSetEditorProviderFromRemote() {
}
// Create post
const postCreationModal = ref(false);
function handleSaveClick() {
if (isUpdateMode.value) {
handleSave({ mute: false });
} 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) {
formState.value = data;
// Update route query params
@ -300,6 +312,17 @@ const { mutateAsync: handleSave, isLoading: isSaving } = useMutation({
mute: false,
},
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
if (!snapshot.value) {
return;
@ -345,6 +368,7 @@ function handlePublishClick() {
if (isUpdateMode.value) {
handlePublish();
} else {
// Set editor title to post
postPublishModal.value = true;
}
}
@ -396,24 +420,7 @@ async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
return;
}
if (!isUpdateMode.value) {
formState.value.metadata.annotations = {
...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);
await handleCreate();
}
const { data } = await apiClient.uc.attachment.createAttachmentForPost(
@ -487,23 +494,17 @@ useSessionKeepAlive();
v-if="currentEditorProvider"
v-model:raw="content.raw"
v-model:content="content.content"
v-model:title="formState.spec.title"
:upload-image="handleUploadImage"
class="h-full"
@update="handleSetContentCache"
/>
</div>
<PostCreationModal
v-if="postCreationModal"
:title="$t('core.uc_post.creation_modal.title')"
:content="content"
@close="postCreationModal = false"
@success="onCreatePostSuccess"
/>
<PostCreationModal
v-if="postPublishModal"
:title="$t('core.uc_post.publish_modal.title')"
:post="formState"
:content="content"
publish
@close="postPublishModal = false"

View File

@ -17,6 +17,7 @@ const props = withDefaults(
title: string;
content: Content;
publish?: boolean;
post: Post;
}>(),
{
publish: false,
@ -107,7 +108,17 @@ function onSubmit(data: PostFormState) {
centered
@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>
<VSpace>