@multiplatform.one/rich-text 7.6.3 → 7.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/cjs/RichEditor.native.js +20 -0
  2. package/dist/cjs/RichEditor.native.js.map +1 -1
  3. package/dist/cjs/editor/TentapEditor.native.js +18 -0
  4. package/dist/cjs/editor/TentapEditor.native.js.map +1 -1
  5. package/dist/esm/RichEditor.native.js +21 -1
  6. package/dist/esm/RichEditor.native.js.map +1 -1
  7. package/dist/esm/editor/TentapEditor.native.js +18 -0
  8. package/dist/esm/editor/TentapEditor.native.js.map +1 -1
  9. package/dist/jsx/RichEditor.native.js +20 -0
  10. package/dist/jsx/RichEditor.native.js.map +1 -1
  11. package/dist/jsx/editor/TentapEditor.native.js +18 -0
  12. package/dist/jsx/editor/TentapEditor.native.js.map +1 -1
  13. package/package.json +8 -9
  14. package/src/RichEditor.native.tsx +19 -1
  15. package/src/RichEditor.parts.stories.tsx +139 -0
  16. package/src/editor/SlashCommands.spec.tsx +210 -0
  17. package/src/editor/TentapEditor.native.tsx +35 -0
  18. package/src/richEditorPartsStories.spec.tsx +150 -0
  19. package/src/toolbar/CodeBlockBubble.spec.tsx +219 -0
  20. package/src/toolbar/FixedToolbar.stories.tsx +224 -0
  21. package/src/toolbar/ImageBubble.spec.tsx +140 -0
  22. package/src/toolbar/LinkBubble.spec.tsx +159 -0
  23. package/src/toolbar/ResizeHandle.spec.tsx +118 -0
  24. package/src/toolbar/StatusBar.spec.tsx +100 -0
  25. package/src/toolbar/StatusBar.stories.tsx +146 -0
  26. package/src/toolbar/TableBubble.spec.tsx +189 -0
  27. package/src/toolbar/ToolbarButton.stories.tsx +193 -0
  28. package/src/toolbar/ToolbarGroup.stories.tsx +148 -0
  29. package/src/toolbar/fixedToolbarStories.spec.tsx +200 -0
  30. package/src/toolbar/statusBarStories.spec.tsx +86 -0
  31. package/src/toolbar/toolbarButtonStories.spec.tsx +122 -0
  32. package/src/toolbar/toolbarGroupStories.spec.tsx +108 -0
  33. package/types/RichEditor.native.d.ts.map +1 -1
  34. package/types/editor/TentapEditor.native.d.ts +4 -0
  35. package/types/editor/TentapEditor.native.d.ts.map +1 -1
@@ -42,6 +42,7 @@ import { RichEditorContext } from "../context";
42
42
  import { PALETTE_THEME_TOKENS } from "../constants";
43
43
  import { t } from "../shared/t";
44
44
  import { getEditorStateFromBridge } from "./useEditorState";
45
+ import { getEditorCSS } from "./editorStyles";
45
46
  import { htmlToMarkdown, markdownToHtml } from "./markdown";
46
47
 
47
48
  function useNativeEditorDarkMode(): boolean {
@@ -60,6 +61,10 @@ function useNativeEditorDarkMode(): boolean {
60
61
  export interface TentapEditorProps extends RichEditorProps {
61
62
  /** Fill the available space (fullscreen modal) instead of using minHeight */
62
63
  fill?: boolean;
64
+ /** CSS font-family string for body text (resolved from knobs by RichEditor) */
65
+ bodyFontFamily?: string;
66
+ /** CSS font-family string for headings (resolved from knobs by RichEditor) */
67
+ headingFontFamily?: string;
63
68
  }
64
69
 
65
70
  // Avoid `forwardRef<A, B>(...)` — Metro/Babel TSX can parse the `>` as JSX and
@@ -77,6 +82,8 @@ export const TentapEditor = forwardRef(
77
82
  minHeight = 150,
78
83
  maxHeight,
79
84
  fill = false,
85
+ bodyFontFamily,
86
+ headingFontFamily,
80
87
  hasError: _hasError,
81
88
  }: TentapEditorProps,
82
89
  ref: ForwardedRef<RichEditorRef>,
@@ -148,6 +155,34 @@ export const TentapEditor = forwardRef(
148
155
  return () => clearTimeout(id);
149
156
  }, [editor, theme]);
150
157
 
158
+ // The content rules: padding, the $body / $heading families, block flow
159
+ // for lists and quotes, code and image chrome. Web gets them from
160
+ // getEditorCSS() through a <style> tag; the 10Tap WebView is a separate
161
+ // document with no Tamagui stylesheet, so with nothing injected it renders
162
+ // WebKit's defaults, which is zero padding and the UA serif (MPO-187, Clay's
163
+ // screenshot: "Hello world" at x=0 in Times while the toolbar is the system
164
+ // sans). One source: the same function the web editor uses, so a fix on
165
+ // either side lands on both. motionMs 0 because there is no bubble menu on
166
+ // native, and the palette block above already defines every var(--colorN)
167
+ // this CSS reads. `.ProseMirror` outranks darkEditorCss's `* { color }`, so
168
+ // the foreground also moves onto the theme's --color12 in both schemes.
169
+ useEffect(() => {
170
+ const id = setTimeout(() => {
171
+ editor.injectCSS(
172
+ getEditorCSS({
173
+ minHeight,
174
+ maxHeight,
175
+ bodyFontFamily,
176
+ headingFontFamily,
177
+ fullscreen: fill,
178
+ motionMs: 0,
179
+ }),
180
+ "multiplatform-tentap-content",
181
+ );
182
+ }, 150);
183
+ return () => clearTimeout(id);
184
+ }, [editor, minHeight, maxHeight, bodyFontFamily, headingFontFamily, fill]);
185
+
151
186
  // Subscribe to editor state updates
152
187
  useEffect(() => {
153
188
  const unsubscribe = editor._subscribeToEditorStateUpdate((state: any) => {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Mounts every RichEditor shell story with its args and asserts the visible
3
+ * contract each one exists to show, so a story that stops rendering what its
4
+ * name promises fails here before the gallery ships it.
5
+ *
6
+ * ProseMirror needs a browser layout engine happy-dom does not have, and the
7
+ * catalog Button is heavy under it, so both are stood in as the toolbar specs
8
+ * next door do. The theme hooks stay REAL: the toolbar's pickers render a
9
+ * FloatingPanel, which reads knobs richEditor.spec.tsx's `useResolvedKnobs`
10
+ * stub does not carry ("Cannot destructure property 'style' of
11
+ * 'panel.knobProps.elevatedSurface'"). What survives the swaps — which chrome
12
+ * the shell renders for a given set of props — is exactly what these stories
13
+ * are about.
14
+ */
15
+ import { describe, expect, it, vi } from "vitest";
16
+
17
+ vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
18
+ ...(await importOriginal<Record<string, unknown>>()),
19
+ Button: (props: any) => (
20
+ <button
21
+ role="button"
22
+ aria-label={props["aria-label"]}
23
+ aria-disabled={props["aria-disabled"]}
24
+ onClick={props.onPress}
25
+ >
26
+ {props.children}
27
+ </button>
28
+ ),
29
+ }));
30
+
31
+ vi.mock("@multiplatform.one/components", async (importOriginal) => ({
32
+ ...(await importOriginal<Record<string, unknown>>()),
33
+ Tooltip: ({ children }: any) => children,
34
+ }));
35
+
36
+ const mockEditor = {
37
+ isActive: vi.fn().mockReturnValue(false),
38
+ getAttributes: vi.fn().mockReturnValue({}),
39
+ can: vi.fn().mockReturnValue({ undo: () => false, redo: () => false }),
40
+ getHTML: vi.fn().mockReturnValue("<p></p>"),
41
+ getJSON: vi.fn().mockReturnValue({ type: "doc", content: [] }),
42
+ chain: vi.fn().mockReturnValue({
43
+ focus: vi.fn().mockReturnValue({ toggleBold: vi.fn().mockReturnValue({ run: vi.fn() }) }),
44
+ }),
45
+ commands: { setContent: vi.fn(), focus: vi.fn(), blur: vi.fn() },
46
+ setEditable: vi.fn(),
47
+ storage: { mediaUpload: { handlers: {} }, suggestionChrome: { value: null } },
48
+ };
49
+
50
+ vi.mock("@tiptap/react", () => ({
51
+ useEditor: vi.fn().mockReturnValue(mockEditor),
52
+ EditorContent: () => <div data-testid="editor-content">Editor</div>,
53
+ }));
54
+ vi.mock("@tiptap/react/menus", () => ({
55
+ BubbleMenu: ({ children }: any) => <div data-testid="bubble-menu">{children}</div>,
56
+ }));
57
+
58
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
59
+
60
+ const storiesModule = await import("./RichEditor.parts.stories");
61
+ const meta = storiesModule.default;
62
+ const { Main, NoStatusBar, NotResizable, MarkdownCapabilities, ReadOnly, WithError, Compact } =
63
+ storiesModule;
64
+
65
+ type StoryLike = { args?: Record<string, unknown> };
66
+
67
+ /** Resolve a story the way Storybook does: meta args over story args. */
68
+ function mount(story: StoryLike) {
69
+ const args = { ...(meta.args as Record<string, unknown>), ...story.args };
70
+ const Component = meta.component as React.ComponentType<Record<string, unknown>>;
71
+ return renderWithProviders(<Component {...args} />);
72
+ }
73
+
74
+ const stories = {
75
+ Main,
76
+ NoStatusBar,
77
+ NotResizable,
78
+ MarkdownCapabilities,
79
+ ReadOnly,
80
+ WithError,
81
+ Compact,
82
+ } as const;
83
+
84
+ const labelsIn = (container: HTMLElement) =>
85
+ Array.from(container.querySelectorAll("[role='button']")).map((b) =>
86
+ b.getAttribute("aria-label"),
87
+ );
88
+
89
+ describe("RichEditor shell stories", () => {
90
+ it("nests under the editor title so the gallery buckets it with RichTextEditor", () => {
91
+ expect(meta.title).toBe("Forms/RichTextEditor/RichEditor");
92
+ });
93
+
94
+ it("is a separate title from the form field's, so neither shadows the other", () => {
95
+ expect(meta.title).not.toBe("Forms/RichTextEditor");
96
+ });
97
+
98
+ it.each(Object.entries(stories))("%s mounts the editor content", (_name, story) => {
99
+ const { container } = mount(story);
100
+ expect(container.querySelector("[data-testid='editor-content']")).toBeTruthy();
101
+ });
102
+
103
+ it("Main renders the toolbar AND the status bar", () => {
104
+ const { container, getByText } = mount(Main);
105
+ expect(container.querySelector("[role='toolbar']")).toBeTruthy();
106
+ expect(labelsIn(container)).toContain("Bold");
107
+ expect(getByText("Rich text")).toBeTruthy();
108
+ expect(getByText("0 words")).toBeTruthy();
109
+ });
110
+
111
+ it("NoStatusBar drops the footer, and with it the mode toggle", () => {
112
+ const { container, queryByText } = mount(NoStatusBar);
113
+ expect(queryByText("Rich text")).toBeNull();
114
+ expect(queryByText("0 words")).toBeNull();
115
+ // The toolbar is untouched — only the footer goes.
116
+ expect(container.querySelector("[role='toolbar']")).toBeTruthy();
117
+ });
118
+
119
+ it("ReadOnly hides the toolbar, since there is nothing to command", () => {
120
+ const { container } = mount(ReadOnly);
121
+ expect(container.querySelector("[role='toolbar']")).toBeNull();
122
+ expect(container.querySelector("[data-testid='editor-content']")).toBeTruthy();
123
+ });
124
+
125
+ it("MarkdownCapabilities drops the controls GFM cannot express", () => {
126
+ const { container } = mount(MarkdownCapabilities);
127
+ const labels = labelsIn(container);
128
+ expect(labels).toContain("Bold");
129
+ expect(labels).not.toContain("Underline");
130
+ expect(labels).not.toContain("Text Color");
131
+ });
132
+
133
+ it("WithError keeps the whole shell, since the error is the frame's colour", () => {
134
+ const { container, getByText } = mount(WithError);
135
+ expect(container.querySelector("[role='toolbar']")).toBeTruthy();
136
+ expect(getByText("Rich text")).toBeTruthy();
137
+ });
138
+
139
+ // One render per test: getByText is bound to the document, so two mounts in
140
+ // one test make every footer query ambiguous.
141
+ it.each([
142
+ ["NotResizable", NotResizable],
143
+ ["Compact", Compact],
144
+ ])("%s still renders toolbar, content and footer", (_name, story) => {
145
+ const { container, getByText } = mount(story);
146
+ expect(container.querySelector("[role='toolbar']")).toBeTruthy();
147
+ expect(container.querySelector("[data-testid='editor-content']")).toBeTruthy();
148
+ expect(getByText("Rich text")).toBeTruthy();
149
+ });
150
+ });
@@ -0,0 +1,219 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ import { act, fireEvent } from "@testing-library/react";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { RichEditorContext } from "../context";
5
+
6
+ const bubble = vi.hoisted(() => ({ props: null as Record<string, any> | null }));
7
+
8
+ vi.mock("@tiptap/react/menus", () => ({
9
+ BubbleMenu: (props: any) => {
10
+ bubble.props = props;
11
+ return <div data-testid="bubble-menu">{props.children}</div>;
12
+ },
13
+ }));
14
+
15
+ // The language list lives inside a Popover, which never mounts its content in
16
+ // a headless DOM. Flatten the popover so the list is inspectable.
17
+ vi.mock("tamagui", async (importOriginal) => {
18
+ const actual = await importOriginal<Record<string, any>>();
19
+ const Popover: any = ({ children }: any) => <>{children}</>;
20
+ Popover.Trigger = ({ children }: any) => <>{children}</>;
21
+ return { ...actual, Popover };
22
+ });
23
+
24
+ vi.mock("@multiplatform.one/components", async (importOriginal) => ({
25
+ ...(await importOriginal<Record<string, unknown>>()),
26
+ Tooltip: ({ children }: any) => children,
27
+ PopoverContent: ({ children }: any) => <div data-testid="language-menu">{children}</div>,
28
+ }));
29
+
30
+ vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
31
+ ...(await importOriginal<Record<string, unknown>>()),
32
+ Button: (props: any) => (
33
+ <button role="button" aria-label={props["aria-label"]} onClick={props.onPress}>
34
+ {props.children}
35
+ </button>
36
+ ),
37
+ }));
38
+
39
+ const { CodeBlockBubble } = await import("./CodeBlockBubble");
40
+
41
+ function makeEditor(
42
+ opts: { language?: string | null; code?: string; active?: boolean; attrs?: unknown } = {},
43
+ ) {
44
+ const { language = null, code = "const a = 1;", active = true } = opts;
45
+ const calls: Array<[string, ...unknown[]]> = [];
46
+ const chain: any = new Proxy(
47
+ {},
48
+ {
49
+ get:
50
+ (_target, prop: string) =>
51
+ (...args: unknown[]) => {
52
+ calls.push([prop, ...args]);
53
+ return chain;
54
+ },
55
+ },
56
+ );
57
+ const codeBlockNode = { type: { name: "codeBlock" }, textContent: code };
58
+ const docNode = { type: { name: "doc" }, textContent: "outer" };
59
+ const editor = {
60
+ chain: () => chain,
61
+ getAttributes: (name: string) =>
62
+ "attrs" in opts ? (opts.attrs as any) : name === "codeBlock" ? { language } : {},
63
+ isActive: (name: string) => name === "codeBlock" && active,
64
+ state: {
65
+ selection: { $from: { depth: 1, node: (d: number) => (d === 1 ? codeBlockNode : docNode) } },
66
+ },
67
+ } as any;
68
+ return { editor, calls, names: () => calls.map((c) => c[0]) };
69
+ }
70
+
71
+ function render(editor: any) {
72
+ return renderWithProviders(
73
+ <RichEditorContext.Provider size="$true" compact={false} disabled={false}>
74
+ <CodeBlockBubble editor={editor} />
75
+ </RichEditorContext.Provider>,
76
+ );
77
+ }
78
+
79
+ function labelOf(container: HTMLElement, selector: string) {
80
+ return container.querySelector(selector)?.getAttribute("aria-label");
81
+ }
82
+
83
+ function menuLabels(container: HTMLElement) {
84
+ const menu = container.querySelector('[data-testid="language-menu"]');
85
+ if (!menu) throw new Error("language menu did not render");
86
+ return Array.from(menu.querySelectorAll("[aria-label]")).map((el) =>
87
+ el.getAttribute("aria-label"),
88
+ );
89
+ }
90
+
91
+ describe("CodeBlockBubble", () => {
92
+ let writeText: ReturnType<typeof vi.fn>;
93
+
94
+ beforeEach(() => {
95
+ writeText = vi.fn(() => Promise.resolve());
96
+ Object.defineProperty(globalThis.navigator, "clipboard", {
97
+ value: { writeText },
98
+ configurable: true,
99
+ writable: true,
100
+ });
101
+ });
102
+
103
+ afterEach(() => {
104
+ vi.useRealTimers();
105
+ });
106
+
107
+ it("renders nothing at all without an editor", () => {
108
+ const { container } = render(null);
109
+ expect(container.textContent).toBe("");
110
+ });
111
+
112
+ it("shows itself only while the caret is inside a code block", () => {
113
+ const inside = makeEditor({ active: true });
114
+ render(inside.editor);
115
+ expect(bubble.props?.shouldShow({ editor: inside.editor })).toBe(true);
116
+
117
+ const outside = makeEditor({ active: false });
118
+ render(outside.editor);
119
+ expect(bubble.props?.shouldShow({ editor: outside.editor })).toBe(false);
120
+ });
121
+
122
+ it("anchors to the block rect, not the caret, so it does not chase typing", () => {
123
+ render(makeEditor().editor);
124
+ expect(typeof bubble.props?.getReferencedVirtualElement).toBe("function");
125
+ expect(bubble.props?.options).toMatchObject({ placement: "top-start" });
126
+ });
127
+
128
+ it("labels a null language as Plain text", () => {
129
+ const { container } = render(makeEditor({ language: null }).editor);
130
+ expect(container.querySelector('[aria-label="Select language"]')?.textContent).toBe(
131
+ "Plain text",
132
+ );
133
+ });
134
+
135
+ it("labels a known language with its catalog label, not its attribute value", () => {
136
+ const { container } = render(makeEditor({ language: "typescript" }).editor);
137
+ const trigger = container.querySelector('[aria-label="Select language"]');
138
+ expect(trigger?.textContent).toBe("TypeScript");
139
+ expect(trigger?.textContent).not.toBe("typescript");
140
+ });
141
+
142
+ it("falls back to the raw attribute for a language the catalog does not know", () => {
143
+ const { container } = render(makeEditor({ language: "brainfuck" }).editor);
144
+ const trigger = container.querySelector('[aria-label="Select language"]');
145
+ expect(trigger?.textContent).toBe("brainfuck");
146
+ });
147
+
148
+ it("falls back to Plain text when the node carries no language attribute at all", () => {
149
+ const { container } = render(makeEditor({ attrs: undefined }).editor);
150
+ const trigger = container.querySelector('[aria-label="Select language"]');
151
+ expect(trigger?.textContent).toBe("Plain text");
152
+ });
153
+
154
+ it("offers a language list headed by Plain text", () => {
155
+ const { container } = render(makeEditor().editor);
156
+ const labels = menuLabels(container);
157
+ expect(labels[0]).toBe("Plain text");
158
+ for (const label of ["TypeScript", "Python", "SQL", "Mermaid", "Math (TeX)"]) {
159
+ expect(labels).toContain(label);
160
+ }
161
+ });
162
+
163
+ it("picking a language writes it onto the node; picking Plain text clears it to null", () => {
164
+ const picked = makeEditor({ language: null });
165
+ const { container } = render(picked.editor);
166
+ const menu = container.querySelector('[data-testid="language-menu"]') as HTMLElement;
167
+
168
+ fireEvent.click(menu.querySelector('[aria-label="Python"]')!);
169
+ expect(picked.names()).toEqual(["focus", "updateAttributes", "run"]);
170
+ expect(picked.calls[1]).toEqual(["updateAttributes", "codeBlock", { language: "python" }]);
171
+
172
+ const cleared = makeEditor({ language: "python" });
173
+ const { container: c2 } = render(cleared.editor);
174
+ const menu2 = c2.querySelector('[data-testid="language-menu"]') as HTMLElement;
175
+ fireEvent.click(menu2.querySelector('[aria-label="Plain text"]')!);
176
+ expect(cleared.calls[1]).toEqual(["updateAttributes", "codeBlock", { language: null }]);
177
+ });
178
+
179
+ it("copies the text of the code block the caret is in, and says so for 1.5s", async () => {
180
+ vi.useFakeTimers();
181
+ const { container } = render(makeEditor({ code: "print('hi')" }).editor);
182
+
183
+ await act(async () => {
184
+ fireEvent.click(container.querySelector('[aria-label="Copy code"]')!);
185
+ });
186
+
187
+ expect(writeText).toHaveBeenCalledWith("print('hi')");
188
+ expect(labelOf(container, '[aria-label="Copied"]')).toBe("Copied");
189
+
190
+ act(() => {
191
+ vi.advanceTimersByTime(1500);
192
+ });
193
+ expect(container.querySelector('[aria-label="Copied"]')).toBeNull();
194
+ expect(container.querySelector('[aria-label="Copy code"]')).toBeTruthy();
195
+ });
196
+
197
+ it("a rejected clipboard write warns and leaves the label alone", async () => {
198
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
199
+ writeText.mockRejectedValueOnce(new Error("denied"));
200
+ const { container } = render(makeEditor().editor);
201
+
202
+ await act(async () => {
203
+ fireEvent.click(container.querySelector('[aria-label="Copy code"]')!);
204
+ });
205
+
206
+ expect(warn).toHaveBeenCalled();
207
+ expect(container.querySelector('[aria-label="Copied"]')).toBeNull();
208
+ warn.mockRestore();
209
+ });
210
+
211
+ it("remove toggles the code block off rather than deleting the selection", () => {
212
+ const { editor, names, calls } = makeEditor();
213
+ const { container } = render(editor);
214
+
215
+ fireEvent.click(container.querySelector('[aria-label="Remove code block"]')!);
216
+ expect(names()).toEqual(["focus", "toggleCodeBlock", "run"]);
217
+ expect(calls.some(([n]) => n === "deleteSelection")).toBe(false);
218
+ });
219
+ });
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Storybook stories for FixedToolbar — the bar across the top of the editor,
3
+ * the thing the whole toolbar/ folder assembles into.
4
+ *
5
+ * Stories: Main, Sections, MarkdownCapabilities, ErrorState, FullscreenToggle,
6
+ * LiveEditor
7
+ *
8
+ * The toolbar owns no editor state: it renders `state` and calls commands
9
+ * through `editorRef`. So every story but the last drives it from a plain
10
+ * state object and reports the commands into the actions panel, which is the
11
+ * only way to see the command wiring without a document to run it against.
12
+ * LiveEditor is the exception — a real editor behind a standalone bar, which
13
+ * is what the compound `RichEditor.Editor` export exists for.
14
+ */
15
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
16
+ import { action } from "@multiplatform.one/storybook";
17
+ import { useRef, useState } from "react";
18
+ import { Paragraph, YStack } from "tamagui";
19
+ import { RichEditorContext } from "../context";
20
+ import { EditorFrame } from "../styled";
21
+ import { RichEditor } from "../RichEditor";
22
+ import { defaultEditorState } from "../types";
23
+ import type { EditorCapabilities, EditorState, RichEditorRef, ToolbarSection } from "../types";
24
+ import { FixedToolbar } from "./FixedToolbar";
25
+
26
+ export interface FixedToolbarStoryArgs {
27
+ /** Bold is on in the current selection */
28
+ isBold: boolean;
29
+ /** Italic is on in the current selection */
30
+ isItalic: boolean;
31
+ /** There is history to undo (an empty history washes the Undo button) */
32
+ canUndo: boolean;
33
+ /** There is history to redo */
34
+ canRedo: boolean;
35
+ /** Which sections show; undefined shows every one */
36
+ sections?: ToolbarSection[];
37
+ /** markdown drops the controls GFM cannot express */
38
+ capabilities: EditorCapabilities;
39
+ /** Paints the bar's bottom rule red, matching the frame */
40
+ hasError: boolean;
41
+ }
42
+
43
+ function stateFor(args: FixedToolbarStoryArgs): EditorState {
44
+ return {
45
+ ...defaultEditorState,
46
+ isBold: args.isBold,
47
+ isItalic: args.isItalic,
48
+ canUndo: args.canUndo,
49
+ canRedo: args.canRedo,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * A toolbar with no editor under it. `editorRef` stays null, so commands are
55
+ * reported to the actions panel instead of running — which is the point:
56
+ * the bar's job is to name the command, not to perform it.
57
+ */
58
+ function ToolbarSpecimen(args: FixedToolbarStoryArgs) {
59
+ const editorRef = useRef<RichEditorRef>({
60
+ executeCommand: (command, payload) => action("executeCommand")(command, payload),
61
+ getState: () => stateFor(args),
62
+ getHTML: () => "",
63
+ getJSON: () => null,
64
+ getMarkdown: () => "",
65
+ setHTML: () => {},
66
+ setJSON: () => {},
67
+ setMarkdown: () => {},
68
+ focus: () => {},
69
+ blur: () => {},
70
+ });
71
+ return (
72
+ <EditorFrame maxWidth={720} borderRadius="$3" hasError={args.hasError}>
73
+ <FixedToolbar
74
+ editorRef={editorRef}
75
+ state={stateFor(args)}
76
+ sections={args.sections}
77
+ capabilities={args.capabilities}
78
+ hasError={args.hasError}
79
+ />
80
+ <YStack minHeight={100} padding="$3">
81
+ <Paragraph size="$3" color="$color11">
82
+ The bar above drives this space. Commands land in the Actions panel.
83
+ </Paragraph>
84
+ </YStack>
85
+ </EditorFrame>
86
+ );
87
+ }
88
+
89
+ const meta: Meta<FixedToolbarStoryArgs> = {
90
+ title: "Forms/RichTextEditor/FixedToolbar",
91
+ component: FixedToolbar as never,
92
+ parameters: {
93
+ layout: "padded",
94
+ },
95
+ argTypes: {
96
+ isBold: { control: "boolean" },
97
+ isItalic: { control: "boolean" },
98
+ canUndo: { control: "boolean" },
99
+ canRedo: { control: "boolean" },
100
+ capabilities: { control: "radio", options: ["full", "markdown"] },
101
+ hasError: { control: "boolean" },
102
+ sections: { control: false },
103
+ },
104
+ args: {
105
+ isBold: false,
106
+ isItalic: false,
107
+ canUndo: true,
108
+ canRedo: false,
109
+ capabilities: "full",
110
+ hasError: false,
111
+ },
112
+ decorators: [
113
+ (Story) => (
114
+ <RichEditorContext.Provider size="$true" compact={false} disabled={false}>
115
+ <Story />
116
+ </RichEditorContext.Provider>
117
+ ),
118
+ ],
119
+ render: (args) => <ToolbarSpecimen {...args} />,
120
+ };
121
+
122
+ export default meta;
123
+ type Story = StoryObj<FixedToolbarStoryArgs>;
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Main (with controls) — every section, bold off, redo unavailable
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export const Main: Story = {};
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Sections — the `sections` prop trims the bar to the listed groups
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export const Sections: Story = {
136
+ args: {
137
+ sections: ["formatting", "link"],
138
+ isBold: true,
139
+ },
140
+ };
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // MarkdownCapabilities — GFM cannot express colour or underline, so those
144
+ // controls are absent, not merely disabled
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export const MarkdownCapabilities: Story = {
148
+ name: "Markdown capabilities",
149
+ args: {
150
+ capabilities: "markdown",
151
+ },
152
+ };
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // ErrorState — the bar's bottom rule turns red with the frame
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export const ErrorState: Story = {
159
+ name: "Error state",
160
+ args: {
161
+ hasError: true,
162
+ },
163
+ };
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // FullscreenToggle — onFullscreenToggle adds the expand control at the right
167
+ // end, outside the scrolling strip so it never scrolls away
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export const FullscreenToggle: Story = {
171
+ name: "Fullscreen toggle",
172
+ render: (args) => {
173
+ const FullscreenSpecimen = () => {
174
+ const [fullscreen, setFullscreen] = useState(false);
175
+ const editorRef = useRef<RichEditorRef>(null);
176
+ return (
177
+ <EditorFrame maxWidth={720} borderRadius="$3">
178
+ <FixedToolbar
179
+ editorRef={editorRef}
180
+ state={stateFor(args)}
181
+ fullscreen={fullscreen}
182
+ onFullscreenToggle={() => {
183
+ action("onFullscreenToggle")(!fullscreen);
184
+ setFullscreen(!fullscreen);
185
+ }}
186
+ />
187
+ <YStack minHeight={100} padding="$3">
188
+ <Paragraph size="$3" color="$color11">
189
+ {fullscreen ? "Fullscreen: on" : "Fullscreen: off"}
190
+ </Paragraph>
191
+ </YStack>
192
+ </EditorFrame>
193
+ );
194
+ };
195
+ return <FullscreenSpecimen />;
196
+ },
197
+ };
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // LiveEditor — a standalone bar wired to a real editor through the compound
201
+ // `RichEditor.Editor` export; typing updates the marks
202
+ // ---------------------------------------------------------------------------
203
+
204
+ export const LiveEditor: Story = {
205
+ name: "Live editor",
206
+ render: () => {
207
+ const LiveSpecimen = () => {
208
+ const editorRef = useRef<RichEditorRef>(null);
209
+ const [state, setState] = useState<EditorState>(defaultEditorState);
210
+ return (
211
+ <EditorFrame maxWidth={720} borderRadius="$3">
212
+ <FixedToolbar editorRef={editorRef} state={state} />
213
+ <RichEditor.Editor
214
+ ref={editorRef}
215
+ initialContent="<p>Select this text and press <strong>B</strong> in the bar above — the button marks itself from the editor's own state.</p>"
216
+ onStateChange={setState}
217
+ minHeight={160}
218
+ />
219
+ </EditorFrame>
220
+ );
221
+ };
222
+ return <LiveSpecimen />;
223
+ },
224
+ };