kindy-editor 1.0.0 → 2.0.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 CHANGED
@@ -1,269 +1,278 @@
1
- # kindy-editor (Kindy Editor)
2
-
3
- Trình biên tập tài liệu chuẩn Office (Word-like Document Editor) hiện đại dựa trên **Vue 3** và **Tiptap 3**. Được tùy biến, tối ưu hóa và hỗ trợ 100% giao diện **Tiếng Việt**.
4
-
5
- [![npm version](https://img.shields.io/npm/v/kindy-editor.svg)](https://www.npmjs.com/package/kindy-editor)
6
- [![license](https://img.shields.io/github/license/hotienky/editor.svg)](./LICENSE)
7
-
8
- ---
9
-
10
- ## ✨ Tính năng nổi bật
11
-
12
- - 📄 **Phân trang dạng Word (Page-based Pagination)**: Hỗ trợ ngắt trang, căn lề, khổ giấy (A4, A3, Letter, Legal...) chân trang & đầu trang (Header/Footer).
13
- - 💬 **Hệ thống Bình luận (Word-style Comments)**: Bôi đen văn bản để gắn bình luận, phản hồi (Reply), hoàn thành (Resolve), cuộn mượt và highlight từng người dùng.
14
- - 🇻🇳 **Tiếng Việt 100%**: Chuẩn hóa toàn bộ nhãn giao diện, bộ cỡ chữ tiêu chuẩn (`pt`), từ điển ký hiệu và phông chữ mượt mà.
15
- - 🎨 **Giao diện hiện đại (Ribbon & Classic)**: Hỗ trợ 2 chế độ thanh công cụ dạng Ribbon (như MS Word) hoặc Classic, chế độ tối (Dark mode).
16
- - 🔠 **Phông chữ hệ thống & Web Fonts chuẩn**: Tích hợp phông hệ thống mượt nét (`San Francisco`, `Segoe UI`, `Roboto`, `Arial`, `Times New Roman`...) không bị vỡ hay lệch dấu Tiếng Việt.
17
- - 🗄️ **Lưu trữ linh hoạt**: Tự động mã hóa nhúng bình luận vào HTML/JSON hoặc tách riêng lưu vào cơ sở dữ liệu (Database).
1
+ # Kindy Editor v2
2
+
3
+ Kindy Editor là SDK Document Library chuyên cho DOCX, xây dựng bằng Vue 3 và Tiptap/ProseMirror. Thư viện cung cấp giao diện quản lý tài liệu, editor trên web, lịch sử phiên bản, DOCX codec và hợp đồng API để ứng dụng chủ kết nối backend của mình.
4
+
5
+ Kindy Editor **không** cung cấp backend, database, authentication, object storage hoặc nghiệp vụ hợp đồng. JSON `KindyDocumentState` là trạng thái chỉnh sửa chuẩn; DOCX gốc và DOCX export là artifact do server ứng dụng chủ lưu.
6
+
7
+ ## Phạm vi v2.0
8
+
9
+ - Import DOCX trong Web Worker, kiểm tra ZIP/OOXML và trả `CompatibilityReport`.
10
+ - Chỉnh sửa nội dung bằng Tiptap/ProseMirror với preset `Contract`: toolbar compact, page-only và chỉ giữ công cụ cần cho hợp đồng.
11
+ - Explorer theo thư mục, tìm kiếm, tạo tài liệu trống và tạo từ template.
12
+ - Autosave có debounce, cancellation và optimistic concurrency.
13
+ - Lưu thủ công tạo version, xem read-only và khôi phục version.
14
+ - Export DOCX OOXML thật bằng package `docx`.
15
+ - In hoặc Save as PDF qua print dialog của trình duyệt.
16
+ - UI engine có shell responsive, theme tokens, locale messages, slots và typed hooks.
17
+
18
+ Khả năng round-trip DOCX chỉ được cam kết trong [Kindy DOCX Compatibility Profile](./CAPABILITIES.md). Kindy không phải Microsoft Word layout engine và không cam kết giữ nguyên mọi tính năng OOXML.
19
+
20
+ Ngoại lệ duy nhất có thể bảo đảm byte-for-byte là tài liệu vừa import và **chưa có revision chỉnh sửa**: workspace tải lại artifact `original-docx` thay vì serialize JSON. Sau lần chỉnh sửa/save đầu tiên, DOCX mới được dựng từ `KindyDocumentState` và chỉ có cam kết theo compatibility profile.
21
+
22
+ ## Kiến trúc
23
+
24
+ ```text
25
+ Ứng dụng chủ
26
+ ├─ KindyDocumentLibrary
27
+ │ ├─ DocumentLibraryShell layout/responsive/theme
28
+ │ ├─ KindyDocumentExplorer list/search/folder/import/template
29
+ │ ├─ KindyEditor Tiptap/ProseMirror editor
30
+ │ └─ KindyVersionPanel preview/restore
31
+ ├─ DocumentLibraryClient autosave/conflict/events
32
+ ├─ DOCX codecs / browser print
33
+ └─ DocumentApiAdapter
34
+ ├─ RestDocumentAdapter
35
+ ├─ MemoryDocumentAdapter
36
+ └─ adapter riêng của ứng dụng
37
+ ```
18
38
 
19
- ---
39
+ UI không gọi URL cố định và không biết token. Mọi IO đi qua `DocumentApiAdapter`.
20
40
 
21
- ## 📦 Cài đặt
41
+ ## Cài đặt
22
42
 
23
43
  ```bash
24
44
  npm install kindy-editor
25
- # hoặc
26
- pnpm add kindy-editor
27
- # hoặc
28
- yarn add kindy-editor
29
45
  ```
30
46
 
31
- ---
32
-
33
- ## 🚀 Hướng dẫn tích hợp cho dự án khác (Integration Methods)
47
+ ```ts
48
+ import 'kindy-editor/style'
49
+ ```
34
50
 
35
- Thư viện `kindy-editor` hỗ trợ 4 cách tích hợp linh hoạt cho mọi môi trường dự án:
51
+ Vue phải được cài trong ứng dụng chủ và đáp ứng peer dependency `vue ^3.5.0`.
36
52
 
37
- ### Cách 1: Import Component (Vite / Webpack / Vue 3 SPA)
53
+ ## Quick start với REST API
38
54
 
39
55
  ```vue
40
56
  <template>
41
- <div style="height: 100vh;">
42
- <KindyEditor ref="editorRef" v-bind="editorOptions" />
43
- </div>
57
+ <KindyDocumentLibrary
58
+ ref="workspace"
59
+ :adapter="adapter"
60
+ :autosave="{ enabled: true, delay: 5000 }"
61
+ :ui="{ explorerWidth: '320px', versionsWidth: '300px' }"
62
+ :theme="{ '--kindy-library-primary': '#0b74de' }"
63
+ locale="vi-VN"
64
+ style="height: 100vh"
65
+ @saved="onSaved"
66
+ @error="onError"
67
+ />
44
68
  </template>
45
69
 
46
- <script setup>
70
+ <script setup lang="ts">
47
71
  import { ref } from 'vue'
48
- import { KindyEditor } from 'kindy-editor'
72
+ import {
73
+ KindyDocumentLibrary,
74
+ createRestDocumentAdapter,
75
+ } from 'kindy-editor'
49
76
  import 'kindy-editor/style'
50
77
 
51
- const editorRef = ref(null)
52
- const editorOptions = ref({
53
- locale: 'vi-VN',
54
- document: {
55
- title: 'Tài liệu mới',
56
- content: '<h1>Nội dung ban đầu</h1>',
57
- },
78
+ const workspace = ref()
79
+ const adapter = createRestDocumentAdapter({
80
+ baseUrl: '/document-api',
81
+ transport: (url, init) => fetch(url, {
82
+ ...init,
83
+ credentials: 'include',
84
+ headers: { ...init?.headers, 'x-tenant-id': 'acme' },
85
+ }),
58
86
  })
87
+
88
+ function onSaved(result: unknown) {
89
+ console.info('Saved', result)
90
+ }
91
+
92
+ function onError(error: unknown) {
93
+ console.error(error)
94
+ }
59
95
  </script>
60
96
  ```
61
97
 
62
- ### Cách 2: Đăng ký Global Vue Plugin (`main.js`)
98
+ Server phải triển khai contract trong [`openapi/document-api.yaml`](./openapi/document-api.yaml). OpenAPI không bắt buộc cơ chế auth; cookie, bearer token hoặc API gateway do ứng dụng chủ quyết định.
63
99
 
64
- ```javascript
65
- import { createApp } from 'vue'
66
- import App from './App.vue'
67
- import { useKindyEditor } from 'kindy-editor'
68
- import 'kindy-editor/style'
100
+ ### UI hợp đồng mặc định
69
101
 
70
- const app = createApp(App)
102
+ `KindyDocumentLibrary` tự dùng `CONTRACT_EDITOR_OPTIONS`: toolbar một hàng theo hướng Google Docs, chỉ có Định dạng/Chèn/Bảng/Trang/Xuất, không có Web view, Mermaid, biểu đồ, media web hoặc chuyển đổi số tiếng Trung. Nhóm Xuất chỉ gọi pipeline an toàn của Document Library cho Import DOCX, Export DOCX và In/PDF. Thanh trạng thái chỉ còn `Trang X / Y`, số ký tự, zoom và ngôn ngữ.
71
103
 
72
- // Đăng ký toàn cục component <KindyEditor />
73
- app.use(useKindyEditor, {
74
- locale: 'vi-VN',
75
- theme: 'light',
76
- })
104
+ Ứng dụng chủ vẫn có thể override rõ ràng qua `editorOptions`:
77
105
 
78
- app.mount('#app')
106
+ ```vue
107
+ <KindyDocumentLibrary
108
+ :adapter="adapter"
109
+ :editor-options="{
110
+ toolbar: { menus: ['base', 'insert', 'table', 'page', 'export'] },
111
+ statusbar: { showLocale: false },
112
+ }"
113
+ />
79
114
  ```
80
115
 
81
- ### Cách 3: Tích hợp SSR / Nuxt 3 (CommonJS Bundle)
116
+ Để dùng preset với `KindyEditor` độc lập:
82
117
 
83
- ```javascript
84
- // Nuxt 3 Plugin hoặc Client-only component
85
- import { KindyEditor } from 'kindy-editor' // Tự động resolve qua package.json "require"/"import"
86
- import 'kindy-editor/style'
87
- ```
118
+ ```ts
119
+ import { createContractEditorOptions } from 'kindy-editor'
88
120
 
89
- ### Cách 4: Tích hợp qua CDN / Direct Script Tag (IIFE Bundle)
90
-
91
- ```html
92
- <!DOCTYPE html>
93
- <html>
94
- <head>
95
- <!-- 1. CSS -->
96
- <link rel="stylesheet" href="https://unpkg.com/kindy-editor/dist/kindy-editor.css">
97
- <!-- 2. Vue 3 -->
98
- <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
99
- <!-- 3. Kindy Editor IIFE Bundle -->
100
- <script src="https://unpkg.com/kindy-editor/dist/kindy-editor.iife.js"></script>
101
- </head>
102
- <body>
103
- <div id="app">
104
- <kindy-editor :locale="'vi-VN'"></kindy-editor>
105
- </div>
106
-
107
- <script>
108
- const { createApp } = Vue;
109
- const app = createApp({});
110
- app.use(KindyEditor.useKindyEditor);
111
- app.mount('#app');
112
- </script>
113
- </body>
114
- ### Cách 5: Tích hợp vào React.js & Next.js (Sử dụng `mountKindyEditor`)
115
-
116
- Hàm `mountKindyEditor` cho phép bạn gắn `kindy-editor` vào bất kỳ thẻ HTML nào trong React.js / Next.js / Angular / Svelte mà **không cần cài thêm bất kỳ thư viện phụ thuộc nào**:
117
-
118
- #### A. Trong React.js (Component Wrapper):
119
-
120
- ```tsx
121
- import React, { useEffect, useRef } from 'react'
122
- import { mountKindyEditor } from 'kindy-editor'
123
- import 'kindy-editor/style'
121
+ const editorOptions = createContractEditorOptions({ locale: 'vi-VN' })
122
+ ```
124
123
 
125
- export function KindyEditorReact(props) {
126
- const containerRef = useRef(null)
124
+ ## Chạy demo không cần backend
127
125
 
128
- useEffect(() => {
129
- if (!containerRef.current) return
130
- // Gắn Editor vào DOM container
131
- const instance = mountKindyEditor(containerRef.current, props)
132
- // Cleanup khi component bị unmount
133
- return () => instance.unmount()
134
- }, [])
126
+ ```ts
127
+ import { createMemoryDocumentAdapter } from 'kindy-editor'
135
128
 
136
- return <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />
137
- }
129
+ const adapter = createMemoryDocumentAdapter()
138
130
  ```
139
131
 
140
- #### B. Trong Next.js (App Router / Pages Router):
132
+ `MemoryDocumentAdapter` chỉ dành cho demo và test; dữ liệu mất khi reload trang.
141
133
 
142
- Do trình biên tập tài liệu Rich Text phụ thuộc vào DOM trình duyệt (`window`, `document`), trong Next.js bạn cần nạp component dưới dạng Client Component với `next/dynamic` (`ssr: false`):
134
+ Trong repository:
143
135
 
144
- ```tsx
145
- 'use client'
136
+ ```bash
137
+ npm install
138
+ npm run dev
139
+ ```
146
140
 
147
- import dynamic from 'next/dynamic'
141
+ Mở `http://localhost:9000/kindy-editor`.
148
142
 
149
- // 1. Tắt SSR cho Editor component
150
- const KindyEditor = dynamic(
151
- () => import('./KindyEditorReact').then((mod) => mod.KindyEditorReact),
152
- { ssr: false }
153
- )
143
+ ## Dùng editor độc lập
154
144
 
155
- export default function DocumentPage() {
156
- return (
157
- <main style={{ height: '100vh' }}>
158
- <KindyEditor locale="vi-VN" />
159
- </main>
160
- )
161
- }
145
+ ```vue
146
+ <KindyEditor
147
+ ref="editor"
148
+ :document="{ content: initialState.content, assets: initialState.assets }"
149
+ :page="initialState.page"
150
+ />
162
151
  ```
163
152
 
164
- ---
153
+ ```ts
154
+ const state = editor.value.getState()
155
+ editor.value.setContent(nextContent)
156
+ editor.value.setReadOnly(true)
157
+ const printable = await editor.value.preparePrint()
158
+ editor.value.print()
159
+ ```
165
160
 
166
- ## 💬 Hướng dẫn & API Hệ thống Bình luận (Comments)
161
+ Vanilla/React/Angular/Svelte có thể dùng mount helper:
167
162
 
168
- ### 1. Cơ chế hoạt động
163
+ ```ts
164
+ import { mountKindyEditor } from 'kindy-editor'
169
165
 
170
- Bình luận được lưu trực tiếp dưới dạng thẻ `span` chứa thuộc tính `data-comment` và `data-thread` trong tài liệu HTML/JSON:
166
+ const mounted = mountKindyEditor('#editor', {
167
+ locale: 'vi-VN',
168
+ document: { content: initialState.content },
169
+ })
171
170
 
172
- ```html
173
- <span
174
- data-comment="c1a2b3"
175
- data-color="rgba(255, 213, 79, 0.4)"
176
- data-thread='{"id":"c1a2b3","text":"Cần chỉnh sửa","replies":[]}'
177
- class="kindy-comment"
178
- >
179
- Đoạn văn bản được bình luận
180
- </span>
171
+ mounted.instance.setContent(nextState.content)
172
+ mounted.instance.print()
173
+ mounted.unmount()
181
174
  ```
182
175
 
183
- ### 2. Các API tương tác với Bình luận
176
+ V2.0 chưa có native React component; wrapper dùng Vue mount helper.
184
177
 
185
- Thông qua `ref` của component `<KindyEditor ref="editorRef" />`:
178
+ ## Dùng headless client
186
179
 
187
- ```javascript
188
- // 1. Thêm bình luận cho vùng bôi đen hiện tại
189
- editorRef.value.addComment('Nội dung bình luận mới')
180
+ Subpath `kindy-editor/core` không tải Vue UI:
190
181
 
191
- // 2. Thêm phản hồi (Reply) cho 1 bình luận
192
- editorRef.value.addReply(commentId, 'Nội dung phản hồi')
182
+ ```ts
183
+ import {
184
+ createDocumentLibrary,
185
+ createRestDocumentAdapter,
186
+ } from 'kindy-editor/core'
193
187
 
194
- // 3. Đánh dấu Đã giải quyết (Resolve) hoặc Mở lại
195
- editorRef.value.resolveComment(commentId, true) // true: Đã giải quyết, false: Mở lại
188
+ const client = createDocumentLibrary({
189
+ adapter: createRestDocumentAdapter({ baseUrl: '/document-api' }),
190
+ autosave: { enabled: true, delay: 5000 },
191
+ })
196
192
 
197
- // 4. Xóa bình luận
198
- editorRef.value.removeComment(commentId)
193
+ const snapshot = await client.open('document-123')
194
+ client.updateState(snapshot.state)
195
+ await client.save('manual')
196
+ ```
199
197
 
200
- // 5. Tập trung con trỏ & cuộn màn hình tới vị trí bình luận
201
- editorRef.value.focusComment(commentId)
198
+ `VERSION_CONFLICT` dừng autosave. SDK không tự merge hoặc ghi đè revision mới hơn:
202
199
 
203
- // 6. Lấy danh sách toàn bộ bình luận dưới dạng mảng Object
204
- const comments = editorRef.value.getComments()
200
+ ```ts
201
+ client.on('save-failed', (error) => {
202
+ if (error.code === 'VERSION_CONFLICT') {
203
+ // Hiển thị lựa chọn reload hoặc lưu thành bản sao.
204
+ }
205
+ })
206
+ ```
205
207
 
206
- // 7. Đếm tổng số bình luận
207
- const total = editorRef.value.getCommentCount()
208
+ `kindy-editor/core` dùng được trong Node/SSR. Entry `kindy-editor` chứa Vue UI và
209
+ các công cụ browser như cropper/print, vì vậy chỉ import nó trong bundle phía client.
208
210
 
209
- // 8. Ẩn / Hiện thanh Sidebar bình luận
210
- editorRef.value.toggleCommentSidebar(true) // true: hiện, false: ẩn
211
- ```
211
+ ## Import và export DOCX
212
212
 
213
- ---
213
+ ```ts
214
+ import { importDocxInWorker, exportDocx } from 'kindy-editor'
214
215
 
215
- ## 🗄️ Hướng dẫn tích hợp Cơ sở dữ liệu (Database Integration)
216
+ const imported = await importDocxInWorker(file, {
217
+ mode: 'best-effort',
218
+ profile: 'kindy-docx-v2.0',
219
+ })
220
+ if (imported.report.issues.length) {
221
+ // Hiển thị warning trước khi tiếp tục.
222
+ }
216
223
 
217
- ### Cách 1: Gộp chung vào Nội dung (Khuyên dùng - Đơn giản nhất)
224
+ const output = await exportDocx(imported.state) // strict mặc định
225
+ ```
218
226
 
219
- Khi bạn lưu `content` thu được từ `onSave` hoặc `editorRef.value.getHTML()`, toàn bộ bình luận đã nằm sẵn bên trong thẻ `<span data-comment>`:
227
+ UI workspace tự xử lý luồng xác nhận best-effort khi import hoặc download. API programmatic mặc định strict và ném `DOCX_UNSUPPORTED` nếu state có feature ngoài profile.
228
+ Mặc định là v2.0. Chọn `kindy-docx-v2.1` cho sections/header/footer hoặc
229
+ `kindy-docx-v2.2` cho comments/Track Changes sau khi integration đã chạy corpus tương ứng.
220
230
 
221
- - **Lưu DB**: Lưu chuỗi `content` vào cột `content TEXT` trong bảng `documents`.
222
- - **Đọc DB**: Truyền lại `content` vào `:options="{ document: { content: data.content } }"` ➔ Bình luận tự động hiển thị lại 100%.
231
+ `POST /documents/import` phải lưu blob gốc và trả `DocumentRecord.originalSource` gồm `artifactId`, `revisionId`, `format: 'original-docx'` và `fileName`. Nhờ đó `KindyDocumentLibrary.downloadDocx()` trả đúng file gốc khi revision chưa đổi. URL artifact của REST backend phải là URL tải được (thường là signed URL); Memory adapter trả trực tiếp `Blob`.
223
232
 
224
- ### Cách 2: Tách Bình luận ra Bảng Database riêng
233
+ ## Public components
225
234
 
226
- Nếu bạn muốn quản lý bình luận riêng trong bảng `comments` (để thông báo, phân quyền):
235
+ | Export | Mục đích |
236
+ |---|---|
237
+ | `KindyDocumentLibrary` | Workspace hoàn chỉnh, kết nối adapter và điều phối state |
238
+ | `KindyDocumentLibraryShell` | Layout ba vùng độc lập cho UI tùy biến |
239
+ | `KindyDocumentExplorer` | List/search/folder/import/template độc lập |
240
+ | `KindyEditor` | Editor độc lập |
241
+ | `KindyVersionPanel` | Lịch sử version độc lập |
227
242
 
228
- ```javascript
229
- const saveToBackend = async () => {
230
- const htmlContent = editorRef.value.getHTML()
231
- const commentsList = editorRef.value.getComments()
243
+ ## Events chuẩn
232
244
 
233
- await fetch('/api/documents/save', {
234
- method: 'POST',
235
- headers: { 'Content-Type': 'application/json' },
236
- body: JSON.stringify({
237
- docId: 'doc_123',
238
- content: htmlContent,
239
- comments: commentsList, // Danh sách mảng comment object
240
- }),
241
- })
242
- }
243
- ```
245
+ `ready`, `opened`, `changed`, `save-started`, `saved`, `save-failed`, `created`, `imported`, `compatibility-warning`, `version-restored`, `printed`, `error`.
244
246
 
245
- ---
247
+ ## Error codes
246
248
 
247
- ## 🛠️ Lệnh phát triển (Development Commands)
249
+ `DOCX_INVALID`, `DOCX_UNSUPPORTED`, `IMPORT_FAILED`, `EXPORT_FAILED`, `ADAPTER_ERROR`, `NETWORK_ERROR`, `VERSION_CONFLICT`, `DOCUMENT_NOT_FOUND`, `OPERATION_CANCELLED`.
248
250
 
249
- ```bash
250
- # Cài đặt phụ thuộc
251
- npm install
251
+ ## Hiệu năng tài liệu dài
252
252
 
253
- # Chạy server phát triển (Dev server tại http://localhost:9000/kindy-editor)
254
- npm run dev
253
+ Import parsing đã chạy ngoài main thread. Editor vẫn dùng một ProseMirror instance liên tục; pagination cache phép đo block và workspace gom state sync. Trên production preview cục bộ (Chrome 151, Apple M2/24GB, 40 mẫu), corpus 100 trang text mở khoảng 0,49 giây với typing p95 21,5ms; mixed mở khoảng 0,85 giây với typing p95 36,5ms. Pagination cached của hai corpus lần lượt khoảng 2,8ms và 3,7ms. Đây là regression baseline, không phải SLA cho mọi thiết bị. Corpus mixed 200 trang vẫn chỉ được xem là stress limit. Xem [Performance guide](./docs/performance.md) để biết phương pháp đo và giới hạn.
255
254
 
256
- # Đóng gói sản phẩm (Production build ra thư mục dist/)
257
- npm run build
255
+ ## Tài liệu
258
256
 
259
- # Định dạng code
260
- npm run format
261
- ```
257
+ - [Bắt đầu tích hợp](./docs/getting-started.md)
258
+ - [UI engine, theme, responsive và slots](./docs/ui-engine.md)
259
+ - [API reference](./docs/api-reference.md)
260
+ - [Backend, security và adapter](./GUIDE.md)
261
+ - [DOCX capability matrix](./CAPABILITIES.md)
262
+ - [Performance và tài liệu 100 trang](./docs/performance.md)
263
+ - [Migration v1 → v2](./MIGRATION.md)
264
+ - [REST OpenAPI](./openapi/document-api.yaml)
262
265
 
263
- ---
266
+ ## Phát triển và kiểm thử
264
267
 
265
- ## 📄 License & Credits
268
+ ```bash
269
+ npm test
270
+ npm run test:e2e
271
+ npm run test:package
272
+ npm run test:libreoffice # cần có soffice; CI tự cài LibreOffice
273
+ npm run typecheck
274
+ npm run lint:check
275
+ npm run build
276
+ ```
266
277
 
267
- - Phát hành theo giấy phép [MIT License](./LICENSE).
268
- - Dựa trên mã nguồn gốc [Umo Editor](https://github.com/umodoc/editor) bởi đội ngũ umodoc.
269
- - Bản quyền thuộc về **Kindy** ([GitHub](https://github.com/hotienky)).
278
+ License: MIT.