Skip to content

Commit b7a9785

Browse files
DavidMorganDavidMorgan
authored andcommitted
fix: stabilize playground slash menu and content stats
1 parent 353fd0a commit b7a9785

File tree

11 files changed

+356
-30
lines changed

11 files changed

+356
-30
lines changed

apps/playground/src/App.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,18 @@ function RouteIcon({ routeId }: { routeId: PlaygroundRouteId }) {
3939
export function App() {
4040
const [theme, setTheme] = useState<PlaygroundTheme>("dark");
4141
const { route, routes, navigate } = usePlaygroundRoute();
42+
const pagesRouteProps = {
43+
interactive: true,
44+
showContentStats: true,
45+
} as const;
4246

4347
const renderRoute = () => {
4448
if (route.id === "comments") {
4549
return <CommentsSection />;
4650
}
4751

4852
if (route.id === "pages") {
49-
return <PagesSection />;
53+
return <PagesSection {...pagesRouteProps} />;
5054
}
5155

5256
if (route.id === "collaboration") {
@@ -58,7 +62,7 @@ export function App() {
5862

5963
return (
6064
<main
61-
className="app-shell relative min-h-screen overflow-hidden px-3 py-4 sm:px-6 sm:py-6"
65+
className="app-shell relative min-h-screen overflow-x-hidden overflow-y-auto px-3 py-4 sm:px-6 sm:py-6"
6266
data-theme={theme}
6367
>
6468
<div className="pointer-events-none absolute inset-0 overflow-hidden">

apps/playground/src/playground/components/CollaborationSection.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState } from "react";
22
import type { Editor } from "@mxm-editor/core";
33
import type { CollaborationCaretStorage } from "@mxm-editor/extension-collaboration-caret";
44
import { EditorContent, useEditorState } from "@mxm-editor/react";
5+
import { SlashFloatingMenu } from "./SlashFloatingMenu";
56
import { useCollaborationPlayground } from "../hooks/useCollaborationPlayground";
67

78
interface CollaborationEditorPanelProps {
@@ -47,6 +48,7 @@ function CollaborationEditorPanel({
4748
<div className="collaboration-panel">
4849
<div className="collaboration-panel__label">{label}</div>
4950
<PresenceChips editor={editor} />
51+
<SlashFloatingMenu editor={editor} />
5052
<EditorContent
5153
editor={editor}
5254
className="editor-surface editor-surface--collaboration"

apps/playground/src/playground/components/LocalEditorSection.tsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import type { MouseEventHandler } from "react";
2-
import type { CharacterCountStorage } from "@mxm-editor/extension-character-count";
1+
import {
2+
useCallback,
3+
useRef,
4+
type MouseEventHandler,
5+
} from "react";
36
import {
47
AlignCenter,
58
AlignLeft,
@@ -37,6 +40,11 @@ import {
3740
bubbleMenuShouldShow,
3841
floatingMenuShouldShow,
3942
} from "../constants";
43+
import {
44+
SlashFloatingMenu,
45+
useSlashSuggestionState,
46+
} from "./SlashFloatingMenu";
47+
import { useContentStats } from "../hooks/useContentStats";
4048
import { useLocalPlayground } from "../hooks/useLocalPlayground";
4149

4250
interface LocalEditorPanelProps {
@@ -307,18 +315,7 @@ function EditorToolbar({
307315
function EditorFooter({
308316
resetTemplate,
309317
}: Pick<LocalEditorPanelProps, "resetTemplate">) {
310-
const meta = useEditorState({
311-
selector: ({ editor }) => {
312-
const characterCountStorage = editor?.storage.characterCount as
313-
| CharacterCountStorage
314-
| undefined;
315-
316-
return {
317-
characters: characterCountStorage?.characters() ?? 0,
318-
words: characterCountStorage?.words() ?? 0,
319-
};
320-
},
321-
});
318+
const meta = useContentStats();
322319

323320
return (
324321
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--panel-border)] px-4 py-3 text-xs text-[var(--muted-text)]">
@@ -418,6 +415,15 @@ function EmptyLineFloatingMenu({
418415
insertImage,
419416
}: Pick<LocalEditorPanelProps, "insertImage">) {
420417
const { editor } = useCurrentEditor();
418+
const slashState = useSlashSuggestionState(editor);
419+
const slashStateRef = useRef(slashState);
420+
421+
slashStateRef.current = slashState;
422+
const shouldShow = useCallback(
423+
(props: Parameters<typeof floatingMenuShouldShow>[0]) =>
424+
!slashStateRef.current.active && floatingMenuShouldShow(props),
425+
[],
426+
);
421427

422428
if (!editor) {
423429
return null;
@@ -427,7 +433,8 @@ function EmptyLineFloatingMenu({
427433
<FloatingMenu
428434
className="floating-menu"
429435
editor={editor}
430-
shouldShow={floatingMenuShouldShow}
436+
pluginKey="emptyLineFloatingMenu"
437+
shouldShow={shouldShow}
431438
>
432439
<div className="ui-toolbar-group">
433440
<ToolbarIconButton
@@ -473,6 +480,7 @@ function LocalEditorPanel({
473480
setLink={setLink}
474481
/>
475482
<SelectionBubbleMenu setLink={setLink} />
483+
<SlashFloatingMenu editor={editor} />
476484
<EmptyLineFloatingMenu insertImage={insertImage} />
477485
<EditorContent
478486
className="editor-surface min-h-0 flex-1 overscroll-contain"

apps/playground/src/playground/components/PagesSection.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import {
1919
} from "lucide-react";
2020
import { pagesDemoContent } from "../constants";
2121
import { createPlaygroundExtensions } from "../extensions";
22+
import { useContentStats } from "../hooks/useContentStats";
23+
24+
interface PagesSectionProps {
25+
interactive?: boolean;
26+
showContentStats?: boolean;
27+
}
2228

2329
type PagesPresetId = "editorial" | "briefing";
2430

@@ -226,7 +232,10 @@ function applyPaperFormat(
226232
editor.commands.repaginate();
227233
}
228234

229-
export function PagesSection() {
235+
export function PagesSection({
236+
interactive = false,
237+
showContentStats = false,
238+
}: PagesSectionProps) {
230239
const initialPreset = getPresetById("editorial");
231240
const [activePresetId, setActivePresetId] = useState<PagesPresetId>(initialPreset.id);
232241
const [selectedFormat, setSelectedFormat] = useState<PagesFormatName>(initialPreset.formatName);
@@ -243,7 +252,7 @@ export function PagesSection() {
243252
const editor = useEditor({
244253
extensions: [
245254
...createPlaygroundExtensions({
246-
interactive: false,
255+
interactive,
247256
}),
248257
Pages.configure({
249258
pageFormat: initialPreset.pageFormat,
@@ -297,6 +306,7 @@ export function PagesSection() {
297306
};
298307
},
299308
});
309+
const contentStats = useContentStats(editor);
300310

301311
if (!editor) {
302312
return null;
@@ -407,6 +417,22 @@ export function PagesSection() {
407417
<strong>{roundPixels(pagesMeta.metrics.availableContentHeight)}px</strong>
408418
<p>可用于正文的纵向空间。</p>
409419
</article>
420+
421+
{showContentStats ? (
422+
<article className="pages-stat-card">
423+
<span>词数</span>
424+
<strong>{contentStats.words}</strong>
425+
<p>基于当前文档正文实时统计。</p>
426+
</article>
427+
) : null}
428+
429+
{showContentStats ? (
430+
<article className="pages-stat-card">
431+
<span>字符</span>
432+
<strong>{contentStats.characters}</strong>
433+
<p>用于观察长文编辑时的内容体量。</p>
434+
</article>
435+
) : null}
410436
</div>
411437
</section>
412438

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
useCallback,
3+
useRef,
4+
type MouseEventHandler,
5+
} from "react";
6+
import type { Editor } from "@mxm-editor/core";
7+
import {
8+
FloatingMenu,
9+
useEditorState,
10+
} from "@mxm-editor/react";
11+
import type { SuggestionState } from "@mxm-editor/suggestion";
12+
import {
13+
executeSlashItem,
14+
filterSlashItems,
15+
slashPluginKey,
16+
type SlashItem,
17+
} from "../extensions";
18+
19+
const preventMouseDown: MouseEventHandler<HTMLButtonElement> = (event) => {
20+
event.preventDefault();
21+
};
22+
23+
const slashFloatingMenuOptions = {
24+
offset: 12,
25+
flip: true,
26+
shift: true,
27+
} as const;
28+
29+
export function useSlashSuggestionState(editor: Editor | null | undefined) {
30+
return useEditorState({
31+
editor,
32+
selector: ({ editor: currentEditor }) => {
33+
const suggestionState = currentEditor
34+
? (slashPluginKey.getState(currentEditor.state) as SuggestionState | undefined)
35+
: undefined;
36+
37+
return {
38+
active: suggestionState?.active ?? false,
39+
query: suggestionState?.query ?? "",
40+
range: suggestionState?.range ?? {
41+
from: 0,
42+
to: 0,
43+
},
44+
viewReady: Boolean(currentEditor?.view),
45+
};
46+
},
47+
});
48+
}
49+
50+
export function SlashFloatingMenu({ editor }: { editor: Editor | null }) {
51+
const slashState = useSlashSuggestionState(editor);
52+
const slashStateRef = useRef(slashState);
53+
54+
slashStateRef.current = slashState;
55+
56+
const shouldShow = useCallback(() => slashStateRef.current.active, []);
57+
58+
if (!editor || !slashState.viewReady) {
59+
return null;
60+
}
61+
62+
const items = slashState.active
63+
? filterSlashItems(slashState.query)
64+
: ([] as SlashItem[]);
65+
66+
return (
67+
<FloatingMenu
68+
className="floating-menu slash-floating-menu"
69+
editor={editor}
70+
options={slashFloatingMenuOptions}
71+
pluginKey="slashFloatingMenu"
72+
shouldShow={shouldShow}
73+
>
74+
{items.length ? (
75+
items.map((item, index) => (
76+
<button
77+
key={item.id}
78+
className={`slash-item${index === 0 ? " is-active" : ""}`}
79+
onMouseDown={preventMouseDown}
80+
onClick={() => {
81+
executeSlashItem(editor, slashState.range, item);
82+
}}
83+
type="button"
84+
>
85+
<strong>{item.label}</strong>
86+
<span>{item.description}</span>
87+
</button>
88+
))
89+
) : (
90+
<div className="slash-empty">没有匹配的命令</div>
91+
)}
92+
</FloatingMenu>
93+
);
94+
}

apps/playground/src/playground/extensions.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,16 @@ interface PlaygroundExtensionOptions {
5757
interactive?: boolean;
5858
}
5959

60-
interface SlashItem {
60+
export interface SlashItem {
6161
id: string;
6262
label: string;
6363
description: string;
6464
keywords: string[];
6565
execute: (editor: Editor) => void;
6666
}
6767

68+
export { slashPluginKey };
69+
6870
function filterMentionItems(query: string) {
6971
const normalizedQuery = query.trim().toLowerCase();
7072

@@ -366,7 +368,7 @@ const slashItems: SlashItem[] = [
366368
},
367369
];
368370

369-
function filterSlashItems(query: string) {
371+
export function filterSlashItems(query: string) {
370372
const normalizedQuery = query.trim().toLowerCase();
371373

372374
if (!normalizedQuery) {
@@ -380,6 +382,20 @@ function filterSlashItems(query: string) {
380382
);
381383
}
382384

385+
export function executeSlashItem(
386+
editor: Editor,
387+
range: {
388+
from: number;
389+
to: number;
390+
},
391+
item: SlashItem,
392+
) {
393+
editor.view?.dispatch(
394+
editor.view.state.tr.delete(range.from, range.to).scrollIntoView(),
395+
);
396+
item.execute(editor);
397+
}
398+
383399
function createSlashRenderer() {
384400
let popup: HTMLDivElement | null = null;
385401
let currentProps: SuggestionProps<SlashItem, SlashItem> | null = null;
@@ -531,7 +547,6 @@ function createSlashCommand() {
531547
startOfLine: true,
532548
allowedPrefixes: null,
533549
items: ({ query }) => filterSlashItems(query),
534-
render: createSlashRenderer,
535550
decorationTag: "span",
536551
decorationClass: "slash-suggestion",
537552
decorationEmptyClass: "is-empty",
@@ -544,10 +559,7 @@ function createSlashCommand() {
544559
);
545560
},
546561
command: ({ editor, range, props }) => {
547-
editor.view?.dispatch(
548-
editor.view.state.tr.delete(range.from, range.to).scrollIntoView(),
549-
);
550-
props.execute(editor);
562+
executeSlashItem(editor, range, props);
551563
},
552564
}),
553565
];
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Editor } from "@mxm-editor/core";
2+
import { useEditorState } from "@mxm-editor/react";
3+
4+
function getWordCount(text: string) {
5+
return text
6+
.trim()
7+
.split(/\s+/)
8+
.filter(Boolean)
9+
.length;
10+
}
11+
12+
export function useContentStats(editor?: Editor | null) {
13+
return useEditorState({
14+
editor,
15+
selector: ({ editor: currentEditor }) => {
16+
const characters = currentEditor?.state.doc.textContent.length ?? 0;
17+
const text = currentEditor?.getText({
18+
blockSeparator: " ",
19+
}) ?? "";
20+
21+
return {
22+
characters,
23+
words: getWordCount(text),
24+
};
25+
},
26+
});
27+
}

0 commit comments

Comments
 (0)