feat: add table editing

This commit is contained in:
tianma-if committed 2026-07-21 14:41:24 +08:00
1 parent 76e4874e3a
commit 64c992f315
21 files changed
+648 -35

No files matched your search

+2 -2
View File
@@ -3,7 +3,7 @@
"name": "EdgeEver",
"slug": "edgeever",
"scheme": "edgeever",
"version": "1.2.0",
"version": "1.3.0",
"runtimeVersion": {
"policy": "appVersion"
},
@@ -28,7 +28,7 @@
},
"android": {
"package": "org.edgeever.mobile",
"versionCode": 27,
"versionCode": 28,
"adaptiveIcon": {
"backgroundColor": "#d1fae5",
"backgroundImage": "./assets/icon.png",
+1
View File
@@ -22,6 +22,7 @@
"@tanstack/react-query-persist-client": "^5.101.2",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"expo": "~57.0.7",
@@ -2,6 +2,7 @@
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import type { TiptapDoc } from "@edgeever/shared";
@@ -20,6 +21,7 @@ import {
getMobileEditorPlaceholder,
getMobileEditorToolbarActionLabel,
getMobileEditorToolbarLabel,
type MobileEditorTableActionId,
type MobileEditorToolbarActionId,
} from "@edgeever/shared/mobile-editor";
import { useDOMImperativeHandle, type DOMImperativeFactory, type DOMProps } from "expo/dom";
@@ -89,6 +91,9 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) {
extensions: [
StarterKit,
protectedImageExtension,
TableKit.configure({
table: { renderWrapper: true },
}),
Placeholder.configure({
placeholder: getMobileEditorPlaceholder(props.locale),
}),
@@ -250,7 +255,8 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) {
selector: ({ editor: activeEditor }) =>
(activeEditor?.isActive("bold") ? MOBILE_EDITOR_ACTIVE_FLAGS.bold : 0) |
(activeEditor?.isActive("bulletList") ? MOBILE_EDITOR_ACTIVE_FLAGS.bulletList : 0) |
(activeEditor?.isActive("blockquote") ? MOBILE_EDITOR_ACTIVE_FLAGS.blockquote : 0),
(activeEditor?.isActive("blockquote") ? MOBILE_EDITOR_ACTIVE_FLAGS.blockquote : 0) |
(activeEditor?.isActive("table") ? MOBILE_EDITOR_ACTIVE_FLAGS.table : 0),
});
const insertImage = async () => {
@@ -272,12 +278,49 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) {
}
};
const runTableAction = (action: MobileEditorTableActionId) => {
const chain = editor?.chain().focus();
if (!chain) {
return;
}
switch (action) {
case "insertTable":
chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
return;
case "addTableRow":
chain.addRowAfter().run();
return;
case "deleteTableRow":
chain.deleteRow().run();
return;
case "addTableColumn":
chain.addColumnAfter().run();
return;
case "deleteTableColumn":
chain.deleteColumn().run();
return;
case "toggleTableHeader":
chain.toggleHeaderRow().run();
return;
case "deleteTable":
chain.deleteTable().run();
}
};
const toolbarIcons: Record<MobileEditorToolbarActionId, ReactNode> = {
image: <ImagePlusIcon />,
bold: <BoldIcon />,
bulletList: <ListIcon />,
blockquote: <QuoteIcon />,
horizontalRule: <MinusIcon />,
insertTable: <TableGridIcon />,
addTableRow: <TableActionGlyph label="R+" />,
deleteTableRow: <TableActionGlyph label="R−" />,
addTableColumn: <TableActionGlyph label="C+" />,
deleteTableColumn: <TableActionGlyph label="C−" />,
toggleTableHeader: <TableActionGlyph label="H" />,
deleteTable: <TableActionGlyph label="×" />,
};
const toolbarHandlers: Record<MobileEditorToolbarActionId, () => void> = {
image: () => void insertImage(),
@@ -285,32 +328,45 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) {
bulletList: () => editor?.chain().focus().toggleBulletList().run(),
blockquote: () => editor?.chain().focus().toggleBlockquote().run(),
horizontalRule: () => editor?.chain().focus().setHorizontalRule().run(),
insertTable: () => runTableAction("insertTable"),
addTableRow: () => runTableAction("addTableRow"),
deleteTableRow: () => runTableAction("deleteTableRow"),
addTableColumn: () => runTableAction("addTableColumn"),
deleteTableColumn: () => runTableAction("deleteTableColumn"),
toggleTableHeader: () => runTableAction("toggleTableHeader"),
deleteTable: () => runTableAction("deleteTable"),
};
return (
<div className="edgeever-editor-shell">
<style>{getEditorStyles(props.theme)}</style>
<div aria-label={getMobileEditorToolbarLabel(props.locale)} className="edgeever-editor-toolbar" role="toolbar">
{MOBILE_EDITOR_TOOLBAR_ACTIONS.map((action) => (
{MOBILE_EDITOR_TOOLBAR_ACTIONS
.filter((action) => toolbarState & MOBILE_EDITOR_ACTIVE_FLAGS.table
? action.id !== "insertTable"
: !action.requiresTable)
.map((action) => (
<ToolbarButton
key={action.id}
active={action.activeFlag > 0 && Boolean(toolbarState & action.activeFlag)}
disabled={(action.requiresTable && !(toolbarState & MOBILE_EDITOR_ACTIVE_FLAGS.table)) || (action.id === "insertTable" && Boolean(toolbarState & MOBILE_EDITOR_ACTIVE_FLAGS.table))}
icon={toolbarIcons[action.id]}
label={getMobileEditorToolbarActionLabel(action.id, props.locale)}
onRun={toolbarHandlers[action.id]}
/>
))}
))}
</div>
<EditorContent editor={editor} />
</div>
);
}
const ToolbarButton = ({ active = false, icon, label, onRun }: { active?: boolean; icon: ReactNode; label: string; onRun: () => void }) => (
const ToolbarButton = ({ active = false, disabled = false, icon, label, onRun }: { active?: boolean; disabled?: boolean; icon: ReactNode; label: string; onRun: () => void }) => (
<button
aria-label={label}
aria-pressed={active}
className={active ? "is-active" : undefined}
disabled={disabled}
onMouseDown={(event) => event.preventDefault()}
onClick={onRun}
type="button"
@@ -399,6 +455,17 @@ const MinusIcon = () => (
</EditorIcon>
);
const TableGridIcon = () => (
<EditorIcon size={18} strokeWidth={2}>
<rect height="16" rx="1" width="18" x="3" y="4" />
<path d="M3 10h18M9 4v16M15 4v16" />
</EditorIcon>
);
const TableActionGlyph = ({ label }: { label: string }) => (
<span aria-hidden="true" className="edgeever-table-action-glyph">{label}</span>
);
const mapImageSources = (doc: EditorDoc, mapSource: (source: string) => string): EditorDoc => {
const visit = (value: unknown): unknown => {
if (Array.isArray(value)) {
@@ -832,6 +899,8 @@ const getEditorStyles = (theme: "light" | "dark") => `
.edgeever-editor-toolbar::-webkit-scrollbar { display: none; }
.edgeever-editor-toolbar button { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 36px; min-height: 32px; padding: 0; border: 1px solid transparent; border-radius: 999px; background: transparent; color: ${theme === "dark" ? "#cbd5e1" : "#64748b"}; }
.edgeever-editor-toolbar button:active, .edgeever-editor-toolbar button.is-active { border-color: ${theme === "dark" ? "#166534" : "#bbf7d0"}; background: ${theme === "dark" ? "#14532d" : "#ecfdf5"}; color: ${theme === "dark" ? "#86efac" : "#047857"}; }
.edgeever-editor-toolbar button:disabled { opacity: 0.38; }
.edgeever-table-action-glyph { font-size: 12px; font-weight: 800; letter-spacing: -0.03em; }
.tiptap { min-height: 100%; outline: none; }
.edgeever-editor-shell > div:last-child { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
.edgeever-editor-content { min-height: 100%; padding: 18px 12px 32px; font-size: 17px; line-height: 1.7; word-break: break-word; caret-color: #0f766e; }
@@ -843,6 +912,17 @@ const getEditorStyles = (theme: "light" | "dark") => `
.edgeever-editor-content code { border-radius: 4px; padding: 2px 4px; background: ${theme === "dark" ? "#1e293b" : "#f1f5f9"}; }
.edgeever-editor-content pre code { padding: 0; background: transparent; }
.edgeever-editor-content img { display: block; max-width: 100%; height: auto; margin: 14px auto; border-radius: 10px; }
.edgeever-editor-content .tableWrapper { width: fit-content; max-width: 100%; overflow-x: auto; margin-top: 20px; margin-right: auto; margin-bottom: 20px; margin-left: auto; border: 1px solid ${theme === "dark" ? "#334155" : "#d8d8d8"}; border-radius: 2px; background: ${theme === "dark" ? "#0f172a" : "#fff"}; overscroll-behavior-inline: contain; scrollbar-color: rgba(100, 116, 139, 0.28) transparent; }
.edgeever-editor-content table { width: 42rem; min-width: 42rem !important; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
.edgeever-editor-content table col { width: auto !important; }
.edgeever-editor-content th, .edgeever-editor-content td { position: relative; min-width: 7.5rem; border: 0; border-right: 1px solid ${theme === "dark" ? "#334155" : "#dedede"}; border-bottom: 1px solid ${theme === "dark" ? "#334155" : "#dedede"}; padding: 7px 12px; text-align: left; vertical-align: top; overflow-wrap: anywhere; line-height: 1.4; transition: background-color 120ms ease; }
.edgeever-editor-content th { background: ${theme === "dark" ? "#27303f" : "#f0f0f0"}; color: ${theme === "dark" ? "#f8fafc" : "#111827"}; font-size: 14px; font-weight: 700; }
.edgeever-editor-content th:last-child, .edgeever-editor-content td:last-child { border-right: 0; }
.edgeever-editor-content tr:last-child td { border-bottom: 0; }
.edgeever-editor-content tbody tr:nth-child(even) td { background: ${theme === "dark" ? "#182235" : "#f8f8f8"}; }
.edgeever-editor-content tbody tr:hover td { background: ${theme === "dark" ? "#202b3d" : "#f3f4f6"}; }
.edgeever-editor-content th p, .edgeever-editor-content td p { margin: 0; }
.edgeever-editor-content .selectedCell::after { position: absolute; inset: 0; content: ""; pointer-events: none; background: rgba(16, 185, 129, 0.14); }
.edgeever-image-upload-placeholder { position: relative; max-width: 100%; min-height: 112px; margin: 14px auto; overflow: hidden; border-radius: 10px; background: ${theme === "dark" ? "#1e293b" : "#f1f5f9"}; }
.edgeever-image-upload-preview { display: block; width: 100%; max-height: 360px; margin: 0 !important; object-fit: contain; border-radius: 10px; }
.edgeever-image-upload-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 10px; border-radius: 10px; background: rgba(15, 23, 42, 0.38); color: #fff; font-size: 14px; font-weight: 600; text-shadow: 0 1px 2px rgba(15, 23, 42, 0.45); }
+4 -2
View File
@@ -79,7 +79,7 @@ import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"
import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText";
import Markdown, { type RenderRules } from "react-native-markdown-display";
import { SvgXml } from "react-native-svg";
import { buildGitHubFeedbackUrl, buildRevisionDiffRows, createExcerpt, docToMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, type ApiToken, type AuthUser, type MemoDetail, type MemoRevision, type MemoSummary, type Notebook, type ResourceListItem, type RevisionDiffRow, type TagSummary, type TiptapDoc } from "@edgeever/shared";
import { buildGitHubFeedbackUrl, buildRevisionDiffRows, createExcerpt, docToMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, resolveMemoContentDoc, type ApiToken, type AuthUser, type MemoDetail, type MemoRevision, type MemoSummary, type Notebook, type ResourceListItem, type RevisionDiffRow, type TagSummary, type TiptapDoc } from "@edgeever/shared";
import { MOBILE_UI_METRICS, getMobileCenteredScrollOffset, getMobileNotebookSearchVisibleIds, toggleMobileMemoFilterMode, toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
import { clearMobileMemoDraft, clearMobileNewMemoDraft, readMobileMemoDraft, readMobileNewMemoDraft, writeMobileMemoDraft, writeMobileNewMemoDraft, type MobileMemoDraft } from "../lib/mobile-drafts";
import {
@@ -4267,7 +4267,9 @@ const RichEditorModal = ({
const { resolvedLocale } = useMobileLocale();
const { resolvedTheme } = useMobileTheme();
const restoredDraft = initialDraft?.expectedRevision === memo?.revision ? initialDraft : null;
const initialContentJson = restoredDraft ? markdownToDoc(restoredDraft.contentMarkdown) : memo?.contentJson ?? markdownToDoc(memo?.contentMarkdown ?? "");
const initialContentJson = restoredDraft
? markdownToDoc(restoredDraft.contentMarkdown)
: resolveMemoContentDoc(memo?.contentJson, memo?.contentMarkdown);
const editorRef = useRef<LocalTiptapEditorRef>(null);
const resourceDataUrlCacheRef = useRef(new Map<string, Promise<string | null>>());
const initialFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+16 -5
View File
@@ -5,6 +5,7 @@ import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import { useTranslation } from "react-i18next";
import {
ChevronLeft,
@@ -61,6 +62,7 @@ import { cn, formatDateTime, parseTagsText } from "@/lib/utils";
import {
docToMarkdown,
markdownToDoc,
resolveMemoContentDoc,
type Notebook,
type MemoDetail,
type MemoEditSession,
@@ -743,7 +745,9 @@ const MobileNativeEditorPane = ({
const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt));
const nextTitle = useDraft && draft ? draft.title : memo.title ?? "";
const nextTagsText = useDraft && draft ? draft.tagsText : memo.tags.join(", ");
const nextContent = useDraft && draft ? draft.contentJson : memo.contentJson;
const nextContent = useDraft && draft
? draft.contentJson
: resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown);
editSessionRef.current = editSessionResponse?.editSession ?? null;
hydratingRef.current = true;
@@ -945,7 +949,7 @@ const MobileNativeEditorPane = ({
<textarea
ref={bodyRef}
defaultValue={docToMarkdown(memo.contentJson)}
defaultValue={docToMarkdown(resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown))}
autoCapitalize="sentences"
autoComplete="on"
autoCorrect="on"
@@ -1328,11 +1332,16 @@ const RichEditorPane = ({
allowBase64: false,
inline: false,
}),
TableKit.configure({
table: { renderWrapper: true },
}),
Placeholder.configure({
placeholder: "开始记录...",
}),
],
content: memo?.contentJson ?? { type: "doc", content: [{ type: "paragraph" }] },
content: memo
? resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown)
: { type: "doc", content: [{ type: "paragraph" }] },
editable: Boolean(memo && !effectiveReadOnly),
editorProps: {
attributes: {
@@ -1683,7 +1692,9 @@ const RichEditorPane = ({
const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt));
const nextTitle = useDraft && draft ? draft.title : memo.title ?? "";
const nextTagsText = useDraft && draft ? draft.tagsText : memo.tags.join(", ");
const nextContent = useDraft && draft ? draft.contentJson : memo.contentJson;
const nextContent = useDraft && draft
? draft.contentJson
: resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown);
const nextMarkdown = docToMarkdown(nextContent);
const nextHasUnsavedChanges = Boolean(useDraft && !queuedUpdate);
@@ -1728,7 +1739,7 @@ const RichEditorPane = ({
}
if (memo) {
const nextMarkdown = docToMarkdown(memo.contentJson);
const nextMarkdown = docToMarkdown(resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown));
setMobilePlainText(nextMarkdown);
setMobilePlainTextElementValue(mobileTextAreaRef.current, nextMarkdown);
}
@@ -0,0 +1,94 @@
import { useEditorState, type Editor } from "@tiptap/react";
import { Table2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type EditorTableMenuProps = {
editor: Editor | null;
readOnly: boolean;
};
export const EditorTableMenu = ({ editor, readOnly }: EditorTableMenuProps) => {
const { t } = useTranslation();
const ready = Boolean(editor && !editor.isDestroyed);
const tableActiveState = useEditorState({
editor,
selector: ({ editor: activeEditor }) => Boolean(activeEditor?.isActive("table")),
});
const inTable = Boolean(tableActiveState);
const disabled = readOnly || !ready;
const run = (command: (activeEditor: Editor) => void) => {
if (!editor || editor.isDestroyed || readOnly) {
return;
}
command(editor);
};
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md border transition disabled:pointer-events-none disabled:opacity-40",
inTable
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-transparent bg-transparent text-slate-700 hover:border-slate-200 hover:bg-slate-50"
)}
type="button"
aria-label={t("editorToolbar.table")}
aria-pressed={inTable || undefined}
disabled={disabled}
>
<Table2 aria-hidden="true" className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{t("editorToolbar.table")}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start">
<DropdownMenuItem
disabled={inTable}
onSelect={() => run((activeEditor) => activeEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())}
>
{t("editorToolbar.insertTable")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().addRowAfter().run())}>
{t("editorToolbar.addTableRow")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().deleteRow().run())}>
{t("editorToolbar.deleteTableRow")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().addColumnAfter().run())}>
{t("editorToolbar.addTableColumn")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().deleteColumn().run())}>
{t("editorToolbar.deleteTableColumn")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!inTable} onSelect={() => run((activeEditor) => activeEditor.chain().focus().toggleHeaderRow().run())}>
{t("editorToolbar.toggleTableHeader")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-rose-700 focus:text-rose-800"
disabled={!inTable}
onSelect={() => run((activeEditor) => activeEditor.chain().focus().deleteTable().run())}
>
{t("editorToolbar.deleteTable")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -19,6 +19,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getActiveBlockValue } from "@/lib/app-helpers";
import { CODE_BLOCK_LANGUAGES, getCodeBlockLanguageValue } from "@/lib/code-block";
import { EditorTableMenu } from "@/components/EditorTableMenu";
const EditorToolbarButton = ({
active = false,
@@ -344,6 +345,7 @@ export const EditorToolbar = ({
>
<Minus className="h-4 w-4" />
</EditorToolbarButton>
<EditorTableMenu editor={editor} readOnly={readOnly} />
</>
)}
</div>
@@ -2,6 +2,7 @@ import { Bold, Check, ChevronDown, ImagePlus, List, Minus, Quote } from "lucide-
import {
MOBILE_EDITOR_TOOLBAR_ACTIONS,
getMobileEditorToolbarActionLabel,
type MobileEditorTableActionId,
type MobileEditorToolbarActionId,
} from "@edgeever/shared/mobile-editor";
import type { ReactNode } from "react";
@@ -37,21 +38,25 @@ export const MobileEditorToolbar = ({
boldActive,
bulletListActive,
blockquoteActive,
tableActive,
onPickImage,
onToggleBold,
onToggleBulletList,
onToggleBlockquote,
onSetHorizontalRule,
onTableAction,
}: {
disabled: boolean;
boldActive: boolean;
bulletListActive: boolean;
blockquoteActive: boolean;
tableActive: boolean;
onPickImage: () => void;
onToggleBold: () => void;
onToggleBulletList: () => void;
onToggleBlockquote: () => void;
onSetHorizontalRule: () => void;
onTableAction: (action: MobileEditorTableActionId) => void;
}) => {
const icons: Record<MobileEditorToolbarActionId, ReactNode> = {
image: <ImagePlus aria-hidden="true" size={18} strokeWidth={2} />,
@@ -59,6 +64,13 @@ export const MobileEditorToolbar = ({
bulletList: <List aria-hidden="true" size={18} strokeWidth={2.2} />,
blockquote: <Quote aria-hidden="true" size={17} strokeWidth={2.2} />,
horizontalRule: <Minus aria-hidden="true" size={18} strokeWidth={2.4} />,
insertTable: <TableActionGlyph label="T" />,
addTableRow: <TableActionGlyph label="R+" />,
deleteTableRow: <TableActionGlyph label="R−" />,
addTableColumn: <TableActionGlyph label="C+" />,
deleteTableColumn: <TableActionGlyph label="C−" />,
toggleTableHeader: <TableActionGlyph label="H" />,
deleteTable: <TableActionGlyph label="×" />,
};
const handlers: Record<MobileEditorToolbarActionId, () => void> = {
image: onPickImage,
@@ -66,6 +78,13 @@ export const MobileEditorToolbar = ({
bulletList: onToggleBulletList,
blockquote: onToggleBlockquote,
horizontalRule: onSetHorizontalRule,
insertTable: () => onTableAction("insertTable"),
addTableRow: () => onTableAction("addTableRow"),
deleteTableRow: () => onTableAction("deleteTableRow"),
addTableColumn: () => onTableAction("addTableColumn"),
deleteTableColumn: () => onTableAction("deleteTableColumn"),
toggleTableHeader: () => onTableAction("toggleTableHeader"),
deleteTable: () => onTableAction("deleteTable"),
};
const activeStates: Partial<Record<MobileEditorToolbarActionId, boolean>> = {
bold: boldActive,
@@ -75,7 +94,9 @@ export const MobileEditorToolbar = ({
return (
<div className="mobile-editor-tool-row">
{MOBILE_EDITOR_TOOLBAR_ACTIONS.map(({ id }) => {
{MOBILE_EDITOR_TOOLBAR_ACTIONS
.filter(({ id, requiresTable }) => tableActive ? id !== "insertTable" : !requiresTable)
.map(({ id, requiresTable }) => {
const label = getMobileEditorToolbarActionLabel(id, "zh-CN");
return (
@@ -86,18 +107,22 @@ export const MobileEditorToolbar = ({
aria-label={label}
title={label}
aria-pressed={activeStates[id]}
disabled={disabled}
disabled={disabled || (requiresTable && !tableActive) || (id === "insertTable" && tableActive)}
onPointerDown={(event) => event.preventDefault()}
onClick={handlers[id]}
>
{icons[id]}
</button>
);
})}
})}
</div>
);
};
const TableActionGlyph = ({ label }: { label: string }) => (
<span aria-hidden="true" className="mobile-editor-table-glyph">{label}</span>
);
export const MobileEditorNotebookButton = ({
label,
disabled,
@@ -3,8 +3,9 @@ import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import { createExcerpt, docToMarkdown, docToText, emptyDoc, type MemoDetail, type MemoEditSession, type Notebook, type TiptapDoc } from "@edgeever/shared";
import { getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor";
import { getMobileEditorInputAttributes, getMobileEditorPlaceholder, type MobileEditorTableActionId } from "@edgeever/shared/mobile-editor";
import {
MobileEditorFallback,
MobileEditorHeader,
@@ -171,6 +172,9 @@ export const MobileStandaloneTiptapEditor = ({
allowBase64: false,
inline: false,
}),
TableKit.configure({
table: { renderWrapper: true },
}),
Placeholder.configure({
placeholder: getMobileEditorPlaceholder("zh-CN"),
}),
@@ -792,6 +796,7 @@ export const MobileStandaloneTiptapEditor = ({
notebookOptions.find((notebook) => notebook.id === memo?.notebookId)?.name ?? (notebookOptions.length === 0 ? "等待分类" : "笔记本");
const fallbackMarkdown = memo ? docToMarkdown(contentJsonRef.current) : "";
const tableActive = Boolean(editor?.isActive("table"));
const runEditorCommand = (command: () => boolean) => {
if (editorActionDisabled || !editor) {
@@ -802,6 +807,32 @@ export const MobileStandaloneTiptapEditor = ({
editor.commands.focus();
};
const runTableAction = (action: MobileEditorTableActionId) => {
runEditorCommand(() => {
const chain = editor?.chain().focus();
if (!chain) {
return false;
}
switch (action) {
case "insertTable":
return chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
case "addTableRow":
return chain.addRowAfter().run();
case "deleteTableRow":
return chain.deleteRow().run();
case "addTableColumn":
return chain.addColumnAfter().run();
case "deleteTableColumn":
return chain.deleteColumn().run();
case "toggleTableHeader":
return chain.toggleHeaderRow().run();
case "deleteTable":
return chain.deleteTable().run();
}
});
};
return (
<div className="mobile-editor-shell">
<MobileEditorHeader saveLabel={saveLabel} statusClassName={statusClassName} saveState={saveState} onLeave={() => void leavePage()} />
@@ -839,11 +870,13 @@ export const MobileStandaloneTiptapEditor = ({
boldActive={Boolean(editor?.isActive("bold"))}
bulletListActive={Boolean(editor?.isActive("bulletList"))}
blockquoteActive={Boolean(editor?.isActive("blockquote"))}
tableActive={tableActive}
onPickImage={() => imageInputRef.current?.click()}
onToggleBold={() => runEditorCommand(() => editor?.chain().focus().toggleBold().run() ?? false)}
onToggleBulletList={() => runEditorCommand(() => editor?.chain().focus().toggleBulletList().run() ?? false)}
onToggleBlockquote={() => runEditorCommand(() => editor?.chain().focus().toggleBlockquote().run() ?? false)}
onSetHorizontalRule={() => runEditorCommand(() => editor?.chain().focus().setHorizontalRule().run() ?? false)}
onTableAction={runTableAction}
/>
<input
ref={imageInputRef}
+4 -8
View File
@@ -1,4 +1,4 @@
import { emptyDoc, markdownToDoc, type MemoDetail, type Resource, type TiptapDoc } from "@edgeever/shared";
import { emptyDoc, resolveMemoContentDoc, type MemoDetail, type Resource, type TiptapDoc } from "@edgeever/shared";
import { ApiRequestError } from "@/lib/api";
export const MOBILE_EDITOR_AUTO_SAVE_DELAY_MS = 1200;
@@ -115,13 +115,9 @@ export const uploadMobileEditorResource = async (memoId: string, file: File) =>
};
export const normalizeMobileEditorDoc = (memo: MemoDetail): TiptapDoc => {
if (memo.contentJson && typeof memo.contentJson === "object") {
return memo.contentJson as TiptapDoc;
if (!memo.contentJson && !memo.contentMarkdown) {
return emptyDoc();
}
if (memo.contentMarkdown) {
return markdownToDoc(memo.contentMarkdown);
}
return emptyDoc();
return resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown);
};
+122 -1
View File
@@ -476,7 +476,7 @@ textarea {
color: var(--brand-green-text);
font-weight: 500;
border-radius: 0 4px 4px 0;
margin: 1.25rem 0;
margin: 1.25rem auto;
}
.markdown-content ul {
list-style-type: disc;
@@ -532,6 +532,127 @@ textarea {
color: var(--brand-green);
}
.ProseMirror .tableWrapper,
.markdown-content .tableWrapper {
width: fit-content;
max-width: 100%;
overflow-x: auto;
margin-top: 1.25rem;
margin-right: auto;
margin-bottom: 1.25rem;
margin-left: auto;
border: 1px solid #d8d8d8;
border-radius: 2px;
background: #fff;
overscroll-behavior-inline: contain;
scrollbar-color: rgb(100 116 139 / 0.28) transparent;
}
.ProseMirror table,
.markdown-content table {
width: 42rem;
min-width: 42rem !important;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.ProseMirror table col,
.markdown-content table col {
width: auto !important;
}
.ProseMirror th,
.ProseMirror td,
.markdown-content th,
.markdown-content td {
position: relative;
min-width: 7.5rem;
border: 0;
border-right: 1px solid #dedede;
border-bottom: 1px solid #dedede;
padding: 0.42rem 0.75rem;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
line-height: 1.4;
transition: background-color 120ms ease;
}
.ProseMirror th,
.markdown-content th {
background: #f0f0f0;
color: #111827;
font-size: 0.875rem;
font-weight: 700;
}
.ProseMirror th:last-child,
.ProseMirror td:last-child,
.markdown-content th:last-child,
.markdown-content td:last-child {
border-right: 0;
}
.ProseMirror tr:last-child td,
.markdown-content tr:last-child td {
border-bottom: 0;
}
.ProseMirror tbody tr:nth-child(even) td,
.markdown-content tbody tr:nth-child(even) td {
background: #f8f8f8;
}
.ProseMirror tbody tr:hover td,
.markdown-content tbody tr:hover td {
background: #f3f4f6;
}
.ProseMirror th p,
.ProseMirror td p,
.markdown-content th p,
.markdown-content td p {
margin: 0;
}
.ProseMirror .selectedCell::after {
position: absolute;
inset: 0;
content: "";
pointer-events: none;
background: rgb(16 185 129 / 0.14);
}
:root.dark .ProseMirror th,
:root.dark .markdown-content th {
background: #27303f;
color: #f8fafc;
}
:root.dark .ProseMirror .tableWrapper,
:root.dark .markdown-content .tableWrapper {
border-color: #334155;
background: #0f172a;
}
:root.dark .ProseMirror th,
:root.dark .ProseMirror td,
:root.dark .markdown-content th,
:root.dark .markdown-content td {
border-color: #334155;
}
:root.dark .ProseMirror tbody tr:nth-child(even) td,
:root.dark .markdown-content tbody tr:nth-child(even) td {
background: #182235;
}
:root.dark .ProseMirror tbody tr:hover td,
:root.dark .markdown-content tbody tr:hover td {
background: #202b3d;
}
/* Theme-aware surfaces for legacy white utilities and fixed semantic colors. */
:root.dark [class~="bg-white"] {
background-color: rgb(var(--dark-surface-rgb)) !important;
@@ -237,6 +237,12 @@ input {
color: #334155;
}
.mobile-editor-table-glyph {
font-size: 12px;
font-weight: 800;
letter-spacing: -0.03em;
}
.mobile-editor-sheet-backdrop {
position: fixed;
inset: 0;
@@ -448,6 +454,109 @@ input {
border-radius: 8px;
}
.edgeever-mobile-tiptap-content .tableWrapper {
width: fit-content;
max-width: 100%;
overflow-x: auto;
margin-top: 1.25rem;
margin-right: auto;
margin-bottom: 1.25rem;
margin-left: auto;
border: 1px solid #d8d8d8;
border-radius: 2px;
background: #fff;
overscroll-behavior-inline: contain;
scrollbar-color: rgb(100 116 139 / 0.28) transparent;
}
.edgeever-mobile-tiptap-content table {
width: 42rem;
min-width: 42rem !important;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.edgeever-mobile-tiptap-content table col {
width: auto !important;
}
.edgeever-mobile-tiptap-content th,
.edgeever-mobile-tiptap-content td {
position: relative;
min-width: 7.5rem;
border: 0;
border-right: 1px solid #dedede;
border-bottom: 1px solid #dedede;
padding: 0.42rem 0.75rem;
text-align: left;
vertical-align: top;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.4;
transition: background-color 120ms ease;
}
.edgeever-mobile-tiptap-content th {
background: #f0f0f0;
color: #111827;
font-size: 0.875rem;
font-weight: 700;
}
.edgeever-mobile-tiptap-content th:last-child,
.edgeever-mobile-tiptap-content td:last-child {
border-right: 0;
}
.edgeever-mobile-tiptap-content tr:last-child td {
border-bottom: 0;
}
.edgeever-mobile-tiptap-content tbody tr:nth-child(even) td {
background: #f8f8f8;
}
.edgeever-mobile-tiptap-content tbody tr:hover td {
background: #f3f4f6;
}
.edgeever-mobile-tiptap-content th p,
.edgeever-mobile-tiptap-content td p {
margin: 0;
}
.edgeever-mobile-tiptap-content .selectedCell::after {
position: absolute;
inset: 0;
content: "";
pointer-events: none;
background: rgb(16 185 129 / 0.14);
}
:root.dark .edgeever-mobile-tiptap-content th {
background: #27303f;
color: #f8fafc;
}
:root.dark .edgeever-mobile-tiptap-content .tableWrapper {
border-color: #334155;
background: #0f172a;
}
:root.dark .edgeever-mobile-tiptap-content th,
:root.dark .edgeever-mobile-tiptap-content td {
border-color: #334155;
}
:root.dark .edgeever-mobile-tiptap-content tbody tr:nth-child(even) td {
background: #182235;
}
:root.dark .edgeever-mobile-tiptap-content tbody tr:hover td {
background: #202b3d;
}
.edgeever-mobile-tiptap-content p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
+7 -2
View File
@@ -20,6 +20,7 @@
"@tanstack/react-query": "latest",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-placeholder": "latest",
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "latest",
"@tiptap/starter-kit": "latest",
"class-variance-authority": "latest",
@@ -91,6 +92,7 @@
"@tanstack/react-query-persist-client": "^5.101.2",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"expo": "~57.0.7",
@@ -175,6 +177,7 @@
"version": "0.1.3",
"dependencies": {
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/markdown": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
},
@@ -1095,6 +1098,8 @@
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q=="],
"@tiptap/extension-table": ["@tiptap/extension-table@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-tNB8kjxo0+XPremnWkd6NUikpeJ+JQrss7HntL8GgIxX4t0gkdnLn9yUWb0JzcaYP7Y8iTZZHdkPs0P154DG8Q=="],
"@tiptap/extension-text": ["@tiptap/extension-text@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ=="],
@@ -3155,7 +3160,7 @@
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@edgeever/mobile/@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="],
"@edgeever/mobile/@tanstack/react-query": ["@tanstack/react-query@5.101.3", "", { "dependencies": { "@tanstack/query-core": "5.101.3" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-ZjgcRwmQUSCythDPj6pE2NKFi9bpQegxIBxgt3hPytXI1ElFHNaa3A7aWW8vFaLCIRjPv467rbREYHbxcdgQbg=="],
"@edgeever/mobile/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
@@ -3507,7 +3512,7 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@edgeever/mobile/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="],
"@edgeever/mobile/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.101.3", "", {}, "sha512-pWLyu7AjFFVM5G+frjcL81kKEW9R8GCCPKnL3uaWbMwd2f3ZINFCuy+PtKkdz/+pKw/f/XMsFsYq/H+uJHM4GQ=="],
"@edgeever/site/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
+1
View File
@@ -81,6 +81,7 @@
"@tanstack/react-query": "latest",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-placeholder": "latest",
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "latest",
"@tiptap/starter-kit": "latest",
"class-variance-authority": "latest",
+1
View File
@@ -13,6 +13,7 @@
"types": "./src/index.ts",
"dependencies": {
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/markdown": "3.27.1",
"@tiptap/starter-kit": "3.27.1"
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { docToMarkdown, markdownToDoc, resolveMemoContentDoc } from "./content.ts";
describe("Markdown table conversion", () => {
const markdown = [
"| Name | Status |",
"| --- | --- |",
"| Editor | Ready |",
"| Mobile | Planned |",
].join("\n");
test("parses a GFM table into TipTap nodes", () => {
const doc = markdownToDoc(markdown);
expect(doc.content).toHaveLength(1);
expect(doc.content[0]?.type).toBe("table");
expect(doc.content[0]?.content?.[0]?.type).toBe("tableRow");
expect(doc.content[0]?.content?.[0]?.content?.[0]?.type).toBe("tableHeader");
expect(doc.content[0]?.content?.[1]?.content?.[0]?.type).toBe("tableCell");
});
test("preserves table values through a Markdown round trip", () => {
const serialized = docToMarkdown(markdownToDoc(markdown));
const reparsed = markdownToDoc(serialized);
expect(serialized).toContain("| Name");
expect(serialized).toContain("| Editor");
expect(reparsed).toEqual(markdownToDoc(markdown));
});
test("recovers a table omitted by an older JSON schema", () => {
const legacyDoc = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "Legacy note" }] }],
};
expect(resolveMemoContentDoc(legacyDoc, markdown).content[0]?.type).toBe("table");
expect(resolveMemoContentDoc(legacyDoc, "Legacy note")).toBe(legacyDoc);
});
});
+36 -1
View File
@@ -1,4 +1,5 @@
import Image from "@tiptap/extension-image";
import { TableKit } from "@tiptap/extension-table";
import { Markdown, MarkdownManager } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
@@ -32,7 +33,14 @@ export const emptyDoc = (): TiptapDoc => ({
});
const markdownManager = new MarkdownManager({
extensions: [StarterKit, Image, Markdown],
extensions: [
StarterKit,
TableKit,
Image,
Markdown.configure({
markedOptions: { gfm: true },
}),
],
});
export const markdownToDoc = (markdown: string): TiptapDoc => {
@@ -43,6 +51,33 @@ export const markdownToDoc = (markdown: string): TiptapDoc => {
return markdownManager.parse(markdown.replace(/\r\n?/g, "\n")) as TiptapDoc;
};
const docContainsNodeType = (doc: TiptapDoc, nodeType: string): boolean => {
const visit = (nodes: TiptapNode[]): boolean => nodes.some((node) =>
node.type === nodeType || (node.content ? visit(node.content as TiptapNode[]) : false)
);
return visit(doc.content);
};
/**
* Recovers Markdown features that an older editor schema could not persist in
* contentJson. The stored Markdown remains the compatibility source in that
* case; otherwise the richer JSON document (for example image sizing attrs)
* keeps precedence.
*/
export const resolveMemoContentDoc = (
contentJson: TiptapDoc | null | undefined,
contentMarkdown: string | null | undefined
): TiptapDoc => {
const currentDoc = contentJson && Array.isArray(contentJson.content) ? contentJson : emptyDoc();
if (!contentMarkdown?.trim() || docContainsNodeType(currentDoc, "table")) {
return currentDoc;
}
const markdownDoc = markdownToDoc(contentMarkdown);
return docContainsNodeType(markdownDoc, "table") ? markdownDoc : currentDoc;
};
export const docToText = (doc: unknown): string => {
const pieces: string[] = [];
+8
View File
@@ -513,6 +513,14 @@ export const enUS = {
codeLanguage: "Code language",
plainText: "Plain text",
horizontalRule: "Divider",
table: "Table",
insertTable: "Insert 3 × 3 table",
addTableRow: "Add row below",
deleteTableRow: "Delete current row",
addTableColumn: "Add column right",
deleteTableColumn: "Delete current column",
toggleTableHeader: "Toggle header row",
deleteTable: "Delete table",
markdown: "Switch to Markdown source",
richText: "Switch to rich text",
markdownSource: "Markdown source",
+8
View File
@@ -513,6 +513,14 @@ export const zhCN = {
codeLanguage: "代码语言",
plainText: "纯文本",
horizontalRule: "分割线",
table: "表格",
insertTable: "插入 3 × 3 表格",
addTableRow: "在下方添加行",
deleteTableRow: "删除当前行",
addTableColumn: "在右侧添加列",
deleteTableColumn: "删除当前列",
toggleTableHeader: "切换表头行",
deleteTable: "删除表格",
markdown: "切换到 Markdown 源码",
richText: "切换到富文本编辑",
markdownSource: "Markdown 源码",
@@ -18,6 +18,13 @@ describe("mobile editor contract", () => {
"bulletList",
"blockquote",
"horizontalRule",
"insertTable",
"addTableRow",
"deleteTableRow",
"addTableColumn",
"deleteTableColumn",
"toggleTableHeader",
"deleteTable",
]);
expect(MOBILE_EDITOR_TOOLBAR_ACTIONS.find(({ id }) => id === "bold")?.activeFlag).toBe(
MOBILE_EDITOR_ACTIVE_FLAGS.bold
@@ -29,6 +36,7 @@ describe("mobile editor contract", () => {
expect(getMobileEditorPlaceholder("en-US")).toBe("Start writing...");
expect(getMobileEditorToolbarLabel("zh-CN")).toBe("编辑器工具栏");
expect(getMobileEditorToolbarActionLabel("bulletList", "en-US")).toBe("Bullet list");
expect(getMobileEditorToolbarActionLabel("insertTable", "zh-CN")).toBe("插入表格");
expect(getMobileEditorImageScaleLabel("zh-CN")).toBe("图片显示尺寸");
expect(getMobileEditorImageWidthPresetLabel("medium", "en-US")).toBe("Medium");
});
+39 -6
View File
@@ -7,23 +7,42 @@ export type MobileEditorToolbarActionId =
| "bold"
| "bulletList"
| "blockquote"
| "horizontalRule";
| "horizontalRule"
| MobileEditorTableActionId;
export type MobileEditorTableActionId =
| "insertTable"
| "addTableRow"
| "deleteTableRow"
| "addTableColumn"
| "deleteTableColumn"
| "toggleTableHeader"
| "deleteTable";
export const MOBILE_EDITOR_ACTIVE_FLAGS = {
bold: 1,
bulletList: 8,
blockquote: 16,
table: 32,
} as const;
export const MOBILE_EDITOR_TOOLBAR_ACTIONS = [
{ id: "image", activeFlag: 0 },
{ id: "bold", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.bold },
{ id: "bulletList", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.bulletList },
{ id: "blockquote", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.blockquote },
{ id: "horizontalRule", activeFlag: 0 },
{ id: "image", activeFlag: 0, requiresTable: false },
{ id: "bold", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.bold, requiresTable: false },
{ id: "bulletList", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.bulletList, requiresTable: false },
{ id: "blockquote", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.blockquote, requiresTable: false },
{ id: "horizontalRule", activeFlag: 0, requiresTable: false },
{ id: "insertTable", activeFlag: 0, requiresTable: false },
{ id: "addTableRow", activeFlag: 0, requiresTable: true },
{ id: "deleteTableRow", activeFlag: 0, requiresTable: true },
{ id: "addTableColumn", activeFlag: 0, requiresTable: true },
{ id: "deleteTableColumn", activeFlag: 0, requiresTable: true },
{ id: "toggleTableHeader", activeFlag: 0, requiresTable: true },
{ id: "deleteTable", activeFlag: 0, requiresTable: true },
] as const satisfies ReadonlyArray<{
id: MobileEditorToolbarActionId;
activeFlag: number;
requiresTable: boolean;
}>;
const MOBILE_EDITOR_COPY = {
@@ -36,6 +55,13 @@ const MOBILE_EDITOR_COPY = {
bulletList: "无序列表",
blockquote: "引用",
horizontalRule: "分割线",
insertTable: "插入表格",
addTableRow: "在下方添加行",
deleteTableRow: "删除当前行",
addTableColumn: "在右侧添加列",
deleteTableColumn: "删除当前列",
toggleTableHeader: "切换表头行",
deleteTable: "删除表格",
},
imageScale: "图片显示尺寸",
imageSizes: {
@@ -54,6 +80,13 @@ const MOBILE_EDITOR_COPY = {
bulletList: "Bullet list",
blockquote: "Quote",
horizontalRule: "Horizontal rule",
insertTable: "Insert table",
addTableRow: "Add row below",
deleteTableRow: "Delete current row",
addTableColumn: "Add column right",
deleteTableColumn: "Delete current column",
toggleTableHeader: "Toggle header row",
deleteTable: "Delete table",
},
imageScale: "Image display size",
imageSizes: {