@godxjp/editor 31.18.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/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @godxjp/editor
2
+
3
+ The Markdown editor for GoDX apps: a formatting toolbar, write / preview / side by side, a preview
4
+ rendered by [`@godxjp/markdown`](../markdown) (the same sanitiser as the published page), and files
5
+ pasted, dropped or attached through an upload function **the host** provides.
6
+
7
+ ```bash
8
+ pnpm add @godxjp/editor @godxjp/markdown @godxjp/ui lucide-react
9
+ ```
10
+
11
+ ```tsx
12
+ import { MarkdownEditor } from "@godxjp/editor";
13
+ import { FormField } from "@godxjp/ui/data-entry";
14
+
15
+ <FormField id="body" label="本文">
16
+ <MarkdownEditor
17
+ value={body}
18
+ onValueChange={setBody}
19
+ upload={async (file) => ({ url: await storage.put(file) })}
20
+ />
21
+ </FormField>;
22
+ ```
23
+
24
+ ## Behaviour
25
+
26
+ - It is the kit's `Textarea`: Japanese IME composition, the native caret, spell-check and the
27
+ browser's undo keep working. Every toolbar edit is **one** undo step.
28
+ - ⌘/Ctrl + B, I, K — bold, italic, link. Never fire during IME composition.
29
+ - The toolbar is a WAI-ARIA toolbar (one tab stop, arrow keys). In preview, or when `disabled` /
30
+ `readOnly`, its actions stay focusable but do nothing (`aria-disabled`).
31
+ - Files: `upload(file) => Promise<{ url, name? }>`. A placeholder marks each upload and is replaced
32
+ by `![name](url)` (images) or `[name](url)`; a failure removes it and says which file failed.
33
+ `uploadBlockedReason` refuses files with the host's message. Without `upload`, files are ignored.
34
+ - Strings come from the kit's catalogue in ja / en / vi; `labels` overrides any of them.
35
+
36
+ ## Props
37
+
38
+ | prop | |
39
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
40
+ | `value` / `defaultValue` / `onValueChange` | The Markdown text. |
41
+ | `mode` / `defaultMode` / `onModeChange` | `"write" \| "preview" \| "split"`. |
42
+ | `upload`, `uploadBlockedReason` | Files, through the host's storage. |
43
+ | `renderPreview(value)` | Replace the preview (an app with its own embeds). |
44
+ | `actions` | Extra toolbar actions `{ key, label, icon, run(api) }` — the extension point for new block types; `api` has `value`, `selection`, `edit`, `insert`, `focus`. |
45
+ | `labels` | Override strings. |
46
+ | `rows`, `disabled`, `readOnly`, `id`, `aria-*`, … | Passed to the textarea. |
47
+
48
+ `onKeyDown` from the host runs first; if it calls `preventDefault()`, the editor's shortcut does not
49
+ run — so a mention or link autocomplete wrapped around the editor keeps its keys.
@@ -0,0 +1,211 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * The text operations behind the toolbar and the shortcuts, as pure functions of
5
+ * (text, selection) → (replacement range, new selection). Pure so they are tested without a DOM,
6
+ * and so the editor can apply them through `execCommand("insertText")`, which keeps the
7
+ * browser's own undo stack (a value rewrite would erase it).
8
+ */
9
+ type Selection = {
10
+ start: number;
11
+ end: number;
12
+ };
13
+ /** Replace `[from, to)` with `insert`, then select `select` (absolute offsets in the new text). */
14
+ type TextEdit = {
15
+ from: number;
16
+ to: number;
17
+ insert: string;
18
+ select: Selection;
19
+ };
20
+ /**
21
+ * Wrap the selection in `before`…`after` (bold, italic, code). With nothing selected, inserts the
22
+ * markers around `placeholder` and selects the placeholder, so typing replaces it. A selection that
23
+ * is already wrapped is unwrapped — the toolbar toggles, as every editor's B button does.
24
+ */
25
+ declare function wrapSelection(text: string, { start, end }: Selection, before: string, after: string, placeholder: string): TextEdit;
26
+ /**
27
+ * Prefix every line the selection touches (`## `, `- `, `> `, `- [ ] `). `numbered` writes
28
+ * `1. `, `2. `… If every touched line already carries the prefix, it is removed instead.
29
+ */
30
+ declare function prefixLines(text: string, selection: Selection, prefix: string, options?: {
31
+ numbered?: boolean;
32
+ }): TextEdit;
33
+ /** `[text](url)`: the selection becomes the text and `url` is selected for typing. */
34
+ declare function insertLink(text: string, { start, end }: Selection, placeholder: string): TextEdit;
35
+ /** A fenced block on its own lines around the selection. */
36
+ declare function insertCodeBlock(text: string, { start, end }: Selection): TextEdit;
37
+ /** Apply an edit to a string (the fallback when `execCommand` is unavailable, and in tests). */
38
+ declare function applyEdit(text: string, edit: TextEdit): string;
39
+ /** Markdown for an uploaded file: an image embed for images, a link otherwise. */
40
+ declare function uploadedMarkdown(file: {
41
+ name: string;
42
+ type: string;
43
+ }, url: string, name?: string): string;
44
+
45
+ /**
46
+ * The editor's strings, under its own namespace (`markdownEditor`), registered with the kit's
47
+ * catalogue the first time an editor renders. Registered from the component, not from a top-level
48
+ * side-effect import: with `sideEffects: false` a bundler is allowed to drop an import that binds
49
+ * nothing, and the editor would then render raw `markdownEditor.*` keys.
50
+ */
51
+ declare const EDITOR_MESSAGES: {
52
+ readonly ja: {
53
+ readonly toolbar: "書式";
54
+ readonly mode: "表示";
55
+ readonly write: "編集";
56
+ readonly preview: "プレビュー";
57
+ readonly split: "並べて表示";
58
+ readonly bold: "太字";
59
+ readonly italic: "斜体";
60
+ readonly strike: "取り消し線";
61
+ readonly heading: "見出し";
62
+ readonly link: "リンク";
63
+ readonly code: "コード";
64
+ readonly codeBlock: "コードブロック";
65
+ readonly bulletList: "箇条書き";
66
+ readonly numberedList: "番号付きリスト";
67
+ readonly taskList: "チェックリスト";
68
+ readonly quote: "引用";
69
+ readonly attach: "ファイルを添付";
70
+ readonly placeholderText: "テキスト";
71
+ readonly placeholderLink: "リンクの文字";
72
+ readonly emptyPreview: "プレビューする内容はまだありません。";
73
+ readonly uploading: "アップロード中";
74
+ readonly uploadingStatus: "アップロード中…";
75
+ readonly uploadFailed: "{name} をアップロードできませんでした。";
76
+ };
77
+ readonly en: {
78
+ readonly toolbar: "Formatting";
79
+ readonly mode: "View";
80
+ readonly write: "Write";
81
+ readonly preview: "Preview";
82
+ readonly split: "Side by side";
83
+ readonly bold: "Bold";
84
+ readonly italic: "Italic";
85
+ readonly strike: "Strikethrough";
86
+ readonly heading: "Heading";
87
+ readonly link: "Link";
88
+ readonly code: "Code";
89
+ readonly codeBlock: "Code block";
90
+ readonly bulletList: "Bulleted list";
91
+ readonly numberedList: "Numbered list";
92
+ readonly taskList: "Checklist";
93
+ readonly quote: "Quote";
94
+ readonly attach: "Attach a file";
95
+ readonly placeholderText: "text";
96
+ readonly placeholderLink: "link text";
97
+ readonly emptyPreview: "Nothing to preview yet.";
98
+ readonly uploading: "Uploading";
99
+ readonly uploadingStatus: "Uploading…";
100
+ readonly uploadFailed: "Could not upload {name}.";
101
+ };
102
+ readonly vi: {
103
+ readonly toolbar: "Định dạng";
104
+ readonly mode: "Chế độ xem";
105
+ readonly write: "Soạn";
106
+ readonly preview: "Xem trước";
107
+ readonly split: "Song song";
108
+ readonly bold: "In đậm";
109
+ readonly italic: "In nghiêng";
110
+ readonly strike: "Gạch ngang";
111
+ readonly heading: "Tiêu đề";
112
+ readonly link: "Liên kết";
113
+ readonly code: "Mã";
114
+ readonly codeBlock: "Khối mã";
115
+ readonly bulletList: "Danh sách gạch đầu dòng";
116
+ readonly numberedList: "Danh sách đánh số";
117
+ readonly taskList: "Danh sách việc";
118
+ readonly quote: "Trích dẫn";
119
+ readonly attach: "Đính kèm tệp";
120
+ readonly placeholderText: "văn bản";
121
+ readonly placeholderLink: "chữ của liên kết";
122
+ readonly emptyPreview: "Chưa có nội dung để xem trước.";
123
+ readonly uploading: "Đang tải lên";
124
+ readonly uploadingStatus: "Đang tải lên…";
125
+ readonly uploadFailed: "Không tải lên được {name}.";
126
+ };
127
+ };
128
+ type MarkdownEditorLabels = {
129
+ [K in keyof (typeof EDITOR_MESSAGES)["en"]]: string;
130
+ };
131
+
132
+ type MarkdownEditorMode = "write" | "preview" | "split";
133
+ /** What a custom toolbar action (a later "board" block, a template picker) is handed. */
134
+ type MarkdownEditorApi = {
135
+ value: () => string;
136
+ selection: () => Selection;
137
+ /** Replace a range and select inside the result — one undo step. */
138
+ edit: (edit: TextEdit) => void;
139
+ /** Insert at the caret, replacing any selection. */
140
+ insert: (text: string) => void;
141
+ focus: () => void;
142
+ };
143
+ type MarkdownEditorAction = {
144
+ key: string;
145
+ /** Accessible name and tooltip. */
146
+ label: string;
147
+ icon: React.ReactNode;
148
+ run: (api: MarkdownEditorApi) => void;
149
+ };
150
+ /** What the host's storage returns for one file. */
151
+ type MarkdownEditorUploadResult = {
152
+ url: string;
153
+ name?: string;
154
+ };
155
+ type MarkdownEditorProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "defaultValue" | "onChange" | "children"> & {
156
+ value?: string;
157
+ defaultValue?: string;
158
+ onValueChange?: (value: string) => void;
159
+ mode?: MarkdownEditorMode;
160
+ defaultMode?: MarkdownEditorMode;
161
+ onModeChange?: (mode: MarkdownEditorMode) => void;
162
+ /**
163
+ * Stores a pasted, dropped or attached file and returns its URL. The HOST decides where bytes go
164
+ * (an app's own storage today, the media service later). Omit it and files are not accepted.
165
+ */
166
+ upload?: (file: File) => Promise<MarkdownEditorUploadResult>;
167
+ /** When set, files are refused with this message (a quota, a read-only space). */
168
+ uploadBlockedReason?: string | null;
169
+ /** The preview. Default: `<Prose><Markdown>{value}</Markdown></Prose>` from `@godxjp/markdown`. */
170
+ renderPreview?: (value: string) => React.ReactNode;
171
+ /** Extra toolbar actions, after the built-in ones — the extension point for new block types. */
172
+ actions?: readonly MarkdownEditorAction[];
173
+ /** Override any string the editor renders (defaults come from the kit's ja / en / vi catalogue). */
174
+ labels?: Partial<MarkdownEditorLabels>;
175
+ rows?: number;
176
+ };
177
+ /**
178
+ * A Markdown editor (gh#1109): a formatting toolbar, write / preview / side-by-side, the preview
179
+ * rendered by `@godxjp/markdown` (the same sanitiser the published page uses), and paste / drop /
180
+ * attach of files through a host-provided `upload`.
181
+ *
182
+ * It is the kit's `Textarea`, not a rich-text surface: Japanese IME composition, the native caret,
183
+ * spell-check and the browser's undo all keep working, and every edit the toolbar makes goes
184
+ * through `execCommand("insertText")` so it is ONE undo step. Shortcuts (⌘/Ctrl + B, I, K) never
185
+ * fire mid-composition. The toolbar is the kit's `Actions` — a WAI-ARIA toolbar with one tab stop
186
+ * and arrow-key movement.
187
+ */
188
+ declare const MarkdownEditor: React.ForwardRefExoticComponent<Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "children" | "onChange" | "value" | "defaultValue"> & {
189
+ value?: string;
190
+ defaultValue?: string;
191
+ onValueChange?: (value: string) => void;
192
+ mode?: MarkdownEditorMode;
193
+ defaultMode?: MarkdownEditorMode;
194
+ onModeChange?: (mode: MarkdownEditorMode) => void;
195
+ /**
196
+ * Stores a pasted, dropped or attached file and returns its URL. The HOST decides where bytes go
197
+ * (an app's own storage today, the media service later). Omit it and files are not accepted.
198
+ */
199
+ upload?: (file: File) => Promise<MarkdownEditorUploadResult>;
200
+ /** When set, files are refused with this message (a quota, a read-only space). */
201
+ uploadBlockedReason?: string | null;
202
+ /** The preview. Default: `<Prose><Markdown>{value}</Markdown></Prose>` from `@godxjp/markdown`. */
203
+ renderPreview?: (value: string) => React.ReactNode;
204
+ /** Extra toolbar actions, after the built-in ones — the extension point for new block types. */
205
+ actions?: readonly MarkdownEditorAction[];
206
+ /** Override any string the editor renders (defaults come from the kit's ja / en / vi catalogue). */
207
+ labels?: Partial<MarkdownEditorLabels>;
208
+ rows?: number;
209
+ } & React.RefAttributes<HTMLTextAreaElement>>;
210
+
211
+ export { EDITOR_MESSAGES, MarkdownEditor, type MarkdownEditorAction, type MarkdownEditorApi, type MarkdownEditorLabels, type MarkdownEditorMode, type MarkdownEditorProps, type MarkdownEditorUploadResult, type Selection, type TextEdit, applyEdit, insertCodeBlock, insertLink, prefixLines, uploadedMarkdown, wrapSelection };
package/dist/index.js ADDED
@@ -0,0 +1,483 @@
1
+ "use client";
2
+
3
+ // src/editor.tsx
4
+ import * as React from "react";
5
+ import {
6
+ Bold,
7
+ Code,
8
+ Heading2,
9
+ Italic,
10
+ Link2,
11
+ List,
12
+ ListChecks,
13
+ ListOrdered,
14
+ Paperclip,
15
+ Quote,
16
+ SquareCode,
17
+ Strikethrough
18
+ } from "lucide-react";
19
+ import { Markdown } from "@godxjp/markdown";
20
+ import { Card, CardContent, Prose } from "@godxjp/ui/data-display";
21
+ import { Segmented, Textarea } from "@godxjp/ui/data-entry";
22
+ import { Actions, Text } from "@godxjp/ui/general";
23
+ import { useTranslation } from "@godxjp/ui/i18n";
24
+ import { Flex, ResponsiveGrid } from "@godxjp/ui/layout";
25
+
26
+ // src/format.ts
27
+ function wrapSelection(text, { start, end }, before, after, placeholder) {
28
+ const selected = text.slice(start, end);
29
+ const outerStart = start - before.length;
30
+ const outerEnd = end + after.length;
31
+ if (outerStart >= 0 && text.slice(outerStart, start) === before && text.slice(end, outerEnd) === after) {
32
+ return {
33
+ from: outerStart,
34
+ to: outerEnd,
35
+ insert: selected,
36
+ select: { start: outerStart, end: outerStart + selected.length }
37
+ };
38
+ }
39
+ const body = selected || placeholder;
40
+ return {
41
+ from: start,
42
+ to: end,
43
+ insert: `${before}${body}${after}`,
44
+ select: { start: start + before.length, end: start + before.length + body.length }
45
+ };
46
+ }
47
+ function lineRange(text, { start, end }) {
48
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
49
+ const next = text.indexOf("\n", end > start && text[end - 1] === "\n" ? end - 1 : end);
50
+ return { start: lineStart, end: next === -1 ? text.length : next };
51
+ }
52
+ function prefixLines(text, selection, prefix, options = {}) {
53
+ const range = lineRange(text, selection);
54
+ const lines = text.slice(range.start, range.end).split("\n");
55
+ const marker = (index) => options.numbered ? `${index + 1}. ` : prefix;
56
+ const numberedPattern = /^\d+\. /;
57
+ const has = (line, index) => options.numbered ? numberedPattern.test(line) : line.startsWith(marker(index));
58
+ const allPrefixed = lines.every(has);
59
+ const next = lines.map(
60
+ (line, index) => allPrefixed ? line.replace(options.numbered ? numberedPattern : marker(index), "") : `${marker(index)}${line}`
61
+ );
62
+ const insert = next.join("\n");
63
+ return {
64
+ from: range.start,
65
+ to: range.end,
66
+ insert,
67
+ select: { start: range.start, end: range.start + insert.length }
68
+ };
69
+ }
70
+ function insertLink(text, { start, end }, placeholder) {
71
+ const label = text.slice(start, end) || placeholder;
72
+ const insert = `[${label}](url)`;
73
+ const urlStart = start + label.length + 3;
74
+ return { from: start, to: end, insert, select: { start: urlStart, end: urlStart + 3 } };
75
+ }
76
+ function insertCodeBlock(text, { start, end }) {
77
+ const body = text.slice(start, end);
78
+ const lead = start > 0 && text[start - 1] !== "\n" ? "\n" : "";
79
+ const insert = `${lead}\`\`\`
80
+ ${body}
81
+ \`\`\`
82
+ `;
83
+ const bodyStart = start + lead.length + 4;
84
+ return {
85
+ from: start,
86
+ to: end,
87
+ insert,
88
+ select: { start: bodyStart, end: bodyStart + body.length }
89
+ };
90
+ }
91
+ function applyEdit(text, edit) {
92
+ return text.slice(0, edit.from) + edit.insert + text.slice(edit.to);
93
+ }
94
+ function uploadedMarkdown(file, url, name) {
95
+ const label = (name ?? file.name).replace(/[[\]\\]/g, "\\$&");
96
+ const target = /[\s()<>]/.test(url) ? `<${url.replace(/[<>]/g, encodeURIComponent)}>` : url;
97
+ return file.type.startsWith("image/") ? `![${label}](${target})` : `[${label}](${target})`;
98
+ }
99
+
100
+ // src/messages.ts
101
+ import { registerMessages } from "@godxjp/ui/i18n";
102
+ var EDITOR_MESSAGES = {
103
+ ja: {
104
+ toolbar: "\u66F8\u5F0F",
105
+ mode: "\u8868\u793A",
106
+ write: "\u7DE8\u96C6",
107
+ preview: "\u30D7\u30EC\u30D3\u30E5\u30FC",
108
+ split: "\u4E26\u3079\u3066\u8868\u793A",
109
+ bold: "\u592A\u5B57",
110
+ italic: "\u659C\u4F53",
111
+ strike: "\u53D6\u308A\u6D88\u3057\u7DDA",
112
+ heading: "\u898B\u51FA\u3057",
113
+ link: "\u30EA\u30F3\u30AF",
114
+ code: "\u30B3\u30FC\u30C9",
115
+ codeBlock: "\u30B3\u30FC\u30C9\u30D6\u30ED\u30C3\u30AF",
116
+ bulletList: "\u7B87\u6761\u66F8\u304D",
117
+ numberedList: "\u756A\u53F7\u4ED8\u304D\u30EA\u30B9\u30C8",
118
+ taskList: "\u30C1\u30A7\u30C3\u30AF\u30EA\u30B9\u30C8",
119
+ quote: "\u5F15\u7528",
120
+ attach: "\u30D5\u30A1\u30A4\u30EB\u3092\u6DFB\u4ED8",
121
+ placeholderText: "\u30C6\u30AD\u30B9\u30C8",
122
+ placeholderLink: "\u30EA\u30F3\u30AF\u306E\u6587\u5B57",
123
+ emptyPreview: "\u30D7\u30EC\u30D3\u30E5\u30FC\u3059\u308B\u5185\u5BB9\u306F\u307E\u3060\u3042\u308A\u307E\u305B\u3093\u3002",
124
+ uploading: "\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u4E2D",
125
+ uploadingStatus: "\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u4E2D\u2026",
126
+ uploadFailed: "{name} \u3092\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\u3002"
127
+ },
128
+ en: {
129
+ toolbar: "Formatting",
130
+ mode: "View",
131
+ write: "Write",
132
+ preview: "Preview",
133
+ split: "Side by side",
134
+ bold: "Bold",
135
+ italic: "Italic",
136
+ strike: "Strikethrough",
137
+ heading: "Heading",
138
+ link: "Link",
139
+ code: "Code",
140
+ codeBlock: "Code block",
141
+ bulletList: "Bulleted list",
142
+ numberedList: "Numbered list",
143
+ taskList: "Checklist",
144
+ quote: "Quote",
145
+ attach: "Attach a file",
146
+ placeholderText: "text",
147
+ placeholderLink: "link text",
148
+ emptyPreview: "Nothing to preview yet.",
149
+ uploading: "Uploading",
150
+ uploadingStatus: "Uploading\u2026",
151
+ uploadFailed: "Could not upload {name}."
152
+ },
153
+ vi: {
154
+ toolbar: "\u0110\u1ECBnh d\u1EA1ng",
155
+ mode: "Ch\u1EBF \u0111\u1ED9 xem",
156
+ write: "So\u1EA1n",
157
+ preview: "Xem tr\u01B0\u1EDBc",
158
+ split: "Song song",
159
+ bold: "In \u0111\u1EADm",
160
+ italic: "In nghi\xEAng",
161
+ strike: "G\u1EA1ch ngang",
162
+ heading: "Ti\xEAu \u0111\u1EC1",
163
+ link: "Li\xEAn k\u1EBFt",
164
+ code: "M\xE3",
165
+ codeBlock: "Kh\u1ED1i m\xE3",
166
+ bulletList: "Danh s\xE1ch g\u1EA1ch \u0111\u1EA7u d\xF2ng",
167
+ numberedList: "Danh s\xE1ch \u0111\xE1nh s\u1ED1",
168
+ taskList: "Danh s\xE1ch vi\u1EC7c",
169
+ quote: "Tr\xEDch d\u1EABn",
170
+ attach: "\u0110\xEDnh k\xE8m t\u1EC7p",
171
+ placeholderText: "v\u0103n b\u1EA3n",
172
+ placeholderLink: "ch\u1EEF c\u1EE7a li\xEAn k\u1EBFt",
173
+ emptyPreview: "Ch\u01B0a c\xF3 n\u1ED9i dung \u0111\u1EC3 xem tr\u01B0\u1EDBc.",
174
+ uploading: "\u0110ang t\u1EA3i l\xEAn",
175
+ uploadingStatus: "\u0110ang t\u1EA3i l\xEAn\u2026",
176
+ uploadFailed: "Kh\xF4ng t\u1EA3i l\xEAn \u0111\u01B0\u1EE3c {name}."
177
+ }
178
+ };
179
+ var registered = false;
180
+ function ensureEditorMessages() {
181
+ if (registered) return;
182
+ registered = true;
183
+ for (const locale of ["ja", "en", "vi"]) {
184
+ registerMessages(locale, { markdownEditor: EDITOR_MESSAGES[locale] });
185
+ }
186
+ }
187
+
188
+ // src/editor.tsx
189
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
190
+ var isMod = (event) => event.metaKey || event.ctrlKey;
191
+ var MarkdownEditor = React.forwardRef(
192
+ function MarkdownEditor2({
193
+ value: valueProp,
194
+ defaultValue = "",
195
+ onValueChange,
196
+ mode: modeProp,
197
+ defaultMode = "write",
198
+ onModeChange,
199
+ upload,
200
+ uploadBlockedReason = null,
201
+ renderPreview,
202
+ actions = [],
203
+ labels,
204
+ rows = 12,
205
+ disabled,
206
+ readOnly,
207
+ onKeyDown,
208
+ onPaste,
209
+ onDrop,
210
+ ...textareaProps
211
+ }, ref) {
212
+ ensureEditorMessages();
213
+ const { t } = useTranslation();
214
+ const label = (key, params) => labels?.[key] ?? t(`markdownEditor.${key}`, params);
215
+ const [inner, setInner] = React.useState(defaultValue);
216
+ const value = valueProp ?? inner;
217
+ const valueRef = React.useRef(value);
218
+ valueRef.current = value;
219
+ const setValue = React.useCallback(
220
+ (next) => {
221
+ valueRef.current = next;
222
+ if (valueProp === void 0) setInner(next);
223
+ onValueChange?.(next);
224
+ },
225
+ [valueProp, onValueChange]
226
+ );
227
+ const [innerMode, setInnerMode] = React.useState(defaultMode);
228
+ const mode = modeProp ?? innerMode;
229
+ const setMode = (next) => {
230
+ if (modeProp === void 0) setInnerMode(next);
231
+ onModeChange?.(next);
232
+ };
233
+ const area = React.useRef(null);
234
+ const setArea = React.useCallback(
235
+ (node) => {
236
+ area.current = node;
237
+ if (typeof ref === "function") ref(node);
238
+ else if (ref) ref.current = node;
239
+ },
240
+ [ref]
241
+ );
242
+ const pendingSelection = React.useRef(null);
243
+ React.useLayoutEffect(() => {
244
+ const node = area.current;
245
+ const select = pendingSelection.current;
246
+ if (!node || !select) return;
247
+ pendingSelection.current = null;
248
+ node.setSelectionRange(select.start, select.end);
249
+ });
250
+ const selection = () => {
251
+ const node = area.current;
252
+ return node ? { start: node.selectionStart, end: node.selectionEnd } : { start: valueRef.current.length, end: valueRef.current.length };
253
+ };
254
+ const editable = !disabled && !readOnly;
255
+ const edit = (change) => {
256
+ const node = area.current;
257
+ if (!node || !editable) return;
258
+ node.focus();
259
+ node.setSelectionRange(change.from, change.to);
260
+ const expected = applyEdit(valueRef.current, change);
261
+ let applied;
262
+ try {
263
+ applied = document.execCommand?.("insertText", false, change.insert) ?? false;
264
+ } catch {
265
+ applied = false;
266
+ }
267
+ if (!applied || node.value !== expected) setValue(expected);
268
+ pendingSelection.current = change.select;
269
+ if (applied && node.value === expected)
270
+ node.setSelectionRange(change.select.start, change.select.end);
271
+ };
272
+ const api = {
273
+ value: () => valueRef.current,
274
+ selection,
275
+ edit,
276
+ insert: (text) => {
277
+ const { start, end } = selection();
278
+ edit({
279
+ from: start,
280
+ to: end,
281
+ insert: text,
282
+ select: { start: start + text.length, end: start + text.length }
283
+ });
284
+ },
285
+ focus: () => area.current?.focus()
286
+ };
287
+ const wrap = (before, after = before) => edit(wrapSelection(valueRef.current, selection(), before, after, label("placeholderText")));
288
+ const lines = (prefix, numbered = false) => edit(prefixLines(valueRef.current, selection(), prefix, { numbered }));
289
+ const [uploading, setUploading] = React.useState(0);
290
+ const [uploadError, setUploadError] = React.useState(null);
291
+ const uploadSeq = React.useRef(0);
292
+ const fileInput = React.useRef(null);
293
+ const acceptFiles = async (files) => {
294
+ if (!upload || files.length === 0 || !editable) return;
295
+ if (uploadBlockedReason) {
296
+ setUploadError(uploadBlockedReason);
297
+ return;
298
+ }
299
+ setUploadError(null);
300
+ for (const file of files) {
301
+ const placeholder = `![${label("uploading")} ${file.name.replace(/[[\]\\]/g, "")} #${++uploadSeq.current}]()`;
302
+ api.insert(placeholder);
303
+ setUploading((n) => n + 1);
304
+ try {
305
+ const result = await upload(file);
306
+ const markdown = uploadedMarkdown(file, result.url, result.name);
307
+ const at = valueRef.current.indexOf(placeholder);
308
+ if (at !== -1) {
309
+ edit({
310
+ from: at,
311
+ to: at + placeholder.length,
312
+ insert: markdown,
313
+ select: { start: at + markdown.length, end: at + markdown.length }
314
+ });
315
+ }
316
+ } catch {
317
+ const at = valueRef.current.indexOf(placeholder);
318
+ if (at !== -1)
319
+ edit({
320
+ from: at,
321
+ to: at + placeholder.length,
322
+ insert: "",
323
+ select: { start: at, end: at }
324
+ });
325
+ setUploadError(label("uploadFailed", { name: file.name }));
326
+ } finally {
327
+ setUploading((n) => n - 1);
328
+ }
329
+ }
330
+ };
331
+ const builtIn = [
332
+ { key: "bold", label: label("bold"), icon: /* @__PURE__ */ jsx(Bold, {}), run: () => wrap("**") },
333
+ { key: "italic", label: label("italic"), icon: /* @__PURE__ */ jsx(Italic, {}), run: () => wrap("_") },
334
+ { key: "strike", label: label("strike"), icon: /* @__PURE__ */ jsx(Strikethrough, {}), run: () => wrap("~~") },
335
+ { key: "heading", label: label("heading"), icon: /* @__PURE__ */ jsx(Heading2, {}), run: () => lines("## ") },
336
+ {
337
+ key: "link",
338
+ label: label("link"),
339
+ icon: /* @__PURE__ */ jsx(Link2, {}),
340
+ run: () => edit(insertLink(valueRef.current, selection(), label("placeholderLink")))
341
+ },
342
+ { key: "code", label: label("code"), icon: /* @__PURE__ */ jsx(Code, {}), run: () => wrap("`") },
343
+ {
344
+ key: "codeBlock",
345
+ label: label("codeBlock"),
346
+ icon: /* @__PURE__ */ jsx(SquareCode, {}),
347
+ run: () => edit(insertCodeBlock(valueRef.current, selection()))
348
+ },
349
+ { key: "bulletList", label: label("bulletList"), icon: /* @__PURE__ */ jsx(List, {}), run: () => lines("- ") },
350
+ {
351
+ key: "numberedList",
352
+ label: label("numberedList"),
353
+ icon: /* @__PURE__ */ jsx(ListOrdered, {}),
354
+ run: () => lines("", true)
355
+ },
356
+ {
357
+ key: "taskList",
358
+ label: label("taskList"),
359
+ icon: /* @__PURE__ */ jsx(ListChecks, {}),
360
+ run: () => lines("- [ ] ")
361
+ },
362
+ { key: "quote", label: label("quote"), icon: /* @__PURE__ */ jsx(Quote, {}), run: () => lines("> ") }
363
+ ];
364
+ if (upload) {
365
+ builtIn.push({
366
+ key: "attach",
367
+ label: label("attach"),
368
+ icon: /* @__PURE__ */ jsx(Paperclip, {}),
369
+ run: () => fileInput.current?.click()
370
+ });
371
+ }
372
+ const all = [...builtIn, ...actions];
373
+ const toolbarDisabled = !editable || mode === "preview";
374
+ const onEditorKeyDown = (event) => {
375
+ onKeyDown?.(event);
376
+ if (event.defaultPrevented) return;
377
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
378
+ if (!isMod(event) || event.altKey || event.shiftKey) return;
379
+ const key = event.key.toLowerCase();
380
+ const run = key === "b" ? "bold" : key === "i" ? "italic" : key === "k" ? "link" : null;
381
+ if (!run) return;
382
+ event.preventDefault();
383
+ builtIn.find((action) => action.key === run)?.run(api);
384
+ };
385
+ const preview = /* @__PURE__ */ jsx(Card, { variant: "muted", "aria-label": label("preview"), role: "region", children: /* @__PURE__ */ jsx(CardContent, { children: value.trim() === "" ? /* @__PURE__ */ jsx(Text, { tone: "muted", children: label("emptyPreview") }) : renderPreview?.(value) ?? /* @__PURE__ */ jsx(Prose, { children: /* @__PURE__ */ jsx(Markdown, { children: value }) }) }) });
386
+ const textarea = /* @__PURE__ */ jsx(
387
+ Textarea,
388
+ {
389
+ ...textareaProps,
390
+ ref: setArea,
391
+ value,
392
+ onValueChange: setValue,
393
+ rows,
394
+ disabled,
395
+ readOnly,
396
+ onKeyDown: onEditorKeyDown,
397
+ onPaste: (event) => {
398
+ onPaste?.(event);
399
+ const files = Array.from(event.clipboardData?.files ?? []);
400
+ if (!event.defaultPrevented && upload && files.length > 0) {
401
+ event.preventDefault();
402
+ void acceptFiles(files);
403
+ }
404
+ },
405
+ onDrop: (event) => {
406
+ onDrop?.(event);
407
+ const files = Array.from(event.dataTransfer?.files ?? []);
408
+ if (!event.defaultPrevented && upload && files.length > 0) {
409
+ event.preventDefault();
410
+ void acceptFiles(files);
411
+ }
412
+ }
413
+ }
414
+ );
415
+ return /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: "xs", "data-slot": "markdown-editor", "data-mode": mode, children: [
416
+ /* @__PURE__ */ jsxs(Flex, { justify: "between", align: "center", gap: "xs", wrap: true, children: [
417
+ /* @__PURE__ */ jsx(
418
+ Actions,
419
+ {
420
+ label: label("toolbar"),
421
+ items: all.map((action) => ({
422
+ key: action.key,
423
+ label: action.label,
424
+ icon: action.icon,
425
+ // aria-disabled, not removed: the toolbar keeps its shape and its focus order.
426
+ disabled: toolbarDisabled,
427
+ onItemClick: () => action.run(api)
428
+ }))
429
+ }
430
+ ),
431
+ /* @__PURE__ */ jsx(
432
+ Segmented,
433
+ {
434
+ size: "sm",
435
+ "aria-label": label("mode"),
436
+ value: mode,
437
+ onValueChange: (next) => setMode(next),
438
+ options: [
439
+ { value: "write", label: label("write") },
440
+ { value: "preview", label: label("preview") },
441
+ { value: "split", label: label("split") }
442
+ ]
443
+ }
444
+ )
445
+ ] }),
446
+ mode === "split" ? /* @__PURE__ */ jsxs(ResponsiveGrid, { columns: { base: 1, md: 2 }, gap: "sm", children: [
447
+ textarea,
448
+ preview
449
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
450
+ /* @__PURE__ */ jsx("div", { hidden: mode === "preview", children: textarea }),
451
+ mode === "preview" ? preview : null
452
+ ] }),
453
+ upload ? /* @__PURE__ */ jsx(
454
+ "input",
455
+ {
456
+ ref: fileInput,
457
+ type: "file",
458
+ multiple: true,
459
+ hidden: true,
460
+ "aria-hidden": "true",
461
+ tabIndex: -1,
462
+ onChange: (event) => {
463
+ const files = Array.from(event.target.files ?? []);
464
+ event.target.value = "";
465
+ void acceptFiles(files);
466
+ }
467
+ }
468
+ ) : null,
469
+ /* @__PURE__ */ jsx("div", { role: "status", "aria-live": "polite", children: uploading > 0 ? /* @__PURE__ */ jsx(Text, { tone: "muted", children: label("uploadingStatus") }) : null }),
470
+ uploadError ? /* @__PURE__ */ jsx(Text, { tone: "destructive", role: "alert", children: uploadError }) : null
471
+ ] });
472
+ }
473
+ );
474
+ export {
475
+ EDITOR_MESSAGES,
476
+ MarkdownEditor,
477
+ applyEdit,
478
+ insertCodeBlock,
479
+ insertLink,
480
+ prefixLines,
481
+ uploadedMarkdown,
482
+ wrapSelection
483
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@godxjp/editor",
3
+ "version": "31.18.0",
4
+ "description": "Markdown editor for GoDX apps: formatting toolbar, write / preview / side by side with @godxjp/markdown, paste / drop / attach through a host upload function, IME-safe, ja / en / vi.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "sideEffects": false,
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "publishConfig": {
22
+ "registry": "https://registry.npmjs.org/",
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/godx-jp/godxjp-ui.git",
28
+ "directory": "packages/editor"
29
+ },
30
+ "license": "Apache-2.0",
31
+ "peerDependencies": {
32
+ "@godxjp/markdown": "~31.18.0",
33
+ "@godxjp/ui": "~31.18.0",
34
+ "lucide-react": ">=1.0.0",
35
+ "react": ">=19.0.0"
36
+ },
37
+ "keywords": [
38
+ "markdown",
39
+ "editor",
40
+ "godxjp"
41
+ ]
42
+ }