@wyttime04/react-vditor 0.1.0-alpha.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wyttime04
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,362 @@
1
+ # @wyttime04/react-vditor
2
+
3
+ 以 [vditor](https://github.com/Vanessa219/vditor) 4.0.0 為基礎的 React 元件,**僅支援 GFM 語法,並可於完全離線的環境運作**。
4
+
5
+ - 直接使用上游原版 vditor,不維護 fork
6
+ - mermaid、數學式、echarts 等 GFM 以外的附加渲染一律停用,相關區塊以一般程式碼區塊呈現
7
+ - 執行期僅載入同源資源,不連線至任何 CDN
8
+
9
+ ## 相容性
10
+
11
+ | 項目 | 版本 |
12
+ |---|---|
13
+ | React | 16.9 ~ 19(`peerDependencies`;自動測試以 16.9 進行) |
14
+ | webpack | ^5.76.0(使用 `VditorAssetsPlugin` 時) |
15
+
16
+ ## 安裝
17
+
18
+ ```sh
19
+ npm install @wyttime04/react-vditor
20
+ ```
21
+
22
+ ### 設定 webpack
23
+
24
+ 加入 `VditorAssetsPlugin`。它會將 vditor 執行期所需的資源(lute、語系、圖示、highlight.js、emoji 圖片,約 4.8 MB)
25
+ 輸出至 webpack output 目錄下的 `vditor/`。
26
+
27
+ ```js
28
+ // webpack.config.js
29
+ const VditorAssetsPlugin = require("@wyttime04/react-vditor/webpack-plugin");
30
+
31
+ module.exports = {
32
+ // ...
33
+ module: {rules: [{test: /\.css$/, use: ["style-loader", "css-loader"]}]},
34
+ plugins: [new VditorAssetsPlugin()],
35
+ };
36
+ ```
37
+
38
+ 元件會依 webpack 的 `publicPath` 自動定位這些資源,無須額外設定。若資源部署於其他位置,
39
+ 請透過 `cdn` prop 指定 `vditor/` 目錄的網址;vditor 會自 `${cdn}/dist/...` 載入資源。
40
+
41
+ ### 載入樣式
42
+
43
+ ```js
44
+ import "@wyttime04/react-vditor/index.css";
45
+ ```
46
+
47
+ `index.css` 包含 vditor 的樣式,以及本套件局部化後的 `light` / `dark` 主題樣式。
48
+
49
+ ## 使用方式
50
+
51
+ ```jsx
52
+ import {Vditor, VditorPreview, md2html} from "@wyttime04/react-vditor";
53
+ import "@wyttime04/react-vditor/index.css";
54
+
55
+ // 編輯器
56
+ const ref = useRef(null);
57
+ <Vditor ref={ref} value={markdown} input={setMarkdown}/>
58
+
59
+ // 僅渲染內容,不建立編輯器
60
+ <VditorPreview value={markdown}/>
61
+
62
+ // 轉換為 HTML(GFM 以外的區塊同樣轉為一般程式碼區塊)
63
+ const html = await md2html(markdown);
64
+ ```
65
+
66
+ ## API
67
+
68
+ ### `<Vditor>`
69
+
70
+ props 沿用 vditor 的 `IOptions`([官方文件](https://github.com/Vanessa219/vditor#options)),另增 `className`, `style`;
71
+ 其中 `theme` 為本套件定義的 `"light" | "dark" | "auto"`,詳見「[主題](#主題)」。
72
+ callback 沿用 vditor 原生名稱,例如 `input`, `after`, `focus`, `blur`。
73
+
74
+ #### props 變更時的行為
75
+
76
+ vditor 本身是非受控元件,大部分設定只在建立編輯器時讀取一次。元件依下表處理 props 的變更:
77
+
78
+ | props | 變更時的行為 | 使用時注意 |
79
+ |---|---|---|
80
+ | `mode`, `lang`, `toolbar`, `icon`, `cdn`, `height` | 重新建立編輯器,內容保留 | 復原紀錄、游標位置與 `disabled` 狀態不會保留,不適合在編輯途中頻繁變更。<br>`toolbar` 以內容比較,每次 render 傳入新的陣列不會觸發重建;但只變更按鈕的 `click` 函式不會生效 |
81
+ | `value` | 與編輯器目前的內容不同時,以新內容重新渲染編輯區 | 可作為受控元件使用:`input` 回傳的值再傳回 `value` 時不會重新渲染,打字不會被打斷。<br>由外部改寫 `value` 會重新渲染整個編輯區,游標位置不保留 |
82
+ | `theme` | 同時切換編輯器外框、內容主題與程式碼上色 | 不會重新建立編輯器 |
83
+ | `className`, `style` | 立即套用於元件的外層容器 | |
84
+ | callback:`after`, `input`, `focus`, `blur`, `keydown`, `esc`, `ctrlEnter`, `select`, `unSelect`, `customWysiwygToolbar`, `customWysiwygMobileToolbar` | 一律呼叫最新傳入的函式 | 可直接傳入 inline 函式,不需以 `useCallback` 固定,也不會觸發重建 |
85
+ | 其他(例如 `placeholder`, `upload`, `preview`, `counter`, `hint`) | 不套用 | 開發模式下會於主控台警告一次;如需套用,請變更元件的 `key` 以重新建立。<br>**物件中的函式同樣只在建立時讀取**,例如 `upload.handler`:若其中引用了會變動的 state,請改以 ref 取得最新值,避免讀到建立當下的舊值 |
86
+
87
+ #### ref
88
+
89
+ 透過 ref 取得的物件,提供 vditor 實例的方法,用於在元件外操作編輯器,例如插入文字、取得內容或停用編輯器。
90
+ 方法名稱與參數皆與 vditor 原生相同,詳細說明請參考 [vditor 文件的 methods](https://github.com/Vanessa219/vditor#methods)。
91
+
92
+ ```jsx
93
+ const editorRef = useRef(null);
94
+
95
+ <Vditor ref={editorRef}/>
96
+ <button onClick={() => editorRef.current.insertValue("**粗體**")}>插入粗體</button>
97
+ <button onClick={() => console.log(editorRef.current.getValue())}>取得內容</button>
98
+ ```
99
+
100
+ **可用的方法**
101
+
102
+ | 分類 | 方法 | 說明 |
103
+ |---|---|---|
104
+ | 內容 | `getValue()` | 取得 Markdown |
105
+ | | `setValue(markdown, clearStack?)` | 取代全部內容;`clearStack` 為 `true` 時一併清除復原紀錄 |
106
+ | | `insertValue(value, render?)` | 於游標處插入內容,預設會渲染 Markdown |
107
+ | | `insertMD(md)` | 於游標處插入 Markdown |
108
+ | | `updateValue(value)` | 取代目前選取的內容 |
109
+ | | `deleteValue()` | 刪除目前選取的內容 |
110
+ | | `getHTML()` | 取得渲染後的 HTML |
111
+ | | `html2md(html)` | 將 HTML 轉換為 Markdown |
112
+ | | `exportJSON(markdown)` | 將 Markdown 轉換為 JSON(語法樹) |
113
+ | 編輯狀態 | `focus()`, `blur()` | 取得/移除焦點 |
114
+ | | `disabled()`, `enable()` | 停用/啟用編輯器 |
115
+ | | `getCurrentMode()` | 取得目前的編輯模式(`wysiwyg` / `ir` / `sv`) |
116
+ | | `clearStack()` | 清除復原紀錄 |
117
+ | | `isUploading()` | 是否仍有上傳進行中 |
118
+ | 游標與選取 | `getSelection()` | 取得選取的文字 |
119
+ | | `getCursorPosition()` | 取得游標在畫面上的位置 |
120
+ | | `insertEmptyBlock(position)` | 於目前區塊前或後插入空白區塊 |
121
+ | 預覽 | `renderPreview(value?)` | 重新渲染預覽區 |
122
+ | | `setPreviewMode(mode)` | 設定 sv 模式的預覽方式(`both` / `editor`) |
123
+ | 介面 | `tip(text, time?)` | 顯示提示訊息;`time` 為 `0` 時持續顯示 |
124
+ | | `updateToolbarConfig(options)` | 更新工具列設定(例如隱藏或固定工具列) |
125
+ | 快取 | `clearCache()`, `disabledCache()`, `enableCache()` | 清除/停用/啟用本機快取 |
126
+ | 評論 | `getCommentIds()`, `hlCommentIds(ids)`, `unHlCommentIds(ids)`, `removeCommentIds(ids)` | 取得、標示、取消標示、移除評論(僅 wysiwyg 模式且啟用 `comment` 時有效) |
127
+
128
+ **不提供的方法**
129
+
130
+ | 方法 | 原因 | 替代方式 |
131
+ |---|---|---|
132
+ | `destroy()` | 由元件於卸載時自動呼叫 | 卸載元件 |
133
+ | `setTheme()` | 原生的 `setTheme` 會插入整頁共用的主題樣式,導致各元件的主題互相影響 | `theme` prop 或 `VditorThemeProvider`,詳見「[主題](#主題)」 |
134
+
135
+ **在編輯器就緒前使用 ref**
136
+
137
+ vditor 的初始化是非同步的:元件掛載後,須先載入語系與 Markdown 解析引擎,完成後才會觸發 `after` callback,
138
+ 在此之前 vditor 實例尚無法操作。為了讓使用端不必等待 `after`,例如在 `useEffect` 中即可呼叫 ref,
139
+ 元件會先代為處理這段期間的呼叫:
140
+
141
+ | 呼叫的方法 | 就緒前的處理方式 |
142
+ |---|---|
143
+ | 不回傳值的方法(`setValue`, `insertValue`, `focus`, `disabled` 等) | 暫存,於就緒後依呼叫順序執行 |
144
+ | `getValue()` | 回傳 `value` prop 的內容 |
145
+ | `getCurrentMode()` | 回傳 `mode` prop,未指定時為 `wysiwyg` |
146
+ | 其他查詢方法(`getHTML()`, `getSelection()` 等) | 回傳 `undefined`;需要結果時,請於 `after` callback 觸發後再呼叫 |
147
+
148
+ ```jsx
149
+ useEffect(() => {
150
+ // 此時編輯器可能尚未就緒;呼叫會先暫存,就緒後自動執行
151
+ editorRef.current.setValue(draft);
152
+ editorRef.current.focus();
153
+ }, []);
154
+ ```
155
+
156
+ ### `<VditorPreview>`
157
+
158
+ props 沿用 `Vditor.preview` 的 `IPreviewOptions`,另增 `value`(要渲染的 Markdown)、`className`, `style`。
159
+ `theme` 與 `<Vditor>` 相同,詳見「[主題](#主題)」;`mode` 僅供 GFM 以外的渲染器使用,因此不提供。
160
+
161
+ ### `md2html(markdown, options?)`
162
+
163
+ 將 Markdown 轉換為 HTML,回傳 `Promise<string>`。GFM 以外的區塊會與預覽相同,轉換為一般程式碼區塊
164
+ (vditor 原生的 `md2html` 不套用 `transform`,因此由本函式處理)。
165
+
166
+ ## 主題
167
+
168
+ `<Vditor>` 與 `<VditorPreview>` 提供 `light`, `dark` 兩種主題,另可設為 `auto`,依作業系統的外觀設定(`prefers-color-scheme`)自動切換,並在系統設定變更時即時套用。
169
+
170
+ ### 指定單一元件的主題
171
+
172
+ 透過元件的 `theme` prop 指定:
173
+
174
+ ```jsx
175
+ <Vditor value={markdown} theme="dark"/>
176
+ <VditorPreview value={markdown} theme="light"/>
177
+ ```
178
+
179
+ ### 同步多個元件的主題
180
+
181
+ 若需要讓頁面或整個系統內的所有編輯器與預覽使用一致的主題,請使用 `VditorThemeProvider`。
182
+ 置於元件樹的上層後,其下所有未指定 `theme` 的 `<Vditor>` 與 `<VditorPreview>` 都會套用它的值;
183
+ 值變更時,這些元件會同步切換。
184
+
185
+ ```jsx
186
+ import {VditorThemeProvider} from "@wyttime04/react-vditor";
187
+
188
+ const App = () => {
189
+ // 主題值由應用程式自行管理;是否保存(例如 localStorage、使用者設定)由應用程式決定
190
+ const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "auto");
191
+ useEffect(() => localStorage.setItem("theme", theme), [theme]);
192
+
193
+ return <VditorThemeProvider theme={theme}>
194
+ <Routes/>
195
+ </VditorThemeProvider>;
196
+ };
197
+ ```
198
+
199
+ - 套用於整個系統:將 `VditorThemeProvider` 置於應用程式的根元件
200
+ - 套用於單一頁面:將 `VditorThemeProvider` 置於該頁面的最上層元件
201
+
202
+ ### 套用順序
203
+
204
+ 1. 元件本身的 `theme` prop
205
+ 2. 最接近的上層 `VditorThemeProvider`
206
+ 3. 預設值 `light`
207
+
208
+ ### 各主題的樣式
209
+
210
+ | 項目 | `light` | `dark` |
211
+ |---|---|---|
212
+ | 編輯器外框 | vditor `classic` | vditor `dark` |
213
+ | 內容主題 | vditor `light` | vditor `dark` |
214
+ | 程式碼上色 | highlight.js `github` | highlight.js `github-dark` |
215
+ | `<VditorPreview>` 背景色 | 不設定 | `#2f363d`(與 vditor 深色編輯區相同) |
216
+
217
+ ### 說明
218
+
219
+ - 各元件的主題彼此獨立,同一頁面可混用不同主題。vditor 原生以整頁共用的 `<link>` 載入內容主題,
220
+ 同一頁面只能使用一種;本套件將主題樣式局部化並併入 `index.css`,改由元件容器上的
221
+ `react-vditor--light` / `react-vditor--dark` class 套用。
222
+ - 切換主題不會重建編輯器。
223
+ - `theme` prop 取代 vditor 原生的 `theme`(`classic` / `dark`)與 `preview.theme` 設定;ref 亦不提供 `setTheme`。
224
+
225
+ ## 工具列
226
+
227
+ ### 預設工具列
228
+
229
+ ```js
230
+ [
231
+ "headings", "bold", "italic", "strike", "link", "|",
232
+ "list", "ordered-list", "check", "outdent", "indent", "|",
233
+ "quote", "line", "code", "inline-code", "insert-before", "insert-after", "|",
234
+ "upload-image", "table", "|",
235
+ "undo", "redo", "|",
236
+ "fullscreen", "edit-mode",
237
+ {name: "more", toolbar: ["both", "outline", "preview"]},
238
+ ]
239
+ ```
240
+
241
+ 與 vditor 原生的預設工具列相比:
242
+
243
+ - 移除 `export`(寫死連線至 CDN)、`devtools`(依賴 echarts)、`info`(寫死 unpkg 的圖片網址)
244
+ - 移除 `emoji`, `record`, `upload`, `code-theme`, `content-theme`, `help`
245
+ - 新增 `upload-image`
246
+
247
+ 傳入 `toolbar` prop 時,會完整取代預設工具列。
248
+
249
+ ### 上傳圖片按鈕 `upload-image`
250
+
251
+ 僅接受圖片的上傳按鈕,已包含在預設工具列中,並可與 vditor 內建的 `upload`(圖片或文件)同時使用。
252
+
253
+ #### 範例
254
+
255
+ ```jsx
256
+ const editorRef = useRef(null);
257
+
258
+ <Vditor
259
+ ref={editorRef}
260
+ upload={{
261
+ handler: async (files) => {
262
+ for (const file of files) {
263
+ const url = await uploadToServer(file); // 由應用程式實作上傳
264
+ editorRef.current.insertValue(`![${file.name}](${url})`);
265
+ }
266
+ return null; // 回傳字串時,會以提示訊息顯示
267
+ },
268
+ }}
269
+ />
270
+ ```
271
+
272
+ 使用自訂的 `toolbar` 時,請將 `"upload-image"` 加入其中:
273
+
274
+ ```jsx
275
+ <Vditor toolbar={["bold", "italic", "|", "upload-image"]} upload={{handler}}/>
276
+ ```
277
+
278
+ #### 行為
279
+
280
+ 1. 點選按鈕後開啟檔案選擇視窗,僅列出圖片檔
281
+ 2. 選取的檔案再次過濾,僅保留圖片,交由 `upload.handler` 處理
282
+ 3. `handler` 回傳字串時,以提示訊息顯示(例如「檔案過大」)
283
+
284
+ 此行為與 vditor 內建上傳在設定 `handler` 時相同。插入圖片語法由 `handler` 負責,例如呼叫 ref 的 `insertValue`。
285
+
286
+ #### 相關設定
287
+
288
+ | 設定 | 說明 |
289
+ |---|---|
290
+ | `upload.handler` | 必填。未設定時,點選按鈕僅顯示設定提示,不開啟檔案選擇視窗 |
291
+ | `upload.multiple` | 為 `true`(vditor 預設)時可一次選取多張圖片,否則僅能選取一張 |
292
+ | `lang` | 按鈕的提示文字依語言顯示,支援 zh_TW、zh_CN、en_US,其他語言顯示英文 |
293
+ | 以物件指定按鈕 | 如需自訂提示文字或圖示:`{name: "upload-image", tip: "…", icon: "<svg>…</svg>"}`,指定的欄位優先於預設值。自訂的 `tip` 為固定字串,不會隨語言切換 |
294
+
295
+ #### 限制
296
+
297
+ - 僅支援 `upload.handler`,不支援 `upload.url`:`url` 屬於 vditor 內部的上傳流程,未提供可呼叫的公開 API
298
+ - 檔案類型的限制僅作用於檔案選擇視窗;以拖放或貼上方式加入的檔案不經過此按鈕。
299
+ 另外,設定 `upload.handler` 時 vditor 不會檢查檔案類型與大小,請於 `handler` 中自行驗證
300
+ - `upload.handler` 只在建立編輯器時讀取,詳見「[props 變更時的行為](#props-變更時的行為)」
301
+
302
+ ## 預設值與原生 vditor 的差異
303
+
304
+ | 設定 | 預設值 | 說明 |
305
+ |---|---|---|
306
+ | `mode` | `wysiwyg` | vditor 原生為 `ir` |
307
+ | `lang` | `zh_TW` | |
308
+ | `theme` | `light` | 僅支援 `light` / `dark` / `auto`,詳見「[主題](#主題)」 |
309
+ | `toolbar` | 詳見「[預設工具列](#預設工具列)」 | |
310
+ | `cache` | `{enable: false}` | vditor 啟用快取時必須提供 `cache.id` |
311
+ | `preview.markdown.footnotes` | `false` | GFM 不包含註腳 |
312
+ | `preview.markdown.codeBlockPreview`, `mathBlockPreview` | `false` | ir / wysiwyg 模式不顯示程式碼區塊的預覽面板,避免渲染 mermaid 等區塊 |
313
+ | `preview.render.media.enable` | `false` | 不將影片、音訊連結嵌入為 iframe |
314
+ | `preview.transform` | GFM 以外的區塊轉為一般程式碼區塊 | 使用端傳入的 `transform` 會於其後執行 |
315
+ | `preview.theme.current` | `""`(固定) | 內容主題改由 `theme` 決定,不使用 vditor 整頁共用的 `<link>` |
316
+ | 行內數學式 `$…$` | 視為一般文字 | GFM 不包含數學式 |
317
+
318
+ ## 已知限制
319
+
320
+ - ir / wysiwyg 模式中的程式碼區塊不會上色(停用預覽面板所致);sv 模式的預覽區與 `<VditorPreview>` 仍會上色
321
+ - `$$ … $$` 區塊會以程式碼區塊呈現(Lute 未提供停用此語法的設定)
322
+ - `:smile:` 等 emoji 代碼會轉為對應字元,YAML front matter 會以程式碼區塊呈現;兩者雖非 GFM 語法,仍予以保留
323
+ - vditor 無論設定為何,都會整頁載入 highlight.js 的 `github.min.css`;`dark` 主題的程式碼樣式以局部化且優先權較高的規則覆寫
324
+ - 啟用 `cache` 且未提供 `value` 時,若快取內容包含 `$…$`,初次渲染會嘗試載入未輸出的 katex,產生本機 404
325
+
326
+ ## 開發
327
+
328
+ ```sh
329
+ npm install
330
+ npm start # 啟動開發用畫面 http://localhost:9000,直接載入 src 原始碼並自動重新整理
331
+ npm run preview # 先 npm run build,再以 dist/ 的套件啟動開發用畫面(production 模式),內容與 GitHub Pages 相同
332
+ npm run build # 以 babel 將 src 的 .js / .jsx 編譯至 dist/,並附上 index.d.ts 與 index.css(由 scripts/css.cjs 產生)
333
+ npm run build:page # 先 npm run build,再以 dist/ 的套件將開發用畫面建置至 dev/dist/,供 GitHub Pages 部署
334
+ npm run test:types # 以 tsc 檢查手寫的 src/index.d.ts(test/types/usage.jsx)
335
+ npm test # 建置測試頁、檢查型別,並執行 puppeteer 測試(React 16.9)
336
+ ```
337
+
338
+ ### 開發用畫面
339
+
340
+ `dev/` 提供開發用畫面:左側為 `<Vditor>`,右側為 `<VditorPreview>` 與 `md2html()` 的輸出,下方列出事件紀錄。
341
+ 上方可切換主題(透過 `VditorThemeProvider`),並呼叫 ref 的 `insertValue`, `disabled` / `enable`, `getValue`。
342
+ 工具列的「上傳圖片」不會實際上傳,選取的圖片以 `blob:` 網址直接顯示。
343
+
344
+ `dev/main.jsx` 與使用端相同,以 `import "@wyttime04/react-vditor"` 及 `import "@wyttime04/react-vditor/index.css"` 引用套件,
345
+ 由 `dev/webpack.config.cjs` 依 mode 決定來源:
346
+
347
+ | mode | 指令 | 套件來源 |
348
+ |---|---|---|
349
+ | development | `npm start` | `src/` 原始碼;`index.css` 由 `dev/index.css.cjs` 即時產生,不需先 build |
350
+ | production | `npm run preview`, `npm run build:page` | `npm run build` 產出的 `dist/`,經 `package.json` 的 `exports` 解析 |
351
+
352
+ 推送至 `main` 分支時,`.github/workflows/deploy-pages.yaml` 會執行 `npm run build:page`,並將 `dev/dist/` 部署至 GitHub Pages。
353
+ 首次使用前,須於 repo 的 Settings → Pages 將 Source 設為「GitHub Actions」。
354
+
355
+ ### 型別
356
+
357
+ 原始碼僅使用 `.js` / `.jsx`,型別定義於 `src/index.d.ts`。變更元件的 props 或 ref 方法時,請同步更新 `index.d.ts`,
358
+ 並於 `test/types/usage.jsx` 補上對應用法;`npm run test:types` 會檢查兩者是否一致。
359
+
360
+ ### 測試
361
+
362
+ 測試會確認每個情境皆無外部請求、無 404、無頁面錯誤。若 puppeteer 未下載瀏覽器,會改用系統安裝的 Chrome。
package/dist/Vditor.js ADDED
@@ -0,0 +1,212 @@
1
+ import * as React from "react";
2
+ import VditorCore from "vditor";
3
+ import { defaultCdn } from "./cdn.js";
4
+ import { gfmEditorOptions } from "./gfm.js";
5
+ import { themeClassName, useResolvedTheme } from "./theme.js";
6
+
7
+ /**
8
+ * ref 上可以呼叫的方法,名稱與原生 vditor 相同。型別見 index.d.ts 的 VditorRef。
9
+ * 不開放 destroy(由元件管理)與 setTheme(主題由 theme prop 控制;原生 setTheme 會插入整頁共用的主題樣式)
10
+ */
11
+ const COMMANDS = ["updateToolbarConfig", "focus", "blur", "disabled", "enable", "renderPreview", "clearCache", "disabledCache", "enableCache", "tip", "setPreviewMode", "deleteValue", "updateValue", "insertValue", "insertMD", "setValue", "insertEmptyBlock", "clearStack", "hlCommentIds", "unHlCommentIds", "removeCommentIds"];
12
+ const QUERIES = ["getValue", "getCurrentMode", "getSelection", "getCursorPosition", "isUploading", "html2md", "exportJSON", "getHTML", "getCommentIds"];
13
+
14
+ /** 變動時重建編輯器,內容會保留 */
15
+ const RECREATE_KEYS = ["mode", "lang", "toolbar", "icon", "cdn", "height"];
16
+ /** 回呼一律轉呼叫最新的 props,變動時不重建 */
17
+ const CALLBACK_KEYS = ["after", "input", "focus", "blur", "keydown", "esc", "ctrlEnter", "select", "unSelect", "customWysiwygToolbar", "customWysiwygMobileToolbar"];
18
+ /** 由元件自己處理或與編輯器無關的 props */
19
+ const HANDLED_KEYS = ["value", "theme", "className", "style", ...RECREATE_KEYS, ...CALLBACK_KEYS];
20
+
21
+ /** 我們的 light / dark 對應到 vditor 編輯器外框的 classic / dark;內容主題與程式碼主題由容器的 class 決定 */
22
+ const editorTheme = theme => theme === "dark" ? "dark" : "classic";
23
+ const isDevelopment = () => {
24
+ try {
25
+ return process.env.NODE_ENV !== "production";
26
+ } catch {
27
+ return false;
28
+ }
29
+ };
30
+
31
+ /** vditor 編輯器。value 是初始值;之後使用端改 value 會套用,打字觸發的 input 回傳值不會被蓋回去 */
32
+ export const Vditor = /*#__PURE__*/React.forwardRef(function Vditor(props, ref) {
33
+ const containerRef = React.useRef(null);
34
+ const propsRef = React.useRef(props);
35
+ propsRef.current = props;
36
+ const theme = useResolvedTheme(props.theme);
37
+ const themeRef = React.useRef(theme);
38
+ themeRef.current = theme;
39
+
40
+ /** ready 之後才有值;null 表示還沒 ready 或已卸載 */
41
+ const editorRef = React.useRef(null);
42
+ /** ready 前呼叫的方法,ready 後依序執行 */
43
+ const queueRef = React.useRef([]);
44
+ /** 編輯器目前的內容(ready 前是要給它的初始值),重建時用來保留內容;undefined 表示沒給 value,交給 vditor(例如讀快取) */
45
+ const contentRef = React.useRef(props.value);
46
+ /** input 回傳過、但使用端可能還沒同步回 value 的內容;value 等於其中之一就是回音,不套用 */
47
+ const emittedRef = React.useRef([]);
48
+ const recreateKey = JSON.stringify(RECREATE_KEYS.map(key => props[key]));
49
+ useUnappliedPropsWarning(props, recreateKey);
50
+ React.useEffect(() => {
51
+ const container = containerRef.current;
52
+ // 每個實例用自己的 host:ready 前就卸載的實例,之後 vditor 還會往 host 裡建 DOM,不能碰到下一個實例
53
+ const host = document.createElement("div");
54
+ container.appendChild(host);
55
+ let disposed = false;
56
+ let ready = false;
57
+ const callbacks = {};
58
+ CALLBACK_KEYS.forEach(key => {
59
+ callbacks[key] = (...args) => propsRef.current[key]?.(...args);
60
+ });
61
+ callbacks.input = value => {
62
+ contentRef.current = value;
63
+ if (propsRef.current.value !== undefined) {
64
+ // 只有受控用法才需要辨認回音;保留最近 100 筆就夠了
65
+ emittedRef.current = emittedRef.current.slice(-99).concat(value);
66
+ }
67
+ propsRef.current.input?.(value);
68
+ };
69
+ callbacks.after = () => {
70
+ if (disposed) {
71
+ // ready 前就被卸載了(例如 React 18 StrictMode 的第一次掛載),現在才能正確 destroy
72
+ instance.destroy();
73
+ return;
74
+ }
75
+ // Lute 預設把 $…$ 當成數學式,GFM 沒有這個語法;關掉之後才放入內容,讓它原樣顯示成文字
76
+ instance.vditor.lute.SetInlineMath(false);
77
+ // ready 前主題若變過(例如 auto 跟著系統切換),這裡補上
78
+ instance.setTheme(editorTheme(themeRef.current));
79
+ // 用 contentRef:ready 前使用端若改過 value,這裡一併套用
80
+ instance.setValue(contentRef.current ?? instance.getValue(), true);
81
+ ready = true;
82
+ editorRef.current = instance;
83
+ const queue = queueRef.current;
84
+ queueRef.current = [];
85
+ queue.forEach(run => run(instance));
86
+ contentRef.current = instance.getValue();
87
+ propsRef.current.after?.();
88
+ };
89
+ const {
90
+ className,
91
+ style,
92
+ ...options
93
+ } = propsRef.current;
94
+ // 先用空內容建立,after 裡關掉行內數學式之後才放入真正的內容;否則初次渲染就會去載入 katex
95
+ const instance = new VditorCore(host, {
96
+ ...gfmEditorOptions({
97
+ ...options,
98
+ value: "",
99
+ theme: editorTheme(themeRef.current)
100
+ }, options.cdn || defaultCdn()),
101
+ ...callbacks
102
+ });
103
+ return () => {
104
+ disposed = true;
105
+ editorRef.current = null;
106
+ if (ready) {
107
+ contentRef.current = instance.getValue();
108
+ instance.destroy();
109
+ } else {
110
+ // vditor 在 init 之前 destroy 會拋錯;設這個內部旗標讓還沒開始的 init 直接結束
111
+ instance.isDestroyed = true;
112
+ }
113
+ host.remove();
114
+ };
115
+ }, [recreateKey]);
116
+
117
+ // value:跟編輯器內容不同、而且不是 input 的回音時才套用
118
+ React.useEffect(() => {
119
+ const value = props.value;
120
+ if (value === undefined) {
121
+ return;
122
+ }
123
+ const echoIndex = emittedRef.current.lastIndexOf(value);
124
+ if (echoIndex >= 0) {
125
+ emittedRef.current = emittedRef.current.slice(echoIndex + 1);
126
+ return;
127
+ }
128
+ if (value === contentRef.current) {
129
+ return;
130
+ }
131
+ contentRef.current = value;
132
+ emittedRef.current = [];
133
+ // 還沒 ready:after 裡會用 contentRef 放入內容
134
+ editorRef.current?.setValue(value);
135
+ }, [props.value]);
136
+
137
+ // theme:外框用 setTheme 切換;內容主題與程式碼主題跟著容器的 class,不用重建
138
+ React.useEffect(() => {
139
+ // 還沒 ready:after 裡會用 themeRef 補上
140
+ editorRef.current?.setTheme(editorTheme(theme));
141
+ }, [theme]);
142
+ React.useImperativeHandle(ref, () => {
143
+ const handle = {};
144
+ COMMANDS.forEach(name => {
145
+ handle[name] = (...args) => {
146
+ const run = instance => instance[name](...args);
147
+ const editor = editorRef.current;
148
+ if (editor) {
149
+ return run(editor);
150
+ }
151
+ queueRef.current.push(run);
152
+ };
153
+ });
154
+ QUERIES.forEach(name => {
155
+ handle[name] = (...args) => {
156
+ const editor = editorRef.current;
157
+ if (editor) {
158
+ return editor[name](...args);
159
+ }
160
+ // ready 前查不到真正的狀態,回傳已知的值
161
+ if (name === "getValue") {
162
+ return contentRef.current ?? "";
163
+ }
164
+ if (name === "getCurrentMode") {
165
+ return propsRef.current.mode || "wysiwyg"; // 跟 gfm.js 的預設 mode 一致
166
+ }
167
+ return undefined;
168
+ };
169
+ });
170
+ return handle;
171
+ }, []);
172
+ const className = [themeClassName(theme), props.className].filter(Boolean).join(" ");
173
+ return /*#__PURE__*/React.createElement("div", {
174
+ ref: containerRef,
175
+ className: className,
176
+ style: props.style
177
+ });
178
+ });
179
+
180
+ /** 其他 props:編輯器建立後才變動的不會套用,開發模式下每個 prop 提醒一次 */
181
+ const useUnappliedPropsWarning = (props, recreateKey) => {
182
+ /** 建立編輯器時的其他 props */
183
+ const createdRef = React.useRef({});
184
+ const warnedRef = React.useRef(new Set());
185
+ React.useEffect(() => {
186
+ createdRef.current = snapshotOtherProps(props);
187
+ }, [recreateKey]);
188
+ React.useEffect(() => {
189
+ if (!isDevelopment()) {
190
+ return;
191
+ }
192
+ const now = snapshotOtherProps(props);
193
+ Object.keys({
194
+ ...now,
195
+ ...createdRef.current
196
+ }).forEach(key => {
197
+ if (now[key] !== createdRef.current[key] && !warnedRef.current.has(key)) {
198
+ warnedRef.current.add(key);
199
+ console.warn(`[react-vditor] prop "${key}" 在編輯器建立後變動不會套用;需要的話請改 key 讓元件重建`);
200
+ }
201
+ });
202
+ });
203
+ };
204
+ const snapshotOtherProps = props => {
205
+ const snapshot = {};
206
+ Object.keys(props).forEach(key => {
207
+ if (!HANDLED_KEYS.includes(key)) {
208
+ snapshot[key] = JSON.stringify(props[key]) ?? "";
209
+ }
210
+ });
211
+ return snapshot;
212
+ };
@@ -0,0 +1,36 @@
1
+ import * as React from "react";
2
+ import VditorCore from "vditor";
3
+ import { defaultCdn } from "./cdn.js";
4
+ import { gfmPreviewOptions } from "./gfm.js";
5
+ import { themeClassName, useResolvedTheme } from "./theme.js";
6
+
7
+ /**
8
+ * 只渲染 Markdown、不建立編輯器(包 Vditor.preview)。
9
+ * 內容的 prop 叫 value 而不是 markdown:IPreviewOptions 已經有 markdown,是語法設定。
10
+ */
11
+ export const VditorPreview = props => {
12
+ const {
13
+ value,
14
+ theme: themeProp,
15
+ className,
16
+ style,
17
+ ...options
18
+ } = props;
19
+ const theme = useResolvedTheme(themeProp);
20
+ const elementRef = React.useRef(null);
21
+ // 選項常常是每次 render 都新建的物件;用內容比較,避免每次 render 都重新渲染
22
+ const optionsKey = JSON.stringify(options);
23
+ React.useEffect(() => {
24
+ VditorCore.preview(elementRef.current, value, gfmPreviewOptions(options, options.cdn || defaultCdn()));
25
+ }, [value, optionsKey]); // options 以內容比較(optionsKey),不放物件本身
26
+
27
+ // 主題只看外層容器的 class(index.css 裡局部化的樣式),換主題不用重新渲染;
28
+ // 內層交給 Vditor.preview,它會在上面加 vditor-reset
29
+ const wrapperClassName = ["react-vditor-preview", themeClassName(theme), className].filter(Boolean).join(" ");
30
+ return /*#__PURE__*/React.createElement("div", {
31
+ className: wrapperClassName,
32
+ style: style
33
+ }, /*#__PURE__*/React.createElement("div", {
34
+ ref: elementRef
35
+ }));
36
+ };
package/dist/cdn.js ADDED
@@ -0,0 +1,9 @@
1
+ // vditor 在執行期會去 `${cdn}/dist/...` 載入 lute、語系、圖示等資源。
2
+ // webpack plugin 把它們輸出在 output 目錄底下的 vditor/,所以預設 cdn 跟著 webpack 的 publicPath 走。
3
+
4
+ /* global __webpack_public_path__ */
5
+ export const defaultCdn = () => {
6
+ // 不是用 webpack 打包時,typeof 未宣告的識別字會得到 "undefined",不會拋錯
7
+ const base = typeof __webpack_public_path__ === "string" ? __webpack_public_path__ : "/";
8
+ return base === "" || base.endsWith("/") ? `${base}vditor` : `${base}/vditor`;
9
+ };