@multiplatform.one/frappe-ui 6.3.0 → 6.4.0
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/package.json +22 -15
- package/src/FrappeDashboard.stories.tsx +3 -1
- package/src/FrappeDashboard.tsx +21 -1
- package/src/FrappeListView.tsx +21 -1
- package/src/fieldRegistry.ts +1 -1
- package/src/fields/MarkdownEditor/MarkdownEditor.spec.tsx +56 -32
- package/src/fields/MarkdownEditor/MarkdownEditor.stories.tsx +5 -5
- package/src/fields/MarkdownEditor/index.native.tsx +292 -0
- package/src/fields/MarkdownEditor/index.tsx +64 -139
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/frappe-ui",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "Pre-wired Tamagui components for Frappe doctypes with live data",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cross-platform",
|
|
@@ -52,6 +52,13 @@
|
|
|
52
52
|
"import": "./dist/esm/index.mjs",
|
|
53
53
|
"require": "./dist/cjs/index.cjs",
|
|
54
54
|
"default": "./dist/esm/index.mjs"
|
|
55
|
+
},
|
|
56
|
+
"./dependsOn": {
|
|
57
|
+
"source": "./src/dependsOn.ts",
|
|
58
|
+
"types": "./types/dependsOn.d.ts",
|
|
59
|
+
"import": "./dist/esm/dependsOn.mjs",
|
|
60
|
+
"require": "./dist/cjs/dependsOn.cjs",
|
|
61
|
+
"default": "./src/dependsOn.ts"
|
|
55
62
|
}
|
|
56
63
|
},
|
|
57
64
|
"publishConfig": {
|
|
@@ -71,17 +78,17 @@
|
|
|
71
78
|
"html5-qrcode": "^2.3.8",
|
|
72
79
|
"signature_pad": "^5.1.3",
|
|
73
80
|
"tamagui": "2.0.0-rc.41",
|
|
74
|
-
"@multiplatform.one/components": "6.
|
|
75
|
-
"@multiplatform.one/
|
|
76
|
-
"@multiplatform.one/
|
|
77
|
-
"@multiplatform.one/
|
|
78
|
-
"@multiplatform.one/i18n": "6.
|
|
79
|
-
"@multiplatform.one/
|
|
80
|
-
"@multiplatform.one/
|
|
81
|
-
"@multiplatform.one/
|
|
82
|
-
"@multiplatform.one/
|
|
83
|
-
"@multiplatform.one/
|
|
84
|
-
"@multiplatform.one/
|
|
81
|
+
"@multiplatform.one/components": "6.4.0",
|
|
82
|
+
"@multiplatform.one/frappe": "6.4.0",
|
|
83
|
+
"@multiplatform.one/desktop": "6.4.0",
|
|
84
|
+
"@multiplatform.one/forms": "6.4.0",
|
|
85
|
+
"@multiplatform.one/i18n": "6.4.0",
|
|
86
|
+
"@multiplatform.one/router": "6.4.0",
|
|
87
|
+
"@multiplatform.one/store": "6.4.0",
|
|
88
|
+
"@multiplatform.one/rich-text": "6.4.0",
|
|
89
|
+
"@multiplatform.one/table": "6.4.0",
|
|
90
|
+
"@multiplatform.one/theme": "6.4.0",
|
|
91
|
+
"@multiplatform.one/platform": "6.4.0"
|
|
85
92
|
},
|
|
86
93
|
"devDependencies": {
|
|
87
94
|
"@tamagui/build": "2.0.0-rc.41",
|
|
@@ -94,9 +101,9 @@
|
|
|
94
101
|
"react-dom": "19.2.5",
|
|
95
102
|
"react-i18next": "^16.6.6",
|
|
96
103
|
"vitest": "^4.1.5",
|
|
97
|
-
"@multiplatform.one/config": "6.
|
|
98
|
-
"@multiplatform.one/test-utils": "6.
|
|
99
|
-
"@multiplatform.one/storybook": "6.
|
|
104
|
+
"@multiplatform.one/config": "6.4.0",
|
|
105
|
+
"@multiplatform.one/test-utils": "6.4.0",
|
|
106
|
+
"@multiplatform.one/storybook": "6.4.0"
|
|
100
107
|
},
|
|
101
108
|
"peerDependencies": {
|
|
102
109
|
"@react-native-community/datetimepicker": "^8.0.1",
|
|
@@ -31,7 +31,9 @@ export const KPICardDefault = () => (
|
|
|
31
31
|
/>
|
|
32
32
|
);
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Not "Main": SalesDashboard below owns that name; two stories sharing one
|
|
35
|
+
// name collide to a single lost-pixel shot file, silently untesting one.
|
|
36
|
+
KPICardDefault.storyName = "KPI Card";
|
|
35
37
|
// Chart Card Stories
|
|
36
38
|
export const BarChart = () => (
|
|
37
39
|
<ChartCard
|
package/src/FrappeDashboard.tsx
CHANGED
|
@@ -15,7 +15,25 @@ import React, { useMemo, useCallback, useState, useEffect, useRef } from "react"
|
|
|
15
15
|
import { useTranslation } from "react-i18next";
|
|
16
16
|
import { Spinner } from "@multiplatform.one/forms";
|
|
17
17
|
import { Button, Text, XStack, YStack, type YStackProps, H3 } from "tamagui";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
MIN_PRESS_TARGET,
|
|
20
|
+
pressTargetHitSlop,
|
|
21
|
+
pressTargetStyle,
|
|
22
|
+
useResolvedKnobs,
|
|
23
|
+
} from "@multiplatform.one/theme";
|
|
24
|
+
|
|
25
|
+
// DG-LAY-04 / D-02 press-floor: the chromeless $2 refresh button paints
|
|
26
|
+
// ~28×28; the press box is floored at 44 via the house pressTarget* channel
|
|
27
|
+
// (grown transparent box + negative outset + hitSlop). Layout stays put; the
|
|
28
|
+
// only adjacent node is non-interactive text, so no target overlap.
|
|
29
|
+
const REFRESH_VISUAL = 28;
|
|
30
|
+
const REFRESH_OUTSET = Math.ceil((MIN_PRESS_TARGET - REFRESH_VISUAL) / 2);
|
|
31
|
+
const refreshPressFloorProps = {
|
|
32
|
+
...pressTargetStyle(),
|
|
33
|
+
marginVertical: -REFRESH_OUTSET,
|
|
34
|
+
marginHorizontal: -REFRESH_OUTSET,
|
|
35
|
+
hitSlop: pressTargetHitSlop(REFRESH_VISUAL),
|
|
36
|
+
} as const;
|
|
19
37
|
import { useFrappeConfig } from "./FrappeProvider";
|
|
20
38
|
|
|
21
39
|
/**
|
|
@@ -209,6 +227,7 @@ function LiveDashboardContent<TDoc extends { name: string; doctype: string }>({
|
|
|
209
227
|
disabled={isRefreshing || isLiveLoading}
|
|
210
228
|
chromeless
|
|
211
229
|
aria-label={t("Refresh")}
|
|
230
|
+
{...refreshPressFloorProps}
|
|
212
231
|
/>
|
|
213
232
|
</XStack>
|
|
214
233
|
)}
|
|
@@ -319,6 +338,7 @@ export function FrappeDashboard<TDoc extends { name: string; doctype: string }>(
|
|
|
319
338
|
disabled={isRefreshing}
|
|
320
339
|
chromeless
|
|
321
340
|
aria-label={t("Refresh")}
|
|
341
|
+
{...refreshPressFloorProps}
|
|
322
342
|
/>
|
|
323
343
|
</XStack>
|
|
324
344
|
</XStack>
|
package/src/FrappeListView.tsx
CHANGED
|
@@ -17,12 +17,31 @@ import {
|
|
|
17
17
|
} from "tamagui";
|
|
18
18
|
import { SavedFilterDropdown, useSavedFilters, type ParsedFilter } from "@multiplatform.one/table";
|
|
19
19
|
import { ViewSwitcher } from "@multiplatform.one/components";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
MIN_PRESS_TARGET,
|
|
22
|
+
pressTargetHitSlop,
|
|
23
|
+
pressTargetStyle,
|
|
24
|
+
useResolvedKnobs,
|
|
25
|
+
} from "@multiplatform.one/theme";
|
|
21
26
|
import { storage } from "@multiplatform.one/store";
|
|
22
27
|
import { useFrappeConfig } from "./FrappeProvider";
|
|
23
28
|
import { FrappeList } from "./FrappeList";
|
|
24
29
|
import { FrappeTable } from "./FrappeTable";
|
|
25
30
|
|
|
31
|
+
// DG-LAY-04 / D-02 press-floor: the chromeless $2 refresh button paints
|
|
32
|
+
// ~28×28; the press box is floored at 44 via the house pressTarget* channel
|
|
33
|
+
// (grown transparent box + negative outset + hitSlop). The horizontal outset
|
|
34
|
+
// (8px) stays under the toolbar gap, so the enlarged target never reaches the
|
|
35
|
+
// adjacent Add button's box.
|
|
36
|
+
const REFRESH_VISUAL = 28;
|
|
37
|
+
const REFRESH_OUTSET = Math.ceil((MIN_PRESS_TARGET - REFRESH_VISUAL) / 2);
|
|
38
|
+
const refreshPressFloorProps = {
|
|
39
|
+
...pressTargetStyle(),
|
|
40
|
+
marginVertical: -REFRESH_OUTSET,
|
|
41
|
+
marginHorizontal: -REFRESH_OUTSET,
|
|
42
|
+
hitSlop: pressTargetHitSlop(REFRESH_VISUAL),
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
26
45
|
interface ListFieldConfig {
|
|
27
46
|
key: string;
|
|
28
47
|
label?: string;
|
|
@@ -481,6 +500,7 @@ export function FrappeListView<TDoc extends { name: string; doctype: string }>({
|
|
|
481
500
|
icon={ArrowsClockwiseIcon}
|
|
482
501
|
onPress={handleRefresh}
|
|
483
502
|
aria-label={t("Refresh")}
|
|
503
|
+
{...refreshPressFloorProps}
|
|
484
504
|
/>
|
|
485
505
|
{onAdd && (
|
|
486
506
|
<Button size="$3" theme="accent" icon={PlusIcon} onPress={onAdd}>
|
package/src/fieldRegistry.ts
CHANGED
|
@@ -1064,7 +1064,7 @@ export function fieldtypeToTableField(
|
|
|
1064
1064
|
onChange: onCellChange,
|
|
1065
1065
|
doctype: rowDoctype,
|
|
1066
1066
|
compact: true,
|
|
1067
|
-
disabled: !rowDoctype || context?.disabled,
|
|
1067
|
+
disabled: Boolean(!rowDoctype || context?.disabled),
|
|
1068
1068
|
// D-18.4: read-only cells block commits, so the combobox must
|
|
1069
1069
|
// render its non-interactive representation.
|
|
1070
1070
|
readOnly: context?.readOnly,
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MarkdownEditorField contract specs:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* MarkdownEditorField contract specs: rich editor in markdown capabilities
|
|
3
|
+
* (LC-52 MARKDOWN-EXPRESSIBLE-EDITING — no highlight/underline/color
|
|
4
|
+
* controls), canonical onChange emitting markdown (LC-14), readOnly/disabled
|
|
5
|
+
* gating, table-cell chromeless branch, skeleton branch, and registry
|
|
6
|
+
* resolution for the "Markdown Editor" fieldtype.
|
|
5
7
|
*/
|
|
6
8
|
import { TableCellContext } from "@multiplatform.one/forms";
|
|
7
9
|
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
8
|
-
import { act, cleanup, fireEvent } from "@testing-library/react";
|
|
10
|
+
import { act, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
|
9
11
|
import type React from "react";
|
|
10
12
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
13
|
import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
|
|
@@ -13,63 +15,85 @@ import { MarkdownEditorField } from ".";
|
|
|
13
15
|
|
|
14
16
|
afterEach(cleanup);
|
|
15
17
|
|
|
18
|
+
const getProseMirror = (container: HTMLElement) =>
|
|
19
|
+
container.querySelector<HTMLElement>('[contenteditable="true"]');
|
|
20
|
+
|
|
16
21
|
describe("MarkdownEditorField (standalone)", () => {
|
|
17
|
-
it("
|
|
18
|
-
const onChange = vi.fn();
|
|
22
|
+
it("renders the rich text editor (WYSIWYG), not a source textarea", async () => {
|
|
19
23
|
const result = renderWithProviders(
|
|
20
|
-
<MarkdownEditorField label="Notes" value="" onChange={
|
|
24
|
+
<MarkdownEditorField label="Notes" value="# Hello" onChange={() => {}} />,
|
|
21
25
|
);
|
|
22
|
-
|
|
23
|
-
expect(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
});
|
|
27
|
-
expect(onChange).toHaveBeenCalledWith("# Hello");
|
|
26
|
+
await waitFor(() => expect(getProseMirror(result.container)).toBeTruthy());
|
|
27
|
+
expect(result.getTextArea()).toBeNull();
|
|
28
|
+
// markdown value parsed into rich nodes
|
|
29
|
+
expect(result.container.querySelector("h1")?.textContent).toBe("Hello");
|
|
28
30
|
});
|
|
29
31
|
|
|
30
|
-
it("
|
|
32
|
+
it("markdown capabilities: no underline/highlight/color toolbar controls (LC-52)", async () => {
|
|
33
|
+
const result = renderWithProviders(
|
|
34
|
+
<MarkdownEditorField label="Notes" value="hello" onChange={() => {}} />,
|
|
35
|
+
);
|
|
36
|
+
await waitFor(() => expect(result.queryByLabelText("Bold")).toBeTruthy());
|
|
37
|
+
expect(result.queryByLabelText("Underline")).toBeNull();
|
|
38
|
+
expect(result.queryByLabelText("Text Color")).toBeNull();
|
|
39
|
+
expect(result.queryByLabelText("Highlight Color")).toBeNull();
|
|
40
|
+
// markdown-expressible controls stay
|
|
41
|
+
expect(result.queryByLabelText("Italic")).toBeTruthy();
|
|
42
|
+
expect(result.queryByLabelText("Blockquote")).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("emits markdown (not HTML) through canonical onChange", async () => {
|
|
31
46
|
const onChange = vi.fn();
|
|
32
47
|
const result = renderWithProviders(
|
|
33
|
-
<
|
|
34
|
-
<MarkdownEditorField label="Notes" value="original" readOnly onChange={onChange} />
|
|
35
|
-
</TableCellContext.Provider>,
|
|
48
|
+
<MarkdownEditorField label="Notes" value="hello" onChange={onChange} />,
|
|
36
49
|
);
|
|
37
|
-
|
|
38
|
-
expect(textarea).toBeTruthy();
|
|
50
|
+
await waitFor(() => expect(result.queryByLabelText("Blockquote")).toBeTruthy());
|
|
39
51
|
await act(async () => {
|
|
40
|
-
|
|
52
|
+
fireEvent.click(result.getByLabelText("Blockquote"));
|
|
41
53
|
});
|
|
42
|
-
expect(onChange).
|
|
54
|
+
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
55
|
+
const emitted = onChange.mock.calls.at(-1)?.[0] as string;
|
|
56
|
+
expect(emitted).toContain("> hello");
|
|
57
|
+
expect(emitted).not.toContain("<blockquote>");
|
|
43
58
|
});
|
|
44
59
|
|
|
45
|
-
it("
|
|
60
|
+
it("readOnly renders a non-editable surface without emitting changes", async () => {
|
|
46
61
|
const onChange = vi.fn();
|
|
47
62
|
const result = renderWithProviders(
|
|
48
|
-
<
|
|
49
|
-
<MarkdownEditorField label="Notes" value="original" disabled onChange={onChange} />
|
|
50
|
-
</TableCellContext.Provider>,
|
|
63
|
+
<MarkdownEditorField label="Notes" value="original" readOnly onChange={onChange} />,
|
|
51
64
|
);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
await waitFor(() =>
|
|
66
|
+
expect(result.container.querySelector('[contenteditable="false"]')).toBeTruthy(),
|
|
67
|
+
);
|
|
68
|
+
expect(getProseMirror(result.container)).toBeNull();
|
|
56
69
|
expect(onChange).not.toHaveBeenCalled();
|
|
57
70
|
});
|
|
58
71
|
|
|
59
|
-
it("renders
|
|
72
|
+
it("disabled renders a non-editable surface", async () => {
|
|
73
|
+
const result = renderWithProviders(
|
|
74
|
+
<MarkdownEditorField label="Notes" value="original" disabled onChange={() => {}} />,
|
|
75
|
+
);
|
|
76
|
+
await waitFor(() =>
|
|
77
|
+
expect(result.container.querySelector('[contenteditable="false"]')).toBeTruthy(),
|
|
78
|
+
);
|
|
79
|
+
expect(getProseMirror(result.container)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renders chromeless (no fixed formatting toolbar) in table cells", async () => {
|
|
60
83
|
const result = renderWithProviders(
|
|
61
84
|
<TableCellContext.Provider value={{ inTableCell: true, editable: true }}>
|
|
62
85
|
<MarkdownEditorField label="Notes" value="# cell" onChange={() => {}} />
|
|
63
86
|
</TableCellContext.Provider>,
|
|
64
87
|
);
|
|
65
|
-
expect(result.
|
|
66
|
-
expect(result.
|
|
88
|
+
await waitFor(() => expect(getProseMirror(result.container)).toBeTruthy());
|
|
89
|
+
expect(result.queryByLabelText("Formatting toolbar")).toBeNull();
|
|
67
90
|
});
|
|
68
91
|
|
|
69
92
|
it("skeleton branch renders no editable surface", () => {
|
|
70
93
|
const result = renderWithProviders(
|
|
71
94
|
<MarkdownEditorField label="Notes" skeleton value="# hidden" onChange={() => {}} />,
|
|
72
95
|
);
|
|
96
|
+
expect(getProseMirror(result.container)).toBeNull();
|
|
73
97
|
expect(result.getTextArea()).toBeNull();
|
|
74
98
|
});
|
|
75
99
|
});
|
|
@@ -9,14 +9,14 @@ const FIXTURE = `# Release notes
|
|
|
9
9
|
|
|
10
10
|
Dedicated **Markdown Editor** field for the Frappe \`Markdown Editor\` fieldtype.
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
12
|
+
- WYSIWYG editing via the canonical rich text editor
|
|
13
|
+
- Constrained to what ~~HTML~~ *markdown* can express (LC-52)
|
|
14
14
|
- Full field contract: label / hint / error / readOnly / skeleton
|
|
15
15
|
|
|
16
|
-
>
|
|
16
|
+
> Serializes to and from a GFM markdown string.
|
|
17
17
|
|
|
18
18
|
\`\`\`python
|
|
19
|
-
print("fenced code
|
|
19
|
+
print("fenced code works too")
|
|
20
20
|
\`\`\`
|
|
21
21
|
`;
|
|
22
22
|
|
|
@@ -28,7 +28,7 @@ export default {
|
|
|
28
28
|
docs: {
|
|
29
29
|
description: {
|
|
30
30
|
component:
|
|
31
|
-
"Frappe `Markdown Editor` fieldtype: markdown
|
|
31
|
+
"Frappe `Markdown Editor` fieldtype: the canonical rich text editor in markdown capabilities (LC-52) — WYSIWYG constrained to GFM-expressible features (no highlight/underline/color/alignment), serializing to and from a markdown string. Native falls back to markdown source editing.",
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
34
|
},
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MarkdownEditorField (NATIVE fallback) — markdown source pane with a live
|
|
3
|
+
* rendered preview (house `@multiplatform.one/markdown` renderer).
|
|
4
|
+
*
|
|
5
|
+
* The web tier renders the canonical rich text editor constrained to
|
|
6
|
+
* markdown capabilities (LC-52 MARKDOWN-EXPRESSIBLE-EDITING). On native the
|
|
7
|
+
* 10Tap WebView cannot express that constraint honestly tonight (extension
|
|
8
|
+
* gating + markdown serialization live in the web bundle), so this tier
|
|
9
|
+
* keeps plain markdown source editing — documented fallback, not a parallel
|
|
10
|
+
* WYSIWYG implementation.
|
|
11
|
+
*
|
|
12
|
+
* Full field contract: three-branch render (skeleton → standalone →
|
|
13
|
+
* form-integrated), canonical `onChange(value)` (LC-14), FieldLayout slot
|
|
14
|
+
* order, chromeless inside table cells (useIsInTableCell).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
Field,
|
|
19
|
+
FieldLayout,
|
|
20
|
+
Skeleton,
|
|
21
|
+
TextArea,
|
|
22
|
+
formCommonColors,
|
|
23
|
+
getFieldError,
|
|
24
|
+
mergeFieldHandler,
|
|
25
|
+
useFormField,
|
|
26
|
+
useIsInTableCell,
|
|
27
|
+
useResolvedValidators,
|
|
28
|
+
type AnyFormApi,
|
|
29
|
+
} from "@multiplatform.one/forms";
|
|
30
|
+
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
|
31
|
+
import { useTranslation } from "react-i18next";
|
|
32
|
+
import type { FontSizeTokens, LabelProps, SizeTokens } from "tamagui";
|
|
33
|
+
import { Paragraph, YStack } from "tamagui";
|
|
34
|
+
import { useResolvedKnobs } from "@multiplatform.one/theme";
|
|
35
|
+
import { EditorPreviewPanes, PreviewSurface } from "../shared/EditorPreviewPanes";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Preview (lazy house Markdown renderer)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
type MarkdownComponent = ComponentType<{ children?: string }>;
|
|
42
|
+
|
|
43
|
+
// Module-level cache so the renderer loads once per session, not per field.
|
|
44
|
+
let markdownRendererCache: MarkdownComponent | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The house Markdown renderer loads on demand the first time a preview pane
|
|
48
|
+
* renders (same optional-dependency pattern as the components shiki
|
|
49
|
+
* Highlighter): `@multiplatform.one/markdown` reaches consumers transitively
|
|
50
|
+
* via `@multiplatform.one/rich-text`, and a failed import degrades to a
|
|
51
|
+
* plain-text preview instead of crashing the field.
|
|
52
|
+
*/
|
|
53
|
+
function useMarkdownRenderer(enabled: boolean): MarkdownComponent | null {
|
|
54
|
+
const [renderer, setRenderer] = useState<MarkdownComponent | null>(markdownRendererCache);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!enabled || renderer) return;
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
import("@multiplatform.one/markdown")
|
|
60
|
+
.then((mod) => {
|
|
61
|
+
markdownRendererCache = mod.Markdown as MarkdownComponent;
|
|
62
|
+
if (!cancelled) setRenderer(() => markdownRendererCache);
|
|
63
|
+
})
|
|
64
|
+
.catch(() => {
|
|
65
|
+
// Renderer unavailable — the plain-text fallback below stays up.
|
|
66
|
+
});
|
|
67
|
+
return () => {
|
|
68
|
+
cancelled = true;
|
|
69
|
+
};
|
|
70
|
+
}, [enabled, renderer]);
|
|
71
|
+
|
|
72
|
+
return renderer;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface MarkdownPreviewProps {
|
|
76
|
+
value: string;
|
|
77
|
+
compact?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function MarkdownPreview({ value, compact }: MarkdownPreviewProps) {
|
|
81
|
+
const { t } = useTranslation();
|
|
82
|
+
const { knobProps } = useResolvedKnobs({ compact });
|
|
83
|
+
const Renderer = useMarkdownRenderer(!!value);
|
|
84
|
+
// Content type scale follows the size knob (same idiom as CodeBlock).
|
|
85
|
+
const textSize = knobProps.sizeToken as FontSizeTokens;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<PreviewSurface compact={compact}>
|
|
89
|
+
{!value ? (
|
|
90
|
+
<Paragraph color={formCommonColors.muted} size={textSize}>
|
|
91
|
+
{t("Nothing to preview")}
|
|
92
|
+
</Paragraph>
|
|
93
|
+
) : Renderer ? (
|
|
94
|
+
<Renderer>{value}</Renderer>
|
|
95
|
+
) : (
|
|
96
|
+
// Loading / renderer unavailable: honest plain-text fallback.
|
|
97
|
+
<Paragraph size={textSize} whiteSpace="pre-wrap">
|
|
98
|
+
{value}
|
|
99
|
+
</Paragraph>
|
|
100
|
+
)}
|
|
101
|
+
</PreviewSurface>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// MarkdownEditorField
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export interface MarkdownEditorFieldProps {
|
|
110
|
+
name?: string;
|
|
111
|
+
form?: AnyFormApi;
|
|
112
|
+
validators?: Record<string, unknown>;
|
|
113
|
+
label?: ReactNode;
|
|
114
|
+
labelProps?: Omit<LabelProps, "htmlFor" | "ref">;
|
|
115
|
+
helperText?: string;
|
|
116
|
+
error?: string | boolean;
|
|
117
|
+
required?: boolean;
|
|
118
|
+
disabled?: boolean;
|
|
119
|
+
readOnly?: boolean;
|
|
120
|
+
skeleton?: boolean;
|
|
121
|
+
compact?: boolean;
|
|
122
|
+
size?: SizeTokens;
|
|
123
|
+
id?: string;
|
|
124
|
+
value?: string;
|
|
125
|
+
defaultValue?: string;
|
|
126
|
+
/** Canonical change contract (LC-14): emits the markdown source string. */
|
|
127
|
+
onChange?: (value: string) => void;
|
|
128
|
+
onBlur?: (...args: any[]) => void;
|
|
129
|
+
placeholder?: string;
|
|
130
|
+
/** Minimum visible source rows (default 6). */
|
|
131
|
+
minRows?: number;
|
|
132
|
+
/** Maximum source rows before the textarea scrolls (default 16). */
|
|
133
|
+
maxRows?: number;
|
|
134
|
+
"aria-label"?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function MarkdownEditorField({
|
|
138
|
+
name,
|
|
139
|
+
form: formProp,
|
|
140
|
+
validators,
|
|
141
|
+
label,
|
|
142
|
+
labelProps,
|
|
143
|
+
helperText,
|
|
144
|
+
error,
|
|
145
|
+
required,
|
|
146
|
+
disabled,
|
|
147
|
+
readOnly,
|
|
148
|
+
skeleton,
|
|
149
|
+
compact,
|
|
150
|
+
size,
|
|
151
|
+
id: idProp,
|
|
152
|
+
value,
|
|
153
|
+
defaultValue,
|
|
154
|
+
onChange,
|
|
155
|
+
onBlur,
|
|
156
|
+
placeholder,
|
|
157
|
+
minRows = 6,
|
|
158
|
+
maxRows = 16,
|
|
159
|
+
"aria-label": ariaLabel,
|
|
160
|
+
}: MarkdownEditorFieldProps) {
|
|
161
|
+
const { t } = useTranslation();
|
|
162
|
+
const { resolvedForm, knobProps, id } = useFormField({ form: formProp, id: idProp, compact });
|
|
163
|
+
const resolvedValidators = useResolvedValidators(required, validators, label, name);
|
|
164
|
+
const inTableCell = useIsInTableCell();
|
|
165
|
+
|
|
166
|
+
// Standalone/uncontrolled mirror so the live preview always has the current
|
|
167
|
+
// source (the form branch reads field state instead).
|
|
168
|
+
const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? "");
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (value !== undefined) setInternalValue(value);
|
|
171
|
+
}, [value]);
|
|
172
|
+
|
|
173
|
+
const accessibleName =
|
|
174
|
+
ariaLabel ?? (typeof label === "string" ? label : undefined) ?? placeholder;
|
|
175
|
+
|
|
176
|
+
if (skeleton) {
|
|
177
|
+
return (
|
|
178
|
+
<YStack {...knobProps.gap} width="100%">
|
|
179
|
+
{label && <Skeleton variant="text" width="30%" height={14} />}
|
|
180
|
+
<Skeleton variant="rounded" width="100%" height={compact ? 120 : 200} />
|
|
181
|
+
</YStack>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const renderSource = (
|
|
186
|
+
textValue: string,
|
|
187
|
+
handleChange?: (text: string) => void,
|
|
188
|
+
hasError?: boolean,
|
|
189
|
+
) => (
|
|
190
|
+
<TextArea
|
|
191
|
+
id={id}
|
|
192
|
+
value={textValue}
|
|
193
|
+
onChangeText={(text) => {
|
|
194
|
+
if (disabled || readOnly) return;
|
|
195
|
+
handleChange?.(text);
|
|
196
|
+
onChange?.(text);
|
|
197
|
+
}}
|
|
198
|
+
placeholder={placeholder}
|
|
199
|
+
disabled={disabled}
|
|
200
|
+
readOnly={readOnly}
|
|
201
|
+
required={required}
|
|
202
|
+
error={hasError ? true : undefined}
|
|
203
|
+
compact={compact}
|
|
204
|
+
size={size}
|
|
205
|
+
minRows={minRows}
|
|
206
|
+
maxRows={maxRows}
|
|
207
|
+
textAreaProps={{
|
|
208
|
+
fontFamily: "$mono",
|
|
209
|
+
autoCapitalize: "none",
|
|
210
|
+
autoCorrect: false,
|
|
211
|
+
spellCheck: false,
|
|
212
|
+
...(accessibleName ? { "aria-label": accessibleName } : {}),
|
|
213
|
+
}}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Table cells get the chromeless source pane only (no tabs/preview chrome);
|
|
218
|
+
// InputParts inside the forms TextArea detects the cell context itself.
|
|
219
|
+
const renderControl = (
|
|
220
|
+
textValue: string,
|
|
221
|
+
handleChange?: (text: string) => void,
|
|
222
|
+
hasError?: boolean,
|
|
223
|
+
) => {
|
|
224
|
+
if (inTableCell) return renderSource(textValue, handleChange, hasError);
|
|
225
|
+
return (
|
|
226
|
+
<EditorPreviewPanes
|
|
227
|
+
editor={renderSource(textValue, handleChange, hasError)}
|
|
228
|
+
preview={<MarkdownPreview value={textValue} compact={compact} />}
|
|
229
|
+
editTabLabel={t("Write")}
|
|
230
|
+
previewTabLabel={t("Preview")}
|
|
231
|
+
compact={compact}
|
|
232
|
+
defaultPreview={!!readOnly}
|
|
233
|
+
/>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (!resolvedForm || !name) {
|
|
238
|
+
if (inTableCell) return renderControl(internalValue, setInternalValue, !!error);
|
|
239
|
+
return (
|
|
240
|
+
<FieldLayout
|
|
241
|
+
id={id}
|
|
242
|
+
label={label}
|
|
243
|
+
labelProps={labelProps}
|
|
244
|
+
error={error}
|
|
245
|
+
helperText={helperText}
|
|
246
|
+
required={required}
|
|
247
|
+
disabled={disabled}
|
|
248
|
+
size={size}
|
|
249
|
+
knobProps={knobProps}
|
|
250
|
+
onBlur={onBlur}
|
|
251
|
+
aria-label={ariaLabel}
|
|
252
|
+
placeholder={placeholder}
|
|
253
|
+
>
|
|
254
|
+
{renderControl(internalValue, setInternalValue, !!error)}
|
|
255
|
+
</FieldLayout>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<Field
|
|
261
|
+
form={resolvedForm}
|
|
262
|
+
name={name}
|
|
263
|
+
defaultValue={defaultValue}
|
|
264
|
+
validators={resolvedValidators}
|
|
265
|
+
>
|
|
266
|
+
{(field) => {
|
|
267
|
+
const resolvedError = getFieldError(field, error);
|
|
268
|
+
const fieldValue = typeof field.state.value === "string" ? field.state.value : "";
|
|
269
|
+
const handleChange = (text: string) => field.handleChange(text as any);
|
|
270
|
+
if (inTableCell) return renderControl(fieldValue, handleChange, !!resolvedError);
|
|
271
|
+
return (
|
|
272
|
+
<FieldLayout
|
|
273
|
+
id={id}
|
|
274
|
+
label={label}
|
|
275
|
+
labelProps={labelProps}
|
|
276
|
+
error={resolvedError}
|
|
277
|
+
helperText={helperText}
|
|
278
|
+
required={required}
|
|
279
|
+
disabled={disabled}
|
|
280
|
+
size={size}
|
|
281
|
+
knobProps={knobProps}
|
|
282
|
+
onBlur={mergeFieldHandler(field, "handleBlur", onBlur)}
|
|
283
|
+
aria-label={ariaLabel}
|
|
284
|
+
placeholder={placeholder}
|
|
285
|
+
>
|
|
286
|
+
{renderControl(fieldValue, handleChange, !!resolvedError)}
|
|
287
|
+
</FieldLayout>
|
|
288
|
+
);
|
|
289
|
+
}}
|
|
290
|
+
</Field>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MarkdownEditorField — dedicated renderer for the Frappe `Markdown Editor`
|
|
3
|
-
* fieldtype:
|
|
4
|
-
* (
|
|
5
|
-
*
|
|
3
|
+
* fieldtype: the canonical house rich text editor constrained to markdown
|
|
4
|
+
* capabilities (LC-52 MARKDOWN-EXPRESSIBLE-EDITING). WYSIWYG editing whose
|
|
5
|
+
* surface is exactly what GFM can express — no highlight, underline, text
|
|
6
|
+
* color, or alignment — with `value`/`onChange` staying markdown strings
|
|
7
|
+
* (Frappe stores markdown text for this fieldtype).
|
|
6
8
|
*
|
|
7
9
|
* Full field contract: three-branch render (skeleton → standalone →
|
|
8
10
|
* form-integrated), canonical `onChange(value)` (LC-14), FieldLayout slot
|
|
9
11
|
* order, chromeless inside table cells (useIsInTableCell).
|
|
12
|
+
*
|
|
13
|
+
* Native renders the markdown source + preview fallback (see
|
|
14
|
+
* `index.native.tsx`) — 10Tap cannot honestly express the capability
|
|
15
|
+
* constraint, and a lookalike would violate the honest-gating posture.
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
18
|
import {
|
|
13
19
|
Field,
|
|
14
20
|
FieldLayout,
|
|
15
21
|
Skeleton,
|
|
16
|
-
TextArea,
|
|
17
|
-
formCommonColors,
|
|
18
22
|
getFieldError,
|
|
19
23
|
mergeFieldHandler,
|
|
20
24
|
useFormField,
|
|
@@ -22,84 +26,13 @@ import {
|
|
|
22
26
|
useResolvedValidators,
|
|
23
27
|
type AnyFormApi,
|
|
24
28
|
} from "@multiplatform.one/forms";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import type {
|
|
28
|
-
import {
|
|
29
|
-
import { useResolvedKnobs } from "@multiplatform.one/theme";
|
|
30
|
-
import { EditorPreviewPanes, PreviewSurface } from "../shared/EditorPreviewPanes";
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Preview (lazy house Markdown renderer)
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
type MarkdownComponent = ComponentType<{ children?: string }>;
|
|
37
|
-
|
|
38
|
-
// Module-level cache so the renderer loads once per session, not per field.
|
|
39
|
-
let markdownRendererCache: MarkdownComponent | null = null;
|
|
29
|
+
import { RichEditor, type RichEditorRef } from "@multiplatform.one/rich-text";
|
|
30
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
31
|
+
import type { LabelProps, SizeTokens } from "tamagui";
|
|
32
|
+
import { YStack } from "tamagui";
|
|
40
33
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
* renders (same optional-dependency pattern as the components shiki
|
|
44
|
-
* Highlighter): `@multiplatform.one/markdown` reaches consumers transitively
|
|
45
|
-
* via `@multiplatform.one/rich-text`, and a failed import degrades to a
|
|
46
|
-
* plain-text preview instead of crashing the field.
|
|
47
|
-
*/
|
|
48
|
-
function useMarkdownRenderer(enabled: boolean): MarkdownComponent | null {
|
|
49
|
-
const [renderer, setRenderer] = useState<MarkdownComponent | null>(markdownRendererCache);
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
if (!enabled || renderer) return;
|
|
53
|
-
let cancelled = false;
|
|
54
|
-
import("@multiplatform.one/markdown")
|
|
55
|
-
.then((mod) => {
|
|
56
|
-
markdownRendererCache = mod.Markdown as MarkdownComponent;
|
|
57
|
-
if (!cancelled) setRenderer(() => markdownRendererCache);
|
|
58
|
-
})
|
|
59
|
-
.catch(() => {
|
|
60
|
-
// Renderer unavailable — the plain-text fallback below stays up.
|
|
61
|
-
});
|
|
62
|
-
return () => {
|
|
63
|
-
cancelled = true;
|
|
64
|
-
};
|
|
65
|
-
}, [enabled, renderer]);
|
|
66
|
-
|
|
67
|
-
return renderer;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
interface MarkdownPreviewProps {
|
|
71
|
-
value: string;
|
|
72
|
-
compact?: boolean;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function MarkdownPreview({ value, compact }: MarkdownPreviewProps) {
|
|
76
|
-
const { t } = useTranslation();
|
|
77
|
-
const { knobProps } = useResolvedKnobs({ compact });
|
|
78
|
-
const Renderer = useMarkdownRenderer(!!value);
|
|
79
|
-
// Content type scale follows the size knob (same idiom as CodeBlock).
|
|
80
|
-
const textSize = knobProps.sizeToken as FontSizeTokens;
|
|
81
|
-
|
|
82
|
-
return (
|
|
83
|
-
<PreviewSurface compact={compact}>
|
|
84
|
-
{!value ? (
|
|
85
|
-
<Paragraph color={formCommonColors.muted} size={textSize}>
|
|
86
|
-
{t("Nothing to preview")}
|
|
87
|
-
</Paragraph>
|
|
88
|
-
) : Renderer ? (
|
|
89
|
-
<Renderer>{value}</Renderer>
|
|
90
|
-
) : (
|
|
91
|
-
// Loading / renderer unavailable: honest plain-text fallback.
|
|
92
|
-
<Paragraph size={textSize} whiteSpace="pre-wrap">
|
|
93
|
-
{value}
|
|
94
|
-
</Paragraph>
|
|
95
|
-
)}
|
|
96
|
-
</PreviewSurface>
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
// MarkdownEditorField
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
34
|
+
/** Approximate source-row height used to map the row props onto the editor */
|
|
35
|
+
const ROW_HEIGHT = 24;
|
|
103
36
|
|
|
104
37
|
export interface MarkdownEditorFieldProps {
|
|
105
38
|
name?: string;
|
|
@@ -122,9 +55,9 @@ export interface MarkdownEditorFieldProps {
|
|
|
122
55
|
onChange?: (value: string) => void;
|
|
123
56
|
onBlur?: (...args: any[]) => void;
|
|
124
57
|
placeholder?: string;
|
|
125
|
-
/** Minimum visible
|
|
58
|
+
/** Minimum visible content rows (default 6). */
|
|
126
59
|
minRows?: number;
|
|
127
|
-
/** Maximum
|
|
60
|
+
/** Maximum content rows before the editor scrolls (default 16). */
|
|
128
61
|
maxRows?: number;
|
|
129
62
|
"aria-label"?: string;
|
|
130
63
|
}
|
|
@@ -153,21 +86,28 @@ export function MarkdownEditorField({
|
|
|
153
86
|
maxRows = 16,
|
|
154
87
|
"aria-label": ariaLabel,
|
|
155
88
|
}: MarkdownEditorFieldProps) {
|
|
156
|
-
const { t } = useTranslation();
|
|
157
89
|
const { resolvedForm, knobProps, id } = useFormField({ form: formProp, id: idProp, compact });
|
|
158
90
|
const resolvedValidators = useResolvedValidators(required, validators, label, name);
|
|
159
91
|
const inTableCell = useIsInTableCell();
|
|
160
92
|
|
|
161
|
-
|
|
162
|
-
//
|
|
93
|
+
const editorRef = useRef<RichEditorRef>(null);
|
|
94
|
+
// Last markdown the editor emitted (or was seeded with) — distinguishes
|
|
95
|
+
// echo renders from real external value changes so programmatic sync
|
|
96
|
+
// never clobbers the caret.
|
|
97
|
+
const lastEmittedRef = useRef<string>(value ?? defaultValue ?? "");
|
|
163
98
|
const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? "");
|
|
99
|
+
|
|
100
|
+
// External (controlled) value change: push into the editor without
|
|
101
|
+
// emitting onChange back (setMarkdown is emitUpdate: false).
|
|
164
102
|
useEffect(() => {
|
|
165
|
-
if (value
|
|
103
|
+
if (value === undefined) return;
|
|
104
|
+
setInternalValue(value);
|
|
105
|
+
if (value !== lastEmittedRef.current) {
|
|
106
|
+
lastEmittedRef.current = value;
|
|
107
|
+
editorRef.current?.setMarkdown(value);
|
|
108
|
+
}
|
|
166
109
|
}, [value]);
|
|
167
110
|
|
|
168
|
-
const accessibleName =
|
|
169
|
-
ariaLabel ?? (typeof label === "string" ? label : undefined) ?? placeholder;
|
|
170
|
-
|
|
171
111
|
if (skeleton) {
|
|
172
112
|
return (
|
|
173
113
|
<YStack {...knobProps.gap} width="100%">
|
|
@@ -177,60 +117,39 @@ export function MarkdownEditorField({
|
|
|
177
117
|
);
|
|
178
118
|
}
|
|
179
119
|
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
handleChange?: (
|
|
120
|
+
const renderControl = (
|
|
121
|
+
markdownValue: string,
|
|
122
|
+
handleChange?: (markdown: string) => void,
|
|
123
|
+
handleFieldBlur?: () => void,
|
|
183
124
|
hasError?: boolean,
|
|
184
125
|
) => (
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
126
|
+
<RichEditor
|
|
127
|
+
ref={editorRef}
|
|
128
|
+
capabilities="markdown"
|
|
129
|
+
initialContent={markdownValue}
|
|
130
|
+
onChange={(markdown) => {
|
|
189
131
|
if (disabled || readOnly) return;
|
|
190
|
-
|
|
191
|
-
|
|
132
|
+
lastEmittedRef.current = markdown;
|
|
133
|
+
handleChange?.(markdown);
|
|
134
|
+
onChange?.(markdown);
|
|
192
135
|
}}
|
|
136
|
+
onBlur={handleFieldBlur}
|
|
193
137
|
placeholder={placeholder}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
compact={compact}
|
|
138
|
+
editable={!disabled && !readOnly}
|
|
139
|
+
minHeight={minRows * ROW_HEIGHT}
|
|
140
|
+
maxHeight={maxRows * ROW_HEIGHT}
|
|
141
|
+
hasError={hasError}
|
|
199
142
|
size={size}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
autoCapitalize: "none",
|
|
205
|
-
autoCorrect: false,
|
|
206
|
-
spellCheck: false,
|
|
207
|
-
...(accessibleName ? { "aria-label": accessibleName } : {}),
|
|
208
|
-
}}
|
|
143
|
+
compact={compact || inTableCell}
|
|
144
|
+
// Table cells stay chromeless: no fixed toolbar, bubble on selection.
|
|
145
|
+
showToolbar={!inTableCell}
|
|
146
|
+
showBubbleToolbar={inTableCell}
|
|
209
147
|
/>
|
|
210
148
|
);
|
|
211
149
|
|
|
212
|
-
// Table cells get the chromeless source pane only (no tabs/preview chrome);
|
|
213
|
-
// InputParts inside the forms TextArea detects the cell context itself.
|
|
214
|
-
const renderControl = (
|
|
215
|
-
textValue: string,
|
|
216
|
-
handleChange?: (text: string) => void,
|
|
217
|
-
hasError?: boolean,
|
|
218
|
-
) => {
|
|
219
|
-
if (inTableCell) return renderSource(textValue, handleChange, hasError);
|
|
220
|
-
return (
|
|
221
|
-
<EditorPreviewPanes
|
|
222
|
-
editor={renderSource(textValue, handleChange, hasError)}
|
|
223
|
-
preview={<MarkdownPreview value={textValue} compact={compact} />}
|
|
224
|
-
editTabLabel={t("Write")}
|
|
225
|
-
previewTabLabel={t("Preview")}
|
|
226
|
-
compact={compact}
|
|
227
|
-
defaultPreview={!!readOnly}
|
|
228
|
-
/>
|
|
229
|
-
);
|
|
230
|
-
};
|
|
231
|
-
|
|
232
150
|
if (!resolvedForm || !name) {
|
|
233
|
-
|
|
151
|
+
const standaloneControl = renderControl(internalValue, setInternalValue, onBlur, !!error);
|
|
152
|
+
if (inTableCell) return standaloneControl;
|
|
234
153
|
return (
|
|
235
154
|
<FieldLayout
|
|
236
155
|
id={id}
|
|
@@ -246,7 +165,7 @@ export function MarkdownEditorField({
|
|
|
246
165
|
aria-label={ariaLabel}
|
|
247
166
|
placeholder={placeholder}
|
|
248
167
|
>
|
|
249
|
-
{
|
|
168
|
+
{standaloneControl}
|
|
250
169
|
</FieldLayout>
|
|
251
170
|
);
|
|
252
171
|
}
|
|
@@ -261,8 +180,14 @@ export function MarkdownEditorField({
|
|
|
261
180
|
{(field) => {
|
|
262
181
|
const resolvedError = getFieldError(field, error);
|
|
263
182
|
const fieldValue = typeof field.state.value === "string" ? field.state.value : "";
|
|
264
|
-
const handleChange = (
|
|
265
|
-
|
|
183
|
+
const handleChange = (markdown: string) => field.handleChange(markdown as any);
|
|
184
|
+
const formControl = renderControl(
|
|
185
|
+
fieldValue,
|
|
186
|
+
handleChange,
|
|
187
|
+
() => field.handleBlur(),
|
|
188
|
+
!!resolvedError,
|
|
189
|
+
);
|
|
190
|
+
if (inTableCell) return formControl;
|
|
266
191
|
return (
|
|
267
192
|
<FieldLayout
|
|
268
193
|
id={id}
|
|
@@ -278,7 +203,7 @@ export function MarkdownEditorField({
|
|
|
278
203
|
aria-label={ariaLabel}
|
|
279
204
|
placeholder={placeholder}
|
|
280
205
|
>
|
|
281
|
-
{
|
|
206
|
+
{formControl}
|
|
282
207
|
</FieldLayout>
|
|
283
208
|
);
|
|
284
209
|
}}
|