feat: add table editing
This commit is contained in:
1 parent
76e4874e3a
commit
64c992f315
21 files changed
+648
-35
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user