@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.
- package/dist/cjs/RichEditor.native.js +20 -0
- package/dist/cjs/RichEditor.native.js.map +1 -1
- package/dist/cjs/editor/TentapEditor.native.js +18 -0
- package/dist/cjs/editor/TentapEditor.native.js.map +1 -1
- package/dist/esm/RichEditor.native.js +21 -1
- package/dist/esm/RichEditor.native.js.map +1 -1
- package/dist/esm/editor/TentapEditor.native.js +18 -0
- package/dist/esm/editor/TentapEditor.native.js.map +1 -1
- package/dist/jsx/RichEditor.native.js +20 -0
- package/dist/jsx/RichEditor.native.js.map +1 -1
- package/dist/jsx/editor/TentapEditor.native.js +18 -0
- package/dist/jsx/editor/TentapEditor.native.js.map +1 -1
- package/package.json +8 -9
- package/src/RichEditor.native.tsx +19 -1
- package/src/RichEditor.parts.stories.tsx +139 -0
- package/src/editor/SlashCommands.spec.tsx +210 -0
- package/src/editor/TentapEditor.native.tsx +35 -0
- package/src/richEditorPartsStories.spec.tsx +150 -0
- package/src/toolbar/CodeBlockBubble.spec.tsx +219 -0
- package/src/toolbar/FixedToolbar.stories.tsx +224 -0
- package/src/toolbar/ImageBubble.spec.tsx +140 -0
- package/src/toolbar/LinkBubble.spec.tsx +159 -0
- package/src/toolbar/ResizeHandle.spec.tsx +118 -0
- package/src/toolbar/StatusBar.spec.tsx +100 -0
- package/src/toolbar/StatusBar.stories.tsx +146 -0
- package/src/toolbar/TableBubble.spec.tsx +189 -0
- package/src/toolbar/ToolbarButton.stories.tsx +193 -0
- package/src/toolbar/ToolbarGroup.stories.tsx +148 -0
- package/src/toolbar/fixedToolbarStories.spec.tsx +200 -0
- package/src/toolbar/statusBarStories.spec.tsx +86 -0
- package/src/toolbar/toolbarButtonStories.spec.tsx +122 -0
- package/src/toolbar/toolbarGroupStories.spec.tsx +108 -0
- package/types/RichEditor.native.d.ts.map +1 -1
- package/types/editor/TentapEditor.native.d.ts +4 -0
- 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
|
+
};
|