@everystack/admin 0.4.1 → 0.4.3
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/CHANGELOG.md +73 -1
- package/package.json +45 -11
- package/src/adapters/EverystackAdapter.ts +107 -4
- package/src/adapters/types.ts +7 -1
- package/src/components/CellText.tsx +18 -0
- package/src/components/FieldColumns.tsx +86 -0
- package/src/components/FormActionBar.tsx +45 -0
- package/src/components/InlineEnumCell.tsx +98 -0
- package/src/components/RowActionButton.tsx +24 -9
- package/src/components/TagsDisplay.tsx +22 -0
- package/src/components/TagsFieldInput.tsx +95 -0
- package/src/editors/rich-html-extensions.ts +63 -0
- package/src/editors/tiptap.tsx +121 -20
- package/src/editors/unsupported-markup.ts +150 -0
- package/src/hooks/useAdminData.ts +104 -24
- package/src/hooks/useListQuery.ts +45 -0
- package/src/hooks/useScopeCounts.ts +72 -0
- package/src/hooks/useScrollToFirstError.ts +74 -0
- package/src/index.ts +7 -0
- package/src/screens/AdminRouter.tsx +11 -0
- package/src/screens/ResourceCreate.tsx +74 -37
- package/src/screens/ResourceEdit.tsx +77 -40
- package/src/screens/ResourceList.tsx +204 -52
- package/src/screens/ResourceShow.tsx +57 -9
- package/src/types.ts +140 -4
- package/src/utils/format-cell-value.ts +5 -0
- package/src/utils/list-query.ts +199 -0
- package/src/utils/required.ts +10 -0
- package/src/utils/resolve-columns.ts +107 -0
- package/src/utils/row-actions.ts +28 -0
- package/src/utils/scope-counts.ts +63 -0
- package/src/utils/scopes.ts +38 -0
- package/src/utils/tags.ts +40 -0
- package/src/utils/view-filters.ts +1 -1
|
@@ -25,12 +25,16 @@ export interface RowActionButtonProps {
|
|
|
25
25
|
/** Value sent as `{ id }` to an rpc action (record[primaryKey]). */
|
|
26
26
|
recordId: string | number;
|
|
27
27
|
adapter: DataAdapter;
|
|
28
|
-
/** Re-fetch the current view after the action completes. */
|
|
28
|
+
/** Re-fetch the current view after the action completes or fails. */
|
|
29
29
|
refetch: () => Promise<void> | void;
|
|
30
30
|
/** Navigate to an admin path, when the route layer provides it. */
|
|
31
31
|
navigate?: (path: string) => void;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function messageOf(err: unknown): string {
|
|
35
|
+
return err instanceof Error ? err.message : String(err);
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
export function RowActionButton({
|
|
35
39
|
action,
|
|
36
40
|
record,
|
|
@@ -56,15 +60,26 @@ export function RowActionButton({
|
|
|
56
60
|
setBusy(true);
|
|
57
61
|
setError(null);
|
|
58
62
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
let failed = false;
|
|
64
|
+
try {
|
|
65
|
+
if (action.onPress) {
|
|
66
|
+
await action.onPress({ record, adapter, refetch, navigate });
|
|
67
|
+
} else if (action.rpc) {
|
|
68
|
+
if (!adapter.rpc) throw new Error('Adapter does not support RPC actions');
|
|
69
|
+
await adapter.rpc(action.rpc, { id: recordId });
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
failed = true;
|
|
73
|
+
setError(messageOf(err));
|
|
74
|
+
}
|
|
75
|
+
// Refetch on failure too: a delete of a row another session already
|
|
76
|
+
// removed fails, and the list must still drop the stale row.
|
|
77
|
+
try {
|
|
78
|
+
await refetch();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// A refetch fault never replaces the action's own error.
|
|
81
|
+
if (!failed) setError(messageOf(err));
|
|
64
82
|
}
|
|
65
|
-
await refetch();
|
|
66
|
-
} catch (err) {
|
|
67
|
-
setError(err instanceof Error ? err.message : String(err));
|
|
68
83
|
} finally {
|
|
69
84
|
setBusy(false);
|
|
70
85
|
setConfirming(false);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TagsDisplay — read-only pills for a `type: 'tags'` value on the show view.
|
|
3
|
+
* Labels come from the field's groups; a value no group lists shows as itself.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { View, Text } from 'react-native';
|
|
8
|
+
import { Badge } from '@everystack/ui';
|
|
9
|
+
import { tagLabel, tagValues } from '../utils/tags';
|
|
10
|
+
import type { FieldDefinition } from '../types';
|
|
11
|
+
|
|
12
|
+
export function TagsDisplay({ field, value }: { field: FieldDefinition; value: unknown }): React.ReactElement {
|
|
13
|
+
const tags = tagValues(value);
|
|
14
|
+
if (tags.length === 0) return <Text className="text-sm text-gray-900">{'—'}</Text>;
|
|
15
|
+
return (
|
|
16
|
+
<View testID={`tags-${field.name}`} className="flex-row flex-wrap gap-2">
|
|
17
|
+
{tags.map((tag, i) => (
|
|
18
|
+
<Badge key={`${i}-${tag}`} label={tagLabel(field, tag)} />
|
|
19
|
+
))}
|
|
20
|
+
</View>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TagsFieldInput
|
|
3
|
+
*
|
|
4
|
+
* Create/edit input for `type: 'tags'` (a text[] column). Each group from
|
|
5
|
+
* `field.tagGroups` is a row of pills. A press toggles that one value and
|
|
6
|
+
* keeps every other stored value in place. Stored values that no group lists
|
|
7
|
+
* show in an "Other" row as selected pills, so they stay visible and a save
|
|
8
|
+
* keeps them; pressing one removes it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { View, Text, Pressable } from 'react-native';
|
|
13
|
+
import { FormField, Badge } from '@everystack/ui';
|
|
14
|
+
import { tagValues, toggleTag, unlistedTags } from '../utils/tags';
|
|
15
|
+
import type { FieldDefinition } from '../types';
|
|
16
|
+
|
|
17
|
+
export interface TagsFieldInputProps {
|
|
18
|
+
field: FieldDefinition;
|
|
19
|
+
value: unknown;
|
|
20
|
+
error?: string;
|
|
21
|
+
onChange: (value: unknown) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PillOption {
|
|
25
|
+
value: string;
|
|
26
|
+
label: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function TagsFieldInput({ field, value, error, onChange }: TagsFieldInputProps): React.ReactElement {
|
|
30
|
+
const label = field.label ?? field.name;
|
|
31
|
+
const selected = tagValues(value);
|
|
32
|
+
const toggle = (tag: string): void => onChange(toggleTag(selected, tag));
|
|
33
|
+
const other = unlistedTags(field, selected);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<FormField label={label} error={error} required={field.validation?.required}>
|
|
37
|
+
{(field.tagGroups ?? []).map((group, i) => (
|
|
38
|
+
<PillRow
|
|
39
|
+
key={i}
|
|
40
|
+
testID={`tags-${field.name}-group-${i}`}
|
|
41
|
+
fieldName={field.name}
|
|
42
|
+
heading={group.label}
|
|
43
|
+
options={group.options.map(o => ({ value: o.value, label: o.label ?? o.value }))}
|
|
44
|
+
selected={selected}
|
|
45
|
+
onToggle={toggle}
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
{other.length > 0 && (
|
|
49
|
+
<PillRow
|
|
50
|
+
testID={`tags-${field.name}-other`}
|
|
51
|
+
fieldName={field.name}
|
|
52
|
+
heading="Other"
|
|
53
|
+
options={other.map(v => ({ value: v, label: v }))}
|
|
54
|
+
selected={selected}
|
|
55
|
+
onToggle={toggle}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
</FormField>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface PillRowProps {
|
|
63
|
+
testID: string;
|
|
64
|
+
fieldName: string;
|
|
65
|
+
heading?: string;
|
|
66
|
+
options: PillOption[];
|
|
67
|
+
selected: readonly string[];
|
|
68
|
+
onToggle: (tag: string) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function PillRow({ testID, fieldName, heading, options, selected, onToggle }: PillRowProps): React.ReactElement {
|
|
72
|
+
return (
|
|
73
|
+
<View testID={testID} className="mb-2">
|
|
74
|
+
{heading ? <Text className="text-xs font-medium text-gray-500 mb-1">{heading}</Text> : null}
|
|
75
|
+
<View className="flex-row flex-wrap gap-2">
|
|
76
|
+
{options.map(option => {
|
|
77
|
+
const isSelected = selected.includes(option.value);
|
|
78
|
+
return (
|
|
79
|
+
<Pressable
|
|
80
|
+
key={option.value}
|
|
81
|
+
testID={`tag-${fieldName}-${option.value}`}
|
|
82
|
+
accessibilityRole="button"
|
|
83
|
+
accessibilityLabel={option.label}
|
|
84
|
+
accessibilityState={{ selected: isSelected }}
|
|
85
|
+
hitSlop={6}
|
|
86
|
+
onPress={() => onToggle(option.value)}
|
|
87
|
+
>
|
|
88
|
+
<Badge label={option.label} variant={isSelected ? 'info' : 'default'} />
|
|
89
|
+
</Pressable>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</View>
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Node, type Extensions } from '@tiptap/react';
|
|
2
|
+
import type { DOMOutputSpec, TagParseRule } from '@tiptap/pm/model';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
4
|
+
import { Code } from '@tiptap/extension-code';
|
|
5
|
+
import { CodeBlock } from '@tiptap/extension-code-block';
|
|
6
|
+
import { Image } from '@tiptap/extension-image';
|
|
7
|
+
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
|
|
8
|
+
import { Subscript } from '@tiptap/extension-subscript';
|
|
9
|
+
import { Superscript } from '@tiptap/extension-superscript';
|
|
10
|
+
|
|
11
|
+
export const Figure = Node.create({
|
|
12
|
+
name: 'figure',
|
|
13
|
+
group: 'block',
|
|
14
|
+
content: 'image+ figcaption?',
|
|
15
|
+
isolating: true,
|
|
16
|
+
draggable: true,
|
|
17
|
+
parseHTML(): TagParseRule[] {
|
|
18
|
+
return [{
|
|
19
|
+
tag: 'figure',
|
|
20
|
+
// An unparseable image must not make ProseMirror invent a required image.
|
|
21
|
+
getAttrs: (element: HTMLElement): null | false => element.querySelector('img[src]') ? null : false,
|
|
22
|
+
}];
|
|
23
|
+
},
|
|
24
|
+
renderHTML(): DOMOutputSpec {
|
|
25
|
+
return ['figure', 0];
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const Figcaption = Node.create({
|
|
30
|
+
name: 'figcaption',
|
|
31
|
+
// Images are inline; the figure's content rule cannot mix inline and block nodes.
|
|
32
|
+
inline: true,
|
|
33
|
+
content: 'inline*',
|
|
34
|
+
defining: true,
|
|
35
|
+
parseHTML(): TagParseRule[] {
|
|
36
|
+
return [{ tag: 'figcaption', context: 'figure/' }];
|
|
37
|
+
},
|
|
38
|
+
renderHTML(): DOMOutputSpec {
|
|
39
|
+
return ['figcaption', 0];
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export function richHtmlExtensions(): Extensions {
|
|
44
|
+
return [
|
|
45
|
+
StarterKit.configure({
|
|
46
|
+
code: false,
|
|
47
|
+
codeBlock: false,
|
|
48
|
+
link: { openOnClick: false, HTMLAttributes: { target: null, rel: null } },
|
|
49
|
+
}),
|
|
50
|
+
// Preserve links without changing code's treatment of the other stock marks.
|
|
51
|
+
Code.extend({ excludes: 'bold italic underline strike subscript superscript code' }),
|
|
52
|
+
CodeBlock.extend({ marks: 'link' }),
|
|
53
|
+
Image.configure({ inline: true, allowBase64: true }),
|
|
54
|
+
Figure,
|
|
55
|
+
Figcaption,
|
|
56
|
+
Table,
|
|
57
|
+
TableRow,
|
|
58
|
+
TableCell,
|
|
59
|
+
TableHeader,
|
|
60
|
+
Subscript,
|
|
61
|
+
Superscript,
|
|
62
|
+
];
|
|
63
|
+
}
|
package/src/editors/tiptap.tsx
CHANGED
|
@@ -2,23 +2,29 @@
|
|
|
2
2
|
* TipTap editor adapter — a reference EditorRegistry plugin (HTML).
|
|
3
3
|
*
|
|
4
4
|
* Opt-in and web-only. The core admin never imports this; tiptap is an
|
|
5
|
-
* optional peer dependency. To use:
|
|
5
|
+
* optional peer dependency. Tiptap 3 is required. To use:
|
|
6
6
|
*
|
|
7
|
-
* pnpm add @tiptap/react @tiptap/starter-kit @tiptap/pm
|
|
7
|
+
* pnpm add @tiptap/react@^3 @tiptap/starter-kit@^3 @tiptap/pm@^3
|
|
8
|
+
* @tiptap/extension-image@^3 @tiptap/extension-table@^3
|
|
9
|
+
* @tiptap/extension-subscript@^3 @tiptap/extension-superscript@^3
|
|
10
|
+
* @tiptap/extension-code@^3 @tiptap/extension-code-block@^3
|
|
8
11
|
* import { TiptapEditor } from '@everystack/admin/editors/tiptap';
|
|
9
12
|
* <AdminShell editors={{ tiptap: TiptapEditor }} ... />
|
|
10
13
|
* // field: { name: 'body', type: 'string', editor: 'tiptap', format: 'html' }
|
|
11
14
|
*
|
|
12
|
-
* Reads and writes an HTML string via the EditorProps contract. The
|
|
15
|
+
* Reads and writes an HTML string via the EditorProps contract. The schema
|
|
16
|
+
* keeps inline images, figures with captions, tables, links, underline,
|
|
17
|
+
* subscript and superscript. The toolbar
|
|
13
18
|
* uses React Native primitives; the editable surface is tiptap's EditorContent
|
|
14
19
|
* (a DOM contenteditable, which is why this is web-only). See
|
|
15
20
|
* docs/plans/admin-editors.md.
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
|
-
import React from 'react';
|
|
19
|
-
import { View, Text, Pressable } from 'react-native';
|
|
23
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
24
|
+
import { View, Text, Pressable, TextInput } from 'react-native';
|
|
20
25
|
import { useEditor, EditorContent } from '@tiptap/react';
|
|
21
|
-
import
|
|
26
|
+
import { richHtmlExtensions } from './rich-html-extensions';
|
|
27
|
+
import { unsupportedMarkup } from './unsupported-markup';
|
|
22
28
|
import type { EditorProps } from '../types';
|
|
23
29
|
|
|
24
30
|
interface ToolbarButtonProps {
|
|
@@ -40,29 +46,124 @@ function ToolbarButton({ label, active, onPress }: ToolbarButtonProps): React.Re
|
|
|
40
46
|
);
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
|
|
49
|
+
// The registry calls adapters as plain functions, so hooks belong in the element.
|
|
50
|
+
export function TiptapEditor(props: EditorProps): React.ReactElement {
|
|
51
|
+
return <RichHtmlEditor {...props} />;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function RichHtmlEditor({ value, onChange, error }: EditorProps): React.ReactElement {
|
|
55
|
+
const incoming = typeof value === 'string' ? value : '';
|
|
56
|
+
const [html, setHtml] = useState(incoming);
|
|
57
|
+
const [mode, setMode] = useState<'visual' | 'html'>(() =>
|
|
58
|
+
unsupportedMarkup(incoming).length > 0 ? 'html' : 'visual');
|
|
59
|
+
const original = useRef(incoming);
|
|
60
|
+
const dirty = useRef(false);
|
|
61
|
+
const currentMode = useRef(mode);
|
|
62
|
+
const loadingContent = useRef(false);
|
|
63
|
+
const extensions = useMemo(richHtmlExtensions, []);
|
|
64
|
+
const unsupported = useMemo(() => unsupportedMarkup(html), [html]);
|
|
44
65
|
const editor = useEditor({
|
|
45
|
-
extensions
|
|
46
|
-
content:
|
|
66
|
+
extensions,
|
|
67
|
+
content: mode === 'visual' ? html : '',
|
|
47
68
|
// Render on the client only — the admin shell SSRs, and a contenteditable
|
|
48
69
|
// can't hydrate from server output.
|
|
49
70
|
immediatelyRender: false,
|
|
50
|
-
onUpdate: ({ editor }) =>
|
|
71
|
+
onUpdate: ({ editor, transaction, appendedTransactions }) => {
|
|
72
|
+
// Loading/normalizing is not a user edit. setContent below also suppresses
|
|
73
|
+
// update events; the initialization guard covers plugin updates on load.
|
|
74
|
+
if (loadingContent.current || !editor.isInitialized || currentMode.current !== 'visual'
|
|
75
|
+
|| ![transaction, ...appendedTransactions].some(tr => tr.docChanged)) return;
|
|
76
|
+
dirty.current = true;
|
|
77
|
+
const next = editor.getHTML();
|
|
78
|
+
setHtml(next);
|
|
79
|
+
onChange(next);
|
|
80
|
+
},
|
|
51
81
|
});
|
|
52
82
|
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (dirty.current || incoming === original.current) return;
|
|
85
|
+
original.current = incoming;
|
|
86
|
+
const nextMode = unsupportedMarkup(incoming).length > 0 ? 'html' : 'visual';
|
|
87
|
+
currentMode.current = nextMode;
|
|
88
|
+
setHtml(incoming);
|
|
89
|
+
setMode(nextMode);
|
|
90
|
+
if (editor) {
|
|
91
|
+
loadingContent.current = true;
|
|
92
|
+
try {
|
|
93
|
+
editor.commands.setContent(nextMode === 'visual' ? incoming : '', { emitUpdate: false });
|
|
94
|
+
} finally {
|
|
95
|
+
loadingContent.current = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}, [incoming, editor]);
|
|
99
|
+
|
|
100
|
+
const showVisual = () => {
|
|
101
|
+
if (mode === 'visual' || unsupported.length > 0 || !editor) return;
|
|
102
|
+
loadingContent.current = true;
|
|
103
|
+
try {
|
|
104
|
+
editor.commands.setContent(html, { emitUpdate: false });
|
|
105
|
+
} finally {
|
|
106
|
+
loadingContent.current = false;
|
|
107
|
+
}
|
|
108
|
+
currentMode.current = 'visual';
|
|
109
|
+
setMode('visual');
|
|
110
|
+
};
|
|
111
|
+
const showHtml = () => {
|
|
112
|
+
if (mode === 'html') return;
|
|
113
|
+
// Keep stored bytes until an edit, even if Tiptap normalized its own copy.
|
|
114
|
+
setHtml(dirty.current && editor ? editor.getHTML() : original.current);
|
|
115
|
+
currentMode.current = 'html';
|
|
116
|
+
setMode('html');
|
|
117
|
+
};
|
|
118
|
+
|
|
53
119
|
return (
|
|
54
120
|
<View>
|
|
55
|
-
<View className="flex-row
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
121
|
+
<View className="flex-row gap-1 mb-2">
|
|
122
|
+
<Pressable onPress={showVisual} disabled={unsupported.length > 0}
|
|
123
|
+
accessibilityRole="button" accessibilityState={{ disabled: unsupported.length > 0, selected: mode === 'visual' }}
|
|
124
|
+
className={unsupported.length > 0 ? 'px-2 py-1 rounded opacity-50' : 'px-2 py-1 rounded border border-gray-200'}>
|
|
125
|
+
<Text className="text-sm text-gray-700">Visual</Text>
|
|
126
|
+
</Pressable>
|
|
127
|
+
<Pressable onPress={showHtml} accessibilityRole="button" accessibilityState={{ selected: mode === 'html' }}
|
|
128
|
+
className="px-2 py-1 rounded border border-gray-200">
|
|
129
|
+
<Text className="text-sm text-gray-700">HTML</Text>
|
|
130
|
+
</Pressable>
|
|
65
131
|
</View>
|
|
132
|
+
{unsupported.length > 0 ? (
|
|
133
|
+
<Text className="text-sm text-gray-700 mb-2">
|
|
134
|
+
{`This content has ${unsupported.map(({ name, count }) => `${count} ${name}`).join(' and ')} that the visual editor would delete. Edit the HTML, or remove them to use the visual editor.`}
|
|
135
|
+
</Text>
|
|
136
|
+
) : null}
|
|
137
|
+
{mode === 'html' ? (
|
|
138
|
+
<TextInput
|
|
139
|
+
accessibilityLabel="HTML source"
|
|
140
|
+
multiline
|
|
141
|
+
value={html}
|
|
142
|
+
onChangeText={next => {
|
|
143
|
+
dirty.current = true;
|
|
144
|
+
setHtml(next);
|
|
145
|
+
onChange(next);
|
|
146
|
+
}}
|
|
147
|
+
autoCapitalize="none"
|
|
148
|
+
autoCorrect={false}
|
|
149
|
+
style={{ fontFamily: 'monospace', minHeight: 200, textAlignVertical: 'top' }}
|
|
150
|
+
className="border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900"
|
|
151
|
+
/>
|
|
152
|
+
) : (
|
|
153
|
+
<>
|
|
154
|
+
<View className="flex-row flex-wrap gap-1 mb-2">
|
|
155
|
+
<ToolbarButton label="B" active={editor?.isActive('bold')} onPress={() => editor?.chain().focus().toggleBold().run()} />
|
|
156
|
+
<ToolbarButton label="I" active={editor?.isActive('italic')} onPress={() => editor?.chain().focus().toggleItalic().run()} />
|
|
157
|
+
<ToolbarButton label="H2" active={editor?.isActive('heading', { level: 2 })} onPress={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} />
|
|
158
|
+
<ToolbarButton label="List" active={editor?.isActive('bulletList')} onPress={() => editor?.chain().focus().toggleBulletList().run()} />
|
|
159
|
+
<ToolbarButton label="1." active={editor?.isActive('orderedList')} onPress={() => editor?.chain().focus().toggleOrderedList().run()} />
|
|
160
|
+
<ToolbarButton label="Quote" active={editor?.isActive('blockquote')} onPress={() => editor?.chain().focus().toggleBlockquote().run()} />
|
|
161
|
+
</View>
|
|
162
|
+
<View className="border border-gray-200 rounded-lg px-3 py-2">
|
|
163
|
+
<EditorContent editor={editor} />
|
|
164
|
+
</View>
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
66
167
|
{error ? <Text className="text-xs text-red-600 mt-1">{error}</Text> : null}
|
|
67
168
|
</View>
|
|
68
169
|
);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const UNSUPPORTED_ELEMENTS = new Set([
|
|
2
|
+
'iframe', 'video', 'audio', 'picture', 'source', 'svg', 'math', 'form', 'input',
|
|
3
|
+
'button', 'select', 'textarea', 'noscript', 'embed', 'object',
|
|
4
|
+
]);
|
|
5
|
+
|
|
6
|
+
// These bodies may be text, depending on the parser's tree and scripting mode.
|
|
7
|
+
const TEXT_ELEMENTS = new Set([
|
|
8
|
+
'iframe', 'script', 'style', 'textarea', 'title', 'xmp', 'noembed', 'noframes', 'noscript',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
interface Tag {
|
|
12
|
+
name: string;
|
|
13
|
+
closing: boolean;
|
|
14
|
+
hasSource: boolean;
|
|
15
|
+
end: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isSpace(char: string): boolean {
|
|
19
|
+
return char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '\f';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readTag(html: string, start: number, limit: number): Tag | null {
|
|
23
|
+
const opening = /<(\/?)([a-z][^\t\n\f\r />]*)/iy;
|
|
24
|
+
opening.lastIndex = start;
|
|
25
|
+
const match = opening.exec(html);
|
|
26
|
+
if (!match || opening.lastIndex > limit) return null;
|
|
27
|
+
|
|
28
|
+
let cursor = opening.lastIndex;
|
|
29
|
+
let sourceSeen = false;
|
|
30
|
+
let hasSource = false;
|
|
31
|
+
while (cursor < limit) {
|
|
32
|
+
if (html[cursor] === '>') {
|
|
33
|
+
const name = match[2].toLowerCase();
|
|
34
|
+
return { name: name === 'image' ? 'img' : name, closing: match[1] === '/', hasSource, end: cursor + 1 };
|
|
35
|
+
}
|
|
36
|
+
if (isSpace(html[cursor]) || html[cursor] === '/') {
|
|
37
|
+
cursor++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const attrStart = cursor;
|
|
42
|
+
// Always consume a character, including malformed attribute names beginning with '='.
|
|
43
|
+
cursor++;
|
|
44
|
+
while (cursor < limit && !isSpace(html[cursor]) && !'/=>'.includes(html[cursor])) cursor++;
|
|
45
|
+
const attribute = html.slice(attrStart, cursor).toLowerCase();
|
|
46
|
+
while (cursor < limit && isSpace(html[cursor])) cursor++;
|
|
47
|
+
let value = '';
|
|
48
|
+
if (html[cursor] === '=') {
|
|
49
|
+
cursor++;
|
|
50
|
+
while (cursor < limit && isSpace(html[cursor])) cursor++;
|
|
51
|
+
const quote = html[cursor];
|
|
52
|
+
if (quote === '"' || quote === "'") {
|
|
53
|
+
const valueStart = ++cursor;
|
|
54
|
+
cursor = html.indexOf(quote, cursor);
|
|
55
|
+
if (cursor === -1 || cursor >= limit) return null;
|
|
56
|
+
value = html.slice(valueStart, cursor++);
|
|
57
|
+
} else {
|
|
58
|
+
const valueStart = cursor;
|
|
59
|
+
while (cursor < limit && !isSpace(html[cursor]) && html[cursor] !== '>') cursor++;
|
|
60
|
+
value = html.slice(valueStart, cursor);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Duplicate HTML attributes keep their first value.
|
|
64
|
+
if (attribute === 'src' && !sourceSeen) {
|
|
65
|
+
sourceSeen = true;
|
|
66
|
+
hasSource = value.length > 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null; // An unfinished tag at EOF creates no element.
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Content-bearing elements outside the stock editor schema, in first-occurrence order.
|
|
73
|
+
* This scans HTML tokens without a DOM; it is not an HTML sanitizer.
|
|
74
|
+
*/
|
|
75
|
+
export function unsupportedMarkup(html: string): Array<{ kind: 'element'; name: string; count: number }> {
|
|
76
|
+
if (typeof html !== 'string' || !html) return [];
|
|
77
|
+
const found = new Map<string, { kind: 'element'; name: string; count: number }>();
|
|
78
|
+
const ranges = [{ start: 0, end: html.length }];
|
|
79
|
+
// Foreign content, select/frameset parsing and noscript can change text-element rules.
|
|
80
|
+
// Once these occur, scan text bodies conservatively rather than guessing the tree.
|
|
81
|
+
let uncertainText = false;
|
|
82
|
+
while (ranges.length) {
|
|
83
|
+
const range = ranges.pop()!;
|
|
84
|
+
let cursor = range.start;
|
|
85
|
+
let limit = range.end;
|
|
86
|
+
while ((cursor = html.indexOf('<', cursor)) !== -1 && cursor < limit) {
|
|
87
|
+
if (html.startsWith('<!--', cursor)) {
|
|
88
|
+
cursor += 4;
|
|
89
|
+
if (html[cursor] === '>') cursor++;
|
|
90
|
+
else if (html.startsWith('->', cursor)) cursor += 2;
|
|
91
|
+
else {
|
|
92
|
+
const ending = /--!?>/g;
|
|
93
|
+
ending.lastIndex = cursor;
|
|
94
|
+
if (!ending.exec(html) || ending.lastIndex > limit) break;
|
|
95
|
+
cursor = ending.lastIndex;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Declarations (including CDATA outside foreign content) and processing instructions
|
|
100
|
+
// consume through the first '>'. Doing so in foreign content can only over-report.
|
|
101
|
+
if (html.startsWith('<!', cursor) || html.startsWith('<?', cursor)) {
|
|
102
|
+
const end = html.indexOf('>', cursor + 2);
|
|
103
|
+
if (end === -1 || end >= limit) break;
|
|
104
|
+
cursor = end + 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tag = readTag(html, cursor, limit);
|
|
109
|
+
if (!tag) {
|
|
110
|
+
// A real tag prefix consumes to EOF if its attributes or closing bracket never finish.
|
|
111
|
+
if (/^<\/?[a-z]/i.test(html.slice(cursor, cursor + 3))) break;
|
|
112
|
+
cursor++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
cursor = tag.end;
|
|
116
|
+
if (tag.closing) continue;
|
|
117
|
+
|
|
118
|
+
const name = UNSUPPORTED_ELEMENTS.has(tag.name) ? tag.name
|
|
119
|
+
: tag.name === 'img' && !tag.hasSource ? 'img[no-src]' : null;
|
|
120
|
+
if (name) {
|
|
121
|
+
const entry = found.get(name);
|
|
122
|
+
if (entry) entry.count++;
|
|
123
|
+
else found.set(name, { kind: 'element', name, count: 1 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (['svg', 'math', 'select', 'frameset', 'noscript'].includes(tag.name)) uncertainText = true;
|
|
127
|
+
if (tag.name === 'plaintext' && !uncertainText) break;
|
|
128
|
+
if (TEXT_ELEMENTS.has(tag.name)) {
|
|
129
|
+
const closing = new RegExp(`</${tag.name}(?=[\\t\\n\\f\\r />])`, 'ig');
|
|
130
|
+
closing.lastIndex = cursor;
|
|
131
|
+
const end = closing.exec(html);
|
|
132
|
+
// Escaped scripts can contain apparent closing tags that do not end the script.
|
|
133
|
+
// Scan possible suffixes conservatively instead of trusting the first one.
|
|
134
|
+
if (tag.name === 'script') {
|
|
135
|
+
const escaped = html.indexOf('<!--', cursor);
|
|
136
|
+
if (escaped !== -1 && escaped < (end?.index ?? limit)) uncertainText = true;
|
|
137
|
+
}
|
|
138
|
+
if (!end || end.index >= limit) {
|
|
139
|
+
if (!uncertainText) break;
|
|
140
|
+
} else if (uncertainText) {
|
|
141
|
+
// A quote or comment in a possible text body must not swallow its closing tag
|
|
142
|
+
// and the real elements after it. Scan the body and the suffix separately.
|
|
143
|
+
ranges.push({ start: end.index, end: limit });
|
|
144
|
+
limit = end.index;
|
|
145
|
+
} else cursor = end.index;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return Array.from(found.values());
|
|
150
|
+
}
|