720 lines
18 KiB
TypeScript
Executable File
720 lines
18 KiB
TypeScript
Executable File
'use client';
|
||
|
||
import React, { createContext, useContext, useCallback, useState, useMemo, useEffect } from 'react';
|
||
import { useQuery, keepPreviousData, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import { useTRPC, usePost } from '@fenghuo/client';
|
||
import { Prisma } from '@fenghuo/db';
|
||
import type { Article } from '@fenghuo/common';
|
||
import { PostStatus, PostType } from '@fenghuo/common/post';
|
||
import type { FilterState, PaginationState, ArticleStats, ArticleAction, BatchActionType } from '@/lib/articles/types';
|
||
import { DEFAULT_PAGE_SIZE } from '@/lib/articles/constants';
|
||
import { type Editor } from '@tiptap/react';
|
||
import { toast } from '@nice/ui/components/sonner';
|
||
import { useAuth } from '@/components/providers/auth-provider';
|
||
|
||
// 后端返回的stats类型
|
||
interface BackendStats {
|
||
all: number;
|
||
published: number;
|
||
draft: number;
|
||
pending: number;
|
||
private: number;
|
||
trash: number;
|
||
}
|
||
|
||
// Context 类型定义
|
||
interface ArticlesContextValue {
|
||
// 数据
|
||
articles: Article[];
|
||
stats: ArticleStats;
|
||
isLoading: boolean;
|
||
|
||
// 状态
|
||
filters: FilterState;
|
||
pagination: PaginationState;
|
||
selectedArticles: string[];
|
||
quickEditId: string | null;
|
||
batchAction: string;
|
||
|
||
// 筛选和分页操作
|
||
updateFilters: (updates: Partial<FilterState>) => void;
|
||
setCurrentPage: (page: number) => void;
|
||
|
||
// 选择操作
|
||
handleSelectAll: (checked: boolean) => void;
|
||
handleSelectArticle: (articleId: string, checked: boolean) => void;
|
||
resetSelection: () => void;
|
||
setQuickEditId: (id: string | null) => void;
|
||
setBatchAction: (action: string) => void;
|
||
|
||
// 文章操作
|
||
handleQuickAction: (articleId: string, action: string) => void;
|
||
handleBatchAction: () => void;
|
||
handleArticleAction: (articleId: string, action: ArticleAction) => void;
|
||
handleQuickEditSave: (articleId: string, updates: Partial<Article>) => void;
|
||
updateVisibility: (articleId: string, visibility: 'public' | 'private') => void;
|
||
refetchArticles: () => void;
|
||
}
|
||
|
||
const ArticlesContext = createContext<ArticlesContextValue | null>(null);
|
||
|
||
// Hook 来使用 Context
|
||
export function useArticlesContext() {
|
||
const context = useContext(ArticlesContext);
|
||
if (!context) {
|
||
throw new Error('useArticlesContext must be used within ArticlesProvider');
|
||
}
|
||
return context;
|
||
}
|
||
|
||
// 将后端stats转换为前端期望的格式
|
||
function transformStats(backendStats: BackendStats): ArticleStats {
|
||
return {
|
||
all: backendStats.all,
|
||
published: backendStats.published,
|
||
draft: backendStats.draft,
|
||
archived: backendStats.private, // 将private映射为archived
|
||
deleted: backendStats.trash ?? 0, // 将trash映射为deleted
|
||
totalViews: 0, // TODO: 后续从其他接口获取
|
||
totalLikes: 0, // TODO: 后续从其他接口获取
|
||
};
|
||
}
|
||
|
||
// Provider 组件
|
||
export function ArticlesProvider({ children }: { children: React.ReactNode }) {
|
||
const router = useRouter();
|
||
const trpc = useTRPC();
|
||
|
||
const { update: updateArticle, softDeleteByIds } = usePost();
|
||
|
||
// 状态管理
|
||
const [filters, setFilters] = useState<FilterState>({
|
||
searchTerm: '',
|
||
statusFilter: 'all',
|
||
categoryFilter: 'all',
|
||
sortBy: 'created-desc',
|
||
});
|
||
|
||
const [pagination, setPagination] = useState<PaginationState>({
|
||
currentPage: 1,
|
||
pageSize: DEFAULT_PAGE_SIZE,
|
||
totalPages: 1,
|
||
totalCount: 0,
|
||
});
|
||
|
||
const [selectedArticles, setSelectedArticles] = useState<string[]>([]);
|
||
const [quickEditId, setQuickEditId] = useState<string | null>(null);
|
||
const [batchAction, setBatchAction] = useState<string>('');
|
||
|
||
// 构建查询条件
|
||
const { where, orderBy } = useMemo(() => {
|
||
const where: Prisma.PostWhereInput = {};
|
||
const orderBy: Prisma.PostOrderByWithRelationInput = {};
|
||
|
||
const andConditions: Prisma.PostWhereInput[] = [];
|
||
|
||
if (filters.statusFilter === 'trash') {
|
||
andConditions.push({ deletedAt: { not: null } } as any);
|
||
} else {
|
||
andConditions.push({ deletedAt: null } as any);
|
||
if (filters.statusFilter !== 'all') {
|
||
andConditions.push({ status: filters.statusFilter as any });
|
||
}
|
||
}
|
||
|
||
if (filters.searchTerm) {
|
||
andConditions.push({
|
||
OR: [
|
||
{ title: { contains: filters.searchTerm, mode: 'insensitive' } },
|
||
{ content: { contains: filters.searchTerm, mode: 'insensitive' } },
|
||
],
|
||
});
|
||
}
|
||
|
||
if (filters.categoryFilter !== 'all') {
|
||
andConditions.push({ terms: { some: { id: filters.categoryFilter } } });
|
||
}
|
||
|
||
if (andConditions.length > 0) {
|
||
where.AND = andConditions;
|
||
}
|
||
|
||
// 排序
|
||
switch (filters.sortBy) {
|
||
case 'created-asc':
|
||
orderBy.createdAt = 'asc';
|
||
break;
|
||
case 'created-desc':
|
||
orderBy.createdAt = 'desc';
|
||
break;
|
||
case 'published-asc':
|
||
orderBy.publishedAt = 'asc';
|
||
break;
|
||
case 'published-desc':
|
||
orderBy.publishedAt = 'desc';
|
||
break;
|
||
case 'title-asc':
|
||
orderBy.title = 'asc';
|
||
break;
|
||
case 'title-desc':
|
||
orderBy.title = 'desc';
|
||
break;
|
||
}
|
||
|
||
return { where, orderBy };
|
||
}, [filters]);
|
||
|
||
// 数据查询
|
||
const {
|
||
data: articlesData,
|
||
isLoading: isLoadingArticles,
|
||
refetch: refetchArticles,
|
||
} = useQuery(
|
||
trpc.post.findManyWithPagination.queryOptions(
|
||
{
|
||
page: pagination.currentPage,
|
||
pageSize: pagination.pageSize,
|
||
where,
|
||
orderBy,
|
||
},
|
||
{
|
||
placeholderData: keepPreviousData,
|
||
},
|
||
),
|
||
);
|
||
|
||
const { data: backendStatsData, isLoading: isLoadingStats } = useQuery(trpc.post.getStats.queryOptions({ where }));
|
||
|
||
// 转换后端stats数据
|
||
const stats = useMemo(() => {
|
||
if (!backendStatsData) {
|
||
return {
|
||
all: 0,
|
||
published: 0,
|
||
draft: 0,
|
||
archived: 0,
|
||
deleted: 0,
|
||
totalViews: 0,
|
||
totalLikes: 0,
|
||
};
|
||
}
|
||
return transformStats(backendStatsData as any);
|
||
}, [backendStatsData]);
|
||
|
||
// 更新分页信息
|
||
useEffect(() => {
|
||
if (articlesData) {
|
||
setPagination((prev) => ({
|
||
...prev,
|
||
totalPages: articlesData.totalPages,
|
||
totalCount: articlesData.totalCount,
|
||
}));
|
||
}
|
||
}, [articlesData]);
|
||
|
||
// 筛选和分页操作
|
||
const updateFilters = useCallback((updates: Partial<FilterState>) => {
|
||
setFilters((prev) => ({ ...prev, ...updates }));
|
||
setPagination((prev) => ({ ...prev, currentPage: 1 }));
|
||
}, []);
|
||
|
||
const setCurrentPage = useCallback((page: number) => {
|
||
setPagination((prev) => ({ ...prev, currentPage: page }));
|
||
}, []);
|
||
|
||
// 选择操作
|
||
const handleSelectAll = useCallback(
|
||
(checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedArticles(articlesData?.items.map((a) => a.id) ?? []);
|
||
} else {
|
||
setSelectedArticles([]);
|
||
}
|
||
},
|
||
[articlesData?.items],
|
||
);
|
||
|
||
const handleSelectArticle = useCallback((articleId: string, checked: boolean) => {
|
||
setSelectedArticles((prev) => (checked ? [...prev, articleId] : prev.filter((id) => id !== articleId)));
|
||
}, []);
|
||
|
||
const resetSelection = useCallback(() => {
|
||
setSelectedArticles([]);
|
||
setBatchAction('');
|
||
}, []);
|
||
|
||
const updateVisibility = useCallback(
|
||
(articleId: string, visibility: 'public' | 'private') => {
|
||
updateArticle.mutate({
|
||
where: { id: articleId },
|
||
data: { visibility },
|
||
});
|
||
},
|
||
[updateArticle],
|
||
);
|
||
|
||
// 文章操作
|
||
const handleQuickAction = useCallback(
|
||
(articleId: string, action: string) => {
|
||
switch (action) {
|
||
case 'publish':
|
||
updateArticle.mutate({
|
||
where: { id: articleId },
|
||
data: { status: 'PUBLISHED', publishedAt: new Date() },
|
||
});
|
||
break;
|
||
case 'draft':
|
||
updateArticle.mutate({
|
||
where: { id: articleId },
|
||
data: { status: 'DRAFT' },
|
||
});
|
||
break;
|
||
case 'archive':
|
||
updateArticle.mutate({
|
||
where: { id: articleId },
|
||
data: { status: 'ARCHIVED' },
|
||
});
|
||
break;
|
||
case 'delete':
|
||
softDeleteByIds.mutate({ ids: [articleId] });
|
||
break;
|
||
default:
|
||
console.warn('未知快速操作', action);
|
||
}
|
||
},
|
||
[updateArticle, softDeleteByIds],
|
||
);
|
||
|
||
// 批量操作
|
||
const handleBatchAction = useCallback(() => {
|
||
if (selectedArticles.length === 0 || !batchAction) return;
|
||
|
||
const actionHandlers = {
|
||
publish: { status: 'PUBLISHED', publishedAt: new Date() },
|
||
draft: { status: 'DRAFT' },
|
||
archive: { status: 'ARCHIVED' },
|
||
};
|
||
|
||
if (batchAction in actionHandlers) {
|
||
const data = actionHandlers[batchAction as keyof typeof actionHandlers];
|
||
updateArticle.mutate({
|
||
where: { id: { in: selectedArticles } } as any,
|
||
data,
|
||
});
|
||
} else if (batchAction === 'delete') {
|
||
softDeleteByIds.mutate({ ids: selectedArticles });
|
||
}
|
||
|
||
resetSelection();
|
||
}, [batchAction, selectedArticles, updateArticle, softDeleteByIds, resetSelection]);
|
||
|
||
const handleArticleAction = useCallback(
|
||
(articleId: string, action: ArticleAction) => {
|
||
switch (action) {
|
||
case 'edit':
|
||
router.push(`/editor?id=${articleId}`);
|
||
break;
|
||
case 'publish':
|
||
updateArticle.mutate({
|
||
where: { id: articleId },
|
||
data: { status: 'PUBLISHED', publishedAt: new Date() },
|
||
});
|
||
break;
|
||
case 'unpublish':
|
||
updateArticle.mutate({ where: { id: articleId }, data: { status: 'DRAFT' } });
|
||
break;
|
||
case 'archive':
|
||
updateArticle.mutate({ where: { id: articleId }, data: { status: 'ARCHIVED' } });
|
||
break;
|
||
case 'delete':
|
||
softDeleteByIds.mutate({ ids: [articleId] });
|
||
break;
|
||
case 'duplicate':
|
||
// TODO: 实现复制功能
|
||
toast.info('此功能正在开发中');
|
||
break;
|
||
case 'preview':
|
||
// TODO: 实现预览功能
|
||
toast.info('此功能正在开发中');
|
||
break;
|
||
case 'share':
|
||
// TODO: 实现分享功能
|
||
toast.info('此功能正在开发中');
|
||
console.log(`分享文章: ${articleId}`);
|
||
break;
|
||
}
|
||
},
|
||
[router, updateArticle, softDeleteByIds],
|
||
);
|
||
|
||
const handleQuickEditSave = useCallback(
|
||
(articleId: string, updates: Partial<Article>) => {
|
||
updateArticle.mutate({
|
||
where: { id: articleId },
|
||
data: updates as any,
|
||
});
|
||
setQuickEditId(null);
|
||
},
|
||
[updateArticle],
|
||
);
|
||
|
||
const contextValue: ArticlesContextValue = {
|
||
// 数据
|
||
articles: articlesData?.items ?? [],
|
||
stats,
|
||
isLoading: isLoadingArticles || isLoadingStats,
|
||
|
||
// 状态
|
||
filters,
|
||
pagination,
|
||
selectedArticles,
|
||
quickEditId,
|
||
batchAction,
|
||
|
||
// 筛选和分页操作
|
||
updateFilters,
|
||
setCurrentPage,
|
||
|
||
// 选择操作
|
||
handleSelectAll,
|
||
handleSelectArticle,
|
||
resetSelection,
|
||
setQuickEditId,
|
||
setBatchAction,
|
||
|
||
// 文章操作
|
||
handleQuickAction,
|
||
handleBatchAction,
|
||
handleArticleAction,
|
||
handleQuickEditSave,
|
||
updateVisibility,
|
||
refetchArticles,
|
||
};
|
||
|
||
return <ArticlesContext.Provider value={contextValue}>{children}</ArticlesContext.Provider>;
|
||
}
|
||
|
||
// 文章编辑器状态类型
|
||
interface EditorState {
|
||
// 文章基本信息
|
||
id?: string;
|
||
title: string;
|
||
excerpt: string;
|
||
content: string;
|
||
status: PostStatus;
|
||
publishedAt?: Date;
|
||
organizationId: string;
|
||
order: number;
|
||
|
||
// 分类信息
|
||
terms: { id: string; taxonomyId: string }[];
|
||
|
||
// 编辑器状态
|
||
isLoading: boolean;
|
||
isSaving: boolean;
|
||
lastSaved?: Date;
|
||
hasUnsavedChanges: boolean;
|
||
}
|
||
|
||
// 上下文值类型
|
||
interface EditorContextValue {
|
||
// 状态
|
||
state: EditorState;
|
||
|
||
// TipTap 编辑器实例
|
||
editor: Editor | null;
|
||
|
||
// 动作
|
||
setEditor: (editor: Editor | null) => void;
|
||
updateContent: (content: string) => void;
|
||
updateField: <K extends keyof EditorState>(field: K, value: EditorState[K]) => void;
|
||
|
||
// 保存操作
|
||
save: (options?: { newStatus?: PostStatus; successMessage?: string }) => Promise<void>;
|
||
|
||
// 重置状态
|
||
reset: () => void;
|
||
}
|
||
|
||
const defaultState: EditorState = {
|
||
title: '',
|
||
excerpt: '',
|
||
content: '',
|
||
status: PostStatus.DRAFT,
|
||
organizationId: '',
|
||
order: 0,
|
||
terms: [],
|
||
isLoading: false,
|
||
isSaving: false,
|
||
hasUnsavedChanges: false,
|
||
};
|
||
|
||
const EditorContext = createContext<EditorContextValue | null>(null);
|
||
|
||
export function useEditorContext() {
|
||
const context = useContext(EditorContext);
|
||
if (!context) {
|
||
throw new Error('useEditorContext must be used within EditorProvider');
|
||
}
|
||
return context;
|
||
}
|
||
|
||
interface EditorProviderProps {
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
export function EditorProvider({ children }: EditorProviderProps) {
|
||
const router = useRouter();
|
||
const trpc = useTRPC();
|
||
const { create, update } = usePost();
|
||
const searchParams = useSearchParams();
|
||
const articleId = searchParams.get('id');
|
||
|
||
// 获取当前用户信息
|
||
const { user: currentUser } = useAuth();
|
||
|
||
const [state, setState] = useState<EditorState>(defaultState);
|
||
const [editor, setEditor] = useState<Editor | null>(null);
|
||
|
||
// 获取文章数据
|
||
const { data: postData, isLoading } = useQuery({
|
||
...trpc.post.findFirst.queryOptions({
|
||
where: { id: articleId || '' },
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
content: true,
|
||
excerpt: true,
|
||
status: true,
|
||
publishedAt: true,
|
||
organizationId: true,
|
||
order: true,
|
||
terms: { select: { id: true, taxonomyId: true } },
|
||
},
|
||
}),
|
||
enabled: !!articleId,
|
||
});
|
||
|
||
// 获取分类数据用于验证
|
||
const { data: taxonomiesData } = useQuery({
|
||
...trpc.taxonomy.findMany.queryOptions({
|
||
where: {
|
||
postTypes: {
|
||
has: PostType.ARTICLE,
|
||
},
|
||
},
|
||
}),
|
||
});
|
||
|
||
// 初始化文章数据
|
||
useEffect(() => {
|
||
if (postData) {
|
||
setState((prev) => ({
|
||
...prev,
|
||
id: postData.id,
|
||
title: postData.title || '',
|
||
content: postData.content || '',
|
||
excerpt: postData.excerpt || '',
|
||
status: postData.status as PostStatus,
|
||
publishedAt: postData.publishedAt ? new Date(postData.publishedAt) : undefined,
|
||
organizationId: postData.organizationId || '',
|
||
order: postData.order,
|
||
terms: (postData as any).terms?.map((t: any) => ({ id: t.id, taxonomyId: t.taxonomyId })) || [],
|
||
isLoading: false,
|
||
}));
|
||
|
||
// 设置编辑器内容
|
||
if (editor && postData.content) {
|
||
editor.commands.setContent(postData.content);
|
||
}
|
||
} else if (!articleId) {
|
||
// 新文章,重置为默认状态并设置默认organizationId
|
||
setState((prev) => ({
|
||
...prev,
|
||
isLoading: false,
|
||
organizationId: currentUser?.organizationId || '',
|
||
}));
|
||
}
|
||
}, [postData, editor, articleId, currentUser]);
|
||
|
||
// 设置加载状态
|
||
useEffect(() => {
|
||
setState((prev) => ({ ...prev, isLoading }));
|
||
}, [isLoading]);
|
||
|
||
// 监听编辑器内容变化
|
||
useEffect(() => {
|
||
if (!editor) return;
|
||
|
||
const handleUpdate = () => {
|
||
const content = editor.getHTML();
|
||
setState((prev) => ({
|
||
...prev,
|
||
content,
|
||
hasUnsavedChanges: true,
|
||
}));
|
||
};
|
||
|
||
editor.on('update', handleUpdate);
|
||
return () => {
|
||
editor.off('update', handleUpdate);
|
||
};
|
||
}, [editor]);
|
||
|
||
// 自动保存
|
||
useEffect(() => {
|
||
if (!state.hasUnsavedChanges) return;
|
||
|
||
const timer = setTimeout(() => {
|
||
// 自动保存时不显示成功消息,静默保存
|
||
save({ successMessage: undefined });
|
||
}, 30000); // 30秒自动保存
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [state.hasUnsavedChanges]);
|
||
|
||
const updateContent = useCallback((content: string) => {
|
||
setState((prev) => ({
|
||
...prev,
|
||
content,
|
||
hasUnsavedChanges: true,
|
||
}));
|
||
}, []);
|
||
|
||
const updateField = useCallback(<K extends keyof EditorState>(field: K, value: EditorState[K]) => {
|
||
setState((prev) => ({
|
||
...prev,
|
||
[field]: value,
|
||
hasUnsavedChanges: true,
|
||
}));
|
||
}, []);
|
||
|
||
const save = useCallback(
|
||
async (options?: { newStatus?: PostStatus; successMessage?: string }) => {
|
||
setState((prev) => ({ ...prev, isSaving: true }));
|
||
|
||
try {
|
||
const finalStatus = options?.newStatus || state.status;
|
||
|
||
// 验证必填字段 - 只有发布和归档状态才需要验证
|
||
if (finalStatus === PostStatus.PUBLISHED || finalStatus === PostStatus.ARCHIVED) {
|
||
if (!state.title.trim()) {
|
||
toast.error('保存失败', { description: '文章标题不能为空。' });
|
||
return;
|
||
}
|
||
|
||
// 验证分类选择
|
||
if (taxonomiesData) {
|
||
for (const taxonomy of taxonomiesData) {
|
||
if (!state.terms.some((t) => t.taxonomyId === taxonomy.id)) {
|
||
toast.error('保存失败', { description: `请选择一个${taxonomy.name}。` });
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const newState = { ...state };
|
||
if (options?.newStatus) {
|
||
newState.status = options.newStatus;
|
||
if (options.newStatus === PostStatus.PUBLISHED && !newState.publishedAt) {
|
||
newState.publishedAt = new Date();
|
||
}
|
||
}
|
||
|
||
// 获取当前编辑器内容
|
||
const currentContent = editor?.getHTML() || state.content;
|
||
|
||
const commonData = {
|
||
title: newState.title,
|
||
content: currentContent, // 包含编辑器内容
|
||
excerpt: newState.excerpt,
|
||
status: newState.status,
|
||
order: newState.order,
|
||
publishedAt: newState.publishedAt,
|
||
type: PostType.ARTICLE,
|
||
};
|
||
|
||
if (state.id) {
|
||
// 更新现有文章
|
||
await update.mutateAsync({
|
||
where: { id: state.id },
|
||
data: {
|
||
...commonData,
|
||
organizationId: newState.organizationId || undefined,
|
||
terms: newState.terms.length ? { set: newState.terms.map(({ id }) => ({ id })) } : { set: [] },
|
||
},
|
||
});
|
||
|
||
// 只有在显式保存时才显示成功消息
|
||
if (options?.successMessage !== undefined) {
|
||
toast.success(options.successMessage || '文章已保存');
|
||
}
|
||
} else {
|
||
// 创建新文章 - organizationId 现在是可选的
|
||
const effectiveOrganizationId = newState.organizationId || currentUser?.organizationId || undefined;
|
||
|
||
const result = await create.mutateAsync({
|
||
data: {
|
||
...commonData,
|
||
organizationId: effectiveOrganizationId,
|
||
authorId: currentUser!.id, // 使用当前用户ID
|
||
terms: newState.terms.length ? { connect: newState.terms.map(({ id }) => ({ id })) } : undefined,
|
||
},
|
||
});
|
||
|
||
// 更新URL和状态
|
||
router.replace(`/editor?id=${result.id}`);
|
||
newState.id = result.id;
|
||
if (effectiveOrganizationId) {
|
||
newState.organizationId = effectiveOrganizationId;
|
||
}
|
||
|
||
// 只有在显式保存时才显示成功消息
|
||
if (options?.successMessage !== undefined) {
|
||
toast.success(options.successMessage || '文章已创建');
|
||
}
|
||
}
|
||
|
||
setState((prev) => ({
|
||
...prev,
|
||
...newState,
|
||
content: currentContent,
|
||
hasUnsavedChanges: false,
|
||
lastSaved: new Date(),
|
||
}));
|
||
} catch (error: any) {
|
||
// 只有在显式保存时才显示错误消息
|
||
if (options?.successMessage !== undefined) {
|
||
toast.error(`操作失败: ${error.message}`);
|
||
} else {
|
||
console.error('自动保存失败:', error);
|
||
}
|
||
} finally {
|
||
setState((prev) => ({ ...prev, isSaving: false }));
|
||
}
|
||
},
|
||
[state, editor, taxonomiesData, update, create, router, currentUser],
|
||
);
|
||
|
||
const reset = useCallback(() => {
|
||
setState(defaultState);
|
||
if (editor) {
|
||
editor.commands.clearContent();
|
||
}
|
||
}, [editor]);
|
||
|
||
const contextValue: EditorContextValue = {
|
||
state,
|
||
editor,
|
||
setEditor,
|
||
updateContent,
|
||
updateField,
|
||
save,
|
||
reset,
|
||
};
|
||
|
||
return <EditorContext.Provider value={contextValue}>{children}</EditorContext.Provider>;
|
||
}
|