nexus-shared 1.1.20 → 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.
Files changed (332) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +903 -0
  3. package/dist/Client.Index.d.ts +44 -0
  4. package/dist/Client.Index.js +49 -0
  5. package/dist/Components/Documents/Button.d.ts +29 -0
  6. package/dist/Components/Documents/Button.js +11 -0
  7. package/dist/Components/Documents/Menu.d.ts +102 -0
  8. package/dist/Components/Documents/Menu.js +238 -0
  9. package/dist/Components/Documents/SplitButton.d.ts +64 -0
  10. package/dist/Components/Documents/SplitButton.js +127 -0
  11. package/dist/Components/Documents/TabButtons.d.ts +62 -0
  12. package/dist/Components/Documents/TabButtons.js +19 -0
  13. package/dist/Components/Forms/ApiForm.d.ts +21 -0
  14. package/dist/Components/Forms/ApiForm.js +99 -0
  15. package/dist/Components/Forms/Crud.d.ts +17 -0
  16. package/dist/Components/Forms/Crud.js +556 -0
  17. package/dist/Components/Forms/CrudParts.d.ts +15 -0
  18. package/dist/Components/Forms/CrudParts.js +36 -0
  19. package/dist/Components/Forms/Form.d.ts +52 -0
  20. package/dist/Components/Forms/Form.js +319 -0
  21. package/dist/Components/Forms/SubmitForm.d.ts +65 -0
  22. package/dist/Components/Forms/SubmitForm.js +198 -0
  23. package/dist/Components/Inputs/Calendar.d.ts +69 -0
  24. package/dist/Components/Inputs/Calendar.js +258 -0
  25. package/dist/Components/Inputs/CheckBox.d.ts +53 -0
  26. package/dist/Components/Inputs/CheckBox.js +87 -0
  27. package/dist/Components/Inputs/CheckBoxGroup.d.ts +20 -0
  28. package/dist/Components/Inputs/CheckBoxGroup.js +80 -0
  29. package/dist/Components/Inputs/ChoiceField.d.ts +122 -0
  30. package/dist/Components/Inputs/ChoiceField.js +169 -0
  31. package/dist/Components/Inputs/DatePicker.d.ts +54 -0
  32. package/dist/Components/Inputs/DatePicker.js +246 -0
  33. package/dist/Components/Inputs/DateRangePicker.d.ts +62 -0
  34. package/dist/Components/Inputs/DateRangePicker.js +283 -0
  35. package/dist/Components/Inputs/DateTimePicker.d.ts +75 -0
  36. package/dist/Components/Inputs/DateTimePicker.js +399 -0
  37. package/dist/Components/Inputs/Dropdown.d.ts +117 -0
  38. package/dist/Components/Inputs/Dropdown.js +839 -0
  39. package/dist/Components/Inputs/DropdownParts.d.ts +84 -0
  40. package/dist/Components/Inputs/DropdownParts.js +102 -0
  41. package/dist/Components/Inputs/EditorShell.d.ts +45 -0
  42. package/dist/Components/Inputs/EditorShell.js +214 -0
  43. package/dist/Components/Inputs/Field.d.ts +53 -0
  44. package/dist/Components/Inputs/Field.js +16 -0
  45. package/dist/Components/Inputs/GroupForm.d.ts +20 -0
  46. package/dist/Components/Inputs/GroupForm.js +170 -0
  47. package/dist/Components/Inputs/InputField.d.ts +59 -0
  48. package/dist/Components/Inputs/InputField.js +169 -0
  49. package/dist/Components/Inputs/InputRenderer.d.ts +35 -0
  50. package/dist/Components/Inputs/InputRenderer.js +99 -0
  51. package/dist/Components/Inputs/MarkdownEditor.d.ts +67 -0
  52. package/dist/Components/Inputs/MarkdownEditor.js +289 -0
  53. package/dist/Components/Inputs/NumberBox.d.ts +53 -0
  54. package/dist/Components/Inputs/NumberBox.js +228 -0
  55. package/dist/Components/Inputs/RadioGroup.d.ts +20 -0
  56. package/dist/Components/Inputs/RadioGroup.js +43 -0
  57. package/dist/Components/Inputs/ReadOnlyNotice.d.ts +36 -0
  58. package/dist/Components/Inputs/ReadOnlyNotice.js +116 -0
  59. package/dist/Components/Inputs/RichTextEditor.d.ts +103 -0
  60. package/dist/Components/Inputs/RichTextEditor.js +1170 -0
  61. package/dist/Components/Inputs/RichTextParts.d.ts +63 -0
  62. package/dist/Components/Inputs/RichTextParts.js +157 -0
  63. package/dist/Components/Inputs/RowsInput.d.ts +104 -0
  64. package/dist/Components/Inputs/RowsInput.js +259 -0
  65. package/dist/Components/Inputs/Slider.d.ts +87 -0
  66. package/dist/Components/Inputs/Slider.js +216 -0
  67. package/dist/Components/Inputs/TabularForm.d.ts +23 -0
  68. package/dist/Components/Inputs/TabularForm.js +283 -0
  69. package/dist/Components/Inputs/TextArea.d.ts +43 -0
  70. package/dist/Components/Inputs/TextArea.js +31 -0
  71. package/dist/Components/Inputs/TextBox.d.ts +52 -0
  72. package/dist/Components/Inputs/TextBox.js +58 -0
  73. package/dist/Components/Inputs/TextField.d.ts +31 -0
  74. package/dist/Components/Inputs/TextField.js +35 -0
  75. package/dist/Components/Inputs/TimePanel.d.ts +34 -0
  76. package/dist/Components/Inputs/TimePanel.js +288 -0
  77. package/dist/Components/Inputs/TimePicker.d.ts +54 -0
  78. package/dist/Components/Inputs/TimePicker.js +239 -0
  79. package/dist/Components/Inputs/TreeView.d.ts +160 -0
  80. package/dist/Components/Inputs/TreeView.js +947 -0
  81. package/dist/Components/Layouts/MessageBox.d.ts +71 -0
  82. package/dist/Components/Layouts/MessageBox.js +104 -0
  83. package/dist/Components/Layouts/Popup.d.ts +92 -0
  84. package/dist/Components/Layouts/Popup.js +376 -0
  85. package/dist/Components/Layouts/PopupContext.d.ts +2 -0
  86. package/dist/Components/Layouts/PopupContext.js +4 -0
  87. package/dist/Components/Layouts/PopupDock.d.ts +22 -0
  88. package/dist/Components/Layouts/PopupDock.js +121 -0
  89. package/dist/Components/Layouts/PopupHost.d.ts +56 -0
  90. package/dist/Components/Layouts/PopupHost.js +163 -0
  91. package/dist/Components/Layouts/PopupRules.d.ts +30 -0
  92. package/dist/Components/Layouts/PopupRules.js +63 -0
  93. package/dist/Components/Layouts/ThemePicker.d.ts +13 -0
  94. package/dist/Components/Layouts/ThemePicker.js +22 -0
  95. package/dist/Components/Layouts/ThemeScript.d.ts +14 -0
  96. package/dist/Components/Layouts/ThemeScript.js +13 -0
  97. package/dist/Components/Layouts/ThemeSwitcher.d.ts +13 -0
  98. package/dist/Components/Layouts/ThemeSwitcher.js +29 -0
  99. package/dist/Components/Layouts/Toaster.d.ts +76 -0
  100. package/dist/Components/Layouts/Toaster.js +172 -0
  101. package/dist/Components/Viewers/DataTable.d.ts +25 -0
  102. package/dist/Components/Viewers/DataTable.js +542 -0
  103. package/dist/Components/Viewers/DataTableParts.d.ts +134 -0
  104. package/dist/Components/Viewers/DataTableParts.js +145 -0
  105. package/dist/Components/Viewers/MarkdownView.d.ts +26 -0
  106. package/dist/Components/Viewers/MarkdownView.js +79 -0
  107. package/dist/Components/Viewers/RichTextView.d.ts +18 -0
  108. package/dist/Components/Viewers/RichTextView.js +17 -0
  109. package/dist/Helpers/AnimationHelpers.d.ts +6 -0
  110. package/dist/Helpers/AnimationHelpers.js +35 -0
  111. package/dist/Helpers/ApiClient.d.ts +74 -0
  112. package/dist/Helpers/ApiClient.js +197 -0
  113. package/dist/Helpers/ApiFormHelpers.d.ts +26 -0
  114. package/dist/Helpers/ApiFormHelpers.js +56 -0
  115. package/dist/Helpers/ApiModules.d.ts +135 -0
  116. package/dist/Helpers/ApiModules.js +194 -0
  117. package/dist/Helpers/ApiResponses.d.ts +41 -0
  118. package/dist/Helpers/ApiResponses.js +159 -0
  119. package/dist/Helpers/ApiRoutes.d.ts +150 -0
  120. package/dist/Helpers/ApiRoutes.js +191 -0
  121. package/dist/Helpers/ApiToasts.d.ts +21 -0
  122. package/dist/Helpers/ApiToasts.js +22 -0
  123. package/dist/Helpers/ClassHelpers.d.ts +3 -0
  124. package/dist/Helpers/ClassHelpers.js +4 -0
  125. package/dist/Helpers/CrudBackend.d.ts +56 -0
  126. package/dist/Helpers/CrudBackend.js +119 -0
  127. package/dist/Helpers/CrudHelpers.d.ts +76 -0
  128. package/dist/Helpers/CrudHelpers.js +202 -0
  129. package/dist/Helpers/DateHelpers.d.ts +88 -0
  130. package/dist/Helpers/DateHelpers.js +363 -0
  131. package/dist/Helpers/DatePreferences.d.ts +14 -0
  132. package/dist/Helpers/DatePreferences.js +94 -0
  133. package/dist/Helpers/DateTimeHelpers.d.ts +87 -0
  134. package/dist/Helpers/DateTimeHelpers.js +286 -0
  135. package/dist/Helpers/DragHelpers.d.ts +129 -0
  136. package/dist/Helpers/DragHelpers.js +281 -0
  137. package/dist/Helpers/DropdownHelpers.d.ts +56 -0
  138. package/dist/Helpers/DropdownHelpers.js +180 -0
  139. package/dist/Helpers/FormDrafts.d.ts +18 -0
  140. package/dist/Helpers/FormDrafts.js +76 -0
  141. package/dist/Helpers/FormStore.d.ts +231 -0
  142. package/dist/Helpers/FormStore.js +734 -0
  143. package/dist/Helpers/InputParamsHelpers.d.ts +79 -0
  144. package/dist/Helpers/InputParamsHelpers.js +388 -0
  145. package/dist/Helpers/MarkdownEditing.d.ts +27 -0
  146. package/dist/Helpers/MarkdownEditing.js +250 -0
  147. package/dist/Helpers/MarkdownHelpers.d.ts +82 -0
  148. package/dist/Helpers/MarkdownHelpers.js +775 -0
  149. package/dist/Helpers/MessageBuilder.d.ts +74 -0
  150. package/dist/Helpers/MessageBuilder.js +254 -0
  151. package/dist/Helpers/NepaliCalendar.d.ts +25 -0
  152. package/dist/Helpers/NepaliCalendar.js +107 -0
  153. package/dist/Helpers/NumberHelpers.d.ts +71 -0
  154. package/dist/Helpers/NumberHelpers.js +202 -0
  155. package/dist/Helpers/Permissions.d.ts +62 -0
  156. package/dist/Helpers/Permissions.js +133 -0
  157. package/dist/Helpers/PopoverHelpers.d.ts +43 -0
  158. package/dist/Helpers/PopoverHelpers.js +91 -0
  159. package/dist/Helpers/RichTextHelpers.d.ts +77 -0
  160. package/dist/Helpers/RichTextHelpers.js +1072 -0
  161. package/dist/Helpers/RowsStore.d.ts +160 -0
  162. package/dist/Helpers/RowsStore.js +584 -0
  163. package/dist/Helpers/TableColumns.d.ts +40 -0
  164. package/dist/Helpers/TableColumns.js +254 -0
  165. package/dist/Helpers/TableExport.d.ts +30 -0
  166. package/dist/Helpers/TableExport.js +97 -0
  167. package/dist/Helpers/TableHelpers.d.ts +102 -0
  168. package/dist/Helpers/TableHelpers.js +281 -0
  169. package/dist/Helpers/TableStore.d.ts +142 -0
  170. package/dist/Helpers/TableStore.js +530 -0
  171. package/dist/Helpers/ThemeHelpers.d.ts +15 -0
  172. package/dist/Helpers/ThemeHelpers.js +16 -0
  173. package/dist/Helpers/TimeHelpers.d.ts +73 -0
  174. package/dist/Helpers/TimeHelpers.js +253 -0
  175. package/dist/Helpers/TreeStore.d.ts +188 -0
  176. package/dist/Helpers/TreeStore.js +577 -0
  177. package/dist/Helpers/ValidationHelpers.d.ts +124 -0
  178. package/dist/Helpers/ValidationHelpers.js +301 -0
  179. package/dist/Interfaces/ApiInterfaces.d.ts +149 -0
  180. package/dist/Interfaces/ApiInterfaces.js +24 -0
  181. package/dist/Interfaces/CrudInterfaces.d.ts +171 -0
  182. package/{src/sso-client.tsx → dist/Interfaces/CrudInterfaces.js} +1 -1
  183. package/dist/Interfaces/DateInterfaces.d.ts +154 -0
  184. package/dist/Interfaces/DateInterfaces.js +29 -0
  185. package/dist/Interfaces/FormInterfaces.d.ts +352 -0
  186. package/dist/Interfaces/FormInterfaces.js +1 -0
  187. package/dist/Interfaces/InputInterfaces.d.ts +141 -0
  188. package/dist/Interfaces/InputInterfaces.js +1 -0
  189. package/dist/Interfaces/MessageInterfaces.d.ts +111 -0
  190. package/dist/Interfaces/MessageInterfaces.js +5 -0
  191. package/dist/Interfaces/TableInterfaces.d.ts +291 -0
  192. package/dist/Interfaces/TableInterfaces.js +1 -0
  193. package/dist/Interfaces/ThemeInterfaces.d.ts +17 -0
  194. package/dist/Interfaces/ThemeInterfaces.js +17 -0
  195. package/dist/Interfaces/TypeInterfaces.d.ts +4 -0
  196. package/dist/Interfaces/TypeInterfaces.js +1 -0
  197. package/dist/Server.Index.d.ts +2 -0
  198. package/dist/Server.Index.js +5 -0
  199. package/dist/Services/ApiProxy.d.ts +32 -0
  200. package/dist/Services/ApiProxy.js +111 -0
  201. package/dist/Services/BrowserApi.d.ts +78 -0
  202. package/dist/Services/BrowserApi.js +111 -0
  203. package/dist/Services/ServerApi.d.ts +49 -0
  204. package/dist/Services/ServerApi.js +75 -0
  205. package/dist/Services/ThemeService.d.ts +17 -0
  206. package/dist/Services/ThemeService.js +83 -0
  207. package/dist/Shared.Index.d.ts +43 -0
  208. package/dist/Shared.Index.js +47 -0
  209. package/package.json +82 -33
  210. package/src/Styles/Nexus.Base.css +70 -0
  211. package/src/Styles/Nexus.Button.css +151 -0
  212. package/src/Styles/Nexus.Calendar.css +275 -0
  213. package/src/Styles/Nexus.Choice.css +345 -0
  214. package/src/Styles/Nexus.Crud.css +84 -0
  215. package/src/Styles/Nexus.DateTime.css +81 -0
  216. package/src/Styles/Nexus.Dropdown.css +774 -0
  217. package/src/Styles/Nexus.Editor.css +297 -0
  218. package/src/Styles/Nexus.Form.css +298 -0
  219. package/src/Styles/Nexus.Index.css +34 -0
  220. package/src/Styles/Nexus.Input.css +637 -0
  221. package/src/Styles/Nexus.Markdown.css +265 -0
  222. package/src/Styles/Nexus.Menu.css +249 -0
  223. package/src/Styles/Nexus.Popup.css +731 -0
  224. package/src/Styles/Nexus.RichText.css +463 -0
  225. package/src/Styles/Nexus.Rows.css +566 -0
  226. package/src/Styles/Nexus.Slider.css +278 -0
  227. package/src/Styles/Nexus.Tab.Buttons.css +364 -0
  228. package/src/Styles/Nexus.Table.css +1014 -0
  229. package/src/Styles/Nexus.Theme.Picker.css +159 -0
  230. package/src/Styles/Nexus.Themes.css +194 -0
  231. package/src/Styles/Nexus.Time.css +147 -0
  232. package/src/Styles/Nexus.Toast.css +265 -0
  233. package/src/Styles/Nexus.Tokens.css +72 -0
  234. package/src/Styles/Nexus.Tree.css +435 -0
  235. package/src/api-services/authentication-service.tsx +0 -23
  236. package/src/api-services/preference-service.tsx +0 -5
  237. package/src/api-services/system-service.tsx +0 -34
  238. package/src/client.ts +0 -31
  239. package/src/components/documents/button.tsx +0 -142
  240. package/src/components/documents/icon-box.tsx +0 -93
  241. package/src/components/documents/page-title.tsx +0 -7
  242. package/src/components/documents/tab-button.tsx +0 -172
  243. package/src/components/documents/tag.tsx +0 -30
  244. package/src/components/index.js +0 -0
  245. package/src/components/inputs/checkbox-input.tsx +0 -66
  246. package/src/components/inputs/input-box.tsx +0 -48
  247. package/src/components/inputs/input-element.tsx +0 -65
  248. package/src/components/inputs/input-form.tsx +0 -164
  249. package/src/components/inputs/input.tsx +0 -181
  250. package/src/components/inputs/number-input.tsx +0 -108
  251. package/src/components/inputs/radiobox-input.tsx +0 -53
  252. package/src/components/inputs/textarea-input.tsx +0 -47
  253. package/src/components/inputs/textbox-input.tsx +0 -45
  254. package/src/components/layouts/global-dialogbox.tsx +0 -431
  255. package/src/components/layouts/global-layout.tsx +0 -59
  256. package/src/components/layouts/layout-helpers.tsx +0 -20
  257. package/src/components/layouts/panels/user-panel.tsx +0 -112
  258. package/src/components/layouts/utility-menu.tsx +0 -49
  259. package/src/components/panels/theme-panel.tsx +0 -46
  260. package/src/helpers/bitwise-helpers.tsx +0 -11
  261. package/src/helpers/browser-helpers.tsx +0 -156
  262. package/src/helpers/datasource-helpers.tsx +0 -99
  263. package/src/helpers/element-helpers.tsx +0 -57
  264. package/src/helpers/input-helpers.tsx +0 -74
  265. package/src/helpers/string-helpers.tsx +0 -28
  266. package/src/helpers/utility-helpers.tsx +0 -45
  267. package/src/helpers/validation-helpers.tsx +0 -302
  268. package/src/index.ts +0 -26
  269. package/src/interface.ts +0 -19
  270. package/src/interfaces/auth-token-interfaces.tsx +0 -6
  271. package/src/interfaces/browser-interfaces.tsx +0 -36
  272. package/src/interfaces/button-interfaces.tsx +0 -63
  273. package/src/interfaces/datasource-interfaces.tsx +0 -22
  274. package/src/interfaces/datatable-interfaces.tsx +0 -25
  275. package/src/interfaces/dialogbox-interfaces.tsx +0 -5
  276. package/src/interfaces/exception-interfaces.tsx +0 -99
  277. package/src/interfaces/http-interfaces.tsx +0 -129
  278. package/src/interfaces/icon-interfaces.tsx +0 -129
  279. package/src/interfaces/input-interfaces.tsx +0 -410
  280. package/src/interfaces/layout-interfaces.tsx +0 -191
  281. package/src/interfaces/menu-interfaces.tsx +0 -49
  282. package/src/interfaces/message-interfaces.tsx +0 -32
  283. package/src/interfaces/permission-interfaces.tsx +0 -9
  284. package/src/interfaces/storage-interfaces.tsx +0 -5
  285. package/src/interfaces/system-interfaces.tsx +0 -22
  286. package/src/interfaces/theme-interfaces.tsx +0 -123
  287. package/src/interfaces/type-interfaces.tsx +0 -28
  288. package/src/interfaces/user-interfaces.tsx +0 -47
  289. package/src/nexus-client.tsx +0 -21
  290. package/src/nexus.environments.tsx +0 -66
  291. package/src/proxy-api/proxy-backend.tsx +0 -60
  292. package/src/proxy-api/proxy-constants.tsx +0 -28
  293. package/src/proxy-api/proxy-helpers.tsx +0 -91
  294. package/src/proxy-api-client.tsx +0 -1
  295. package/src/proxy-api-server.tsx +0 -2
  296. package/src/services/http-client-services.tsx +0 -75
  297. package/src/services/http-services.tsx +0 -158
  298. package/src/services/loader-service.tsx +0 -185
  299. package/src/services/localstorage-service.tsx +0 -119
  300. package/src/services/message-services.tsx +0 -383
  301. package/src/services/theme-service.tsx +0 -161
  302. package/src/services/user-services.tsx +0 -10
  303. package/src/sso-config/auth-token-validation.ts +0 -16
  304. package/src/sso-config/callback-route.tsx +0 -35
  305. package/src/sso-config/cookie-encryption.ts +0 -51
  306. package/src/sso-config/cookie-helpers.tsx +0 -102
  307. package/src/sso-config/forgot-password-route.tsx +0 -36
  308. package/src/sso-config/oauth-callback-state.ts +0 -61
  309. package/src/sso-config/pkce-helpers.ts +0 -9
  310. package/src/sso-config/provider-complete-route.tsx +0 -18
  311. package/src/sso-config/redirect-context-actions.tsx +0 -45
  312. package/src/sso-config/redirect-context-helpers.tsx +0 -159
  313. package/src/sso-config/redirect-context-persist.tsx +0 -18
  314. package/src/sso-config/redirect-context-route.tsx +0 -45
  315. package/src/sso-config/refresh-route.tsx +0 -23
  316. package/src/sso-config/sign-in-route.tsx +0 -74
  317. package/src/sso-config/sign-out-route.tsx +0 -57
  318. package/src/sso-config/sign-up-route.tsx +0 -57
  319. package/src/sso-config/sso-backend-flow.ts +0 -38
  320. package/src/sso-config/sso-complete-route.tsx +0 -50
  321. package/src/sso-config/sso-config.ts +0 -59
  322. package/src/sso-config/sso-interfaces.tsx +0 -99
  323. package/src/sso-config/sso-response-helpers.tsx +0 -20
  324. package/src/sso-server.tsx +0 -21
  325. package/src/styles/nexus.animation.css +0 -269
  326. package/src/styles/nexus.core.css +0 -119
  327. package/src/styles/nexus.dialog.css +0 -144
  328. package/src/styles/nexus.icon.css +0 -51
  329. package/src/styles/nexus.input.css +0 -207
  330. package/src/styles/nexus.loader.css +0 -11
  331. package/src/styles/nexus.logic.css +0 -47
  332. package/src/styles/nexus.utility.css +0 -347
@@ -0,0 +1,1170 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { iconAlignCenter, iconAlignJustified, iconAlignLeft, iconAlignRight, iconArrowBackUp, iconArrowForwardUp, iconBlockquote, iconBold, iconBraces, iconChevronDown, iconClearFormatting, iconCode, iconColumnInsertLeft, iconColumnInsertRight, iconColumnRemove, iconH1, iconH2, iconH3, iconItalic, iconLink, iconList, iconListNumbers, iconPencil, iconPilcrow, iconRowInsertBottom, iconRowInsertTop, iconRowRemove, iconSeparatorHorizontal, iconSourceCode, iconStrikethrough, iconSubscript, iconSuperscript, iconTable, iconTextColor, iconTextSize, iconTrash, iconUnderline, InlineIcon, } from "nexus-icons";
4
+ import { useEffect, useEffectEvent, useId, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { safeUrl } from "../../Helpers/MarkdownHelpers.js";
7
+ import { formatRichTextHtml, parseColor, richTextToEditorHtml, richTextTokenChip, richTextToPlainText, sanitizeRichText, validateRichText, } from "../../Helpers/RichTextHelpers.js";
8
+ import { Menu } from "../Documents/Menu.js";
9
+ import { TabButtons } from "../Documents/TabButtons.js";
10
+ import { EditorCount, useEditorShell, writeEditorCount } from "./EditorShell.js";
11
+ import { Field } from "./Field.js";
12
+ import { isFirstInvalid, useInputField } from "./InputField.js";
13
+ import { useReadOnlyNotice } from "./ReadOnlyNotice.js";
14
+ import { ColorPanel, LinkPanel, ToolPanel } from "./RichTextParts.js";
15
+ /** The default toolbar. `token` shows only when the editor has `tokens`. */
16
+ export const RICH_TEXT_TOOLBAR = [
17
+ "undo",
18
+ "redo",
19
+ "|",
20
+ "block",
21
+ "|",
22
+ "bold",
23
+ "italic",
24
+ "underline",
25
+ "strikethrough",
26
+ "color",
27
+ "|",
28
+ "link",
29
+ "bulletList",
30
+ "numberedList",
31
+ "align",
32
+ "|",
33
+ "table",
34
+ "rule",
35
+ "clear",
36
+ "|",
37
+ "token",
38
+ ];
39
+ const MODES = {
40
+ edit: { label: "Edit", icon: iconPencil },
41
+ html: { label: "HTML", icon: iconSourceCode },
42
+ };
43
+ const BLOCKS = {
44
+ p: { label: "Paragraph", icon: iconPilcrow, keys: "Ctrl+Alt+0" },
45
+ h1: { label: "Heading 1", icon: iconH1, keys: "Ctrl+Alt+1" },
46
+ h2: { label: "Heading 2", icon: iconH2, keys: "Ctrl+Alt+2" },
47
+ h3: { label: "Heading 3", icon: iconH3, keys: "Ctrl+Alt+3" },
48
+ blockquote: { label: "Quote", icon: iconBlockquote, keys: "Ctrl+Shift+." },
49
+ pre: { label: "Code block", icon: iconSourceCode },
50
+ };
51
+ const ALIGNS = {
52
+ left: { label: "Align left", icon: iconAlignLeft, command: "justifyLeft", keys: "Ctrl+Shift+L" },
53
+ center: { label: "Center", icon: iconAlignCenter, command: "justifyCenter", keys: "Ctrl+Shift+E" },
54
+ right: { label: "Align right", icon: iconAlignRight, command: "justifyRight", keys: "Ctrl+Shift+R" },
55
+ justify: { label: "Justify", icon: iconAlignJustified, command: "justifyFull", keys: "Ctrl+Shift+J" },
56
+ };
57
+ /** Plain buttons. `state` is the command whose state shows as pressed. */
58
+ const BUTTONS = {
59
+ undo: { label: "Undo", icon: iconArrowBackUp, keys: "Ctrl+Z" },
60
+ redo: { label: "Redo", icon: iconArrowForwardUp, keys: "Ctrl+Y" },
61
+ bold: { label: "Bold", icon: iconBold, keys: "Ctrl+B", state: "bold" },
62
+ italic: { label: "Italic", icon: iconItalic, keys: "Ctrl+I", state: "italic" },
63
+ underline: { label: "Underline", icon: iconUnderline, keys: "Ctrl+U", state: "underline" },
64
+ strikethrough: { label: "Strikethrough", icon: iconStrikethrough, keys: "Ctrl+Shift+X", state: "strikeThrough" },
65
+ code: { label: "Code", icon: iconCode, keys: "Ctrl+E", state: "code" },
66
+ subscript: { label: "Subscript", icon: iconSubscript, keys: "Ctrl+,", state: "subscript" },
67
+ superscript: { label: "Superscript", icon: iconSuperscript, keys: "Ctrl+.", state: "superscript" },
68
+ bulletList: { label: "Bulleted list", icon: iconList, keys: "Ctrl+Shift+8", state: "insertUnorderedList" },
69
+ numberedList: { label: "Numbered list", icon: iconListNumbers, keys: "Ctrl+Shift+7", state: "insertOrderedList" },
70
+ quote: { label: "Quote", icon: iconBlockquote, keys: "Ctrl+Shift+.", state: "quote" },
71
+ rule: { label: "Horizontal line", icon: iconSeparatorHorizontal },
72
+ clear: { label: "Clear formatting", icon: iconClearFormatting, keys: "Ctrl+\\" },
73
+ };
74
+ /** Commands of plain buttons. */
75
+ const COMMANDS = {
76
+ undo: "undo",
77
+ redo: "redo",
78
+ bold: "bold",
79
+ italic: "italic",
80
+ underline: "underline",
81
+ strikethrough: "strikeThrough",
82
+ subscript: "subscript",
83
+ superscript: "superscript",
84
+ bulletList: "insertUnorderedList",
85
+ numberedList: "insertOrderedList",
86
+ rule: "insertHorizontalRule",
87
+ clear: "removeFormat",
88
+ };
89
+ /** Browser formatting a key can start without the toolbar (Ctrl+B, Ctrl+I, Ctrl+U), and the tool that allows it. */
90
+ const NATIVE_FORMATS = {
91
+ formatBold: "bold",
92
+ formatItalic: "italic",
93
+ formatUnderline: "underline",
94
+ formatStrikeThrough: "strikethrough",
95
+ formatSubscript: "subscript",
96
+ formatSuperscript: "superscript",
97
+ };
98
+ const ALL_MODES = ["edit", "html"];
99
+ const EMPTY_HTML = "<p><br></p>";
100
+ /** Block tags in sanitized HTML. */
101
+ const BLOCK_MARKUP = /<(?:p|h[1-6]|ul|ol|li|table|blockquote|pre|hr)[\s>]/;
102
+ /** Around token chips, so the browser keeps them inside their paragraph. The value drops them. */
103
+ const ZERO_WIDTH = String.fromCharCode(0x200b);
104
+ /** Above this many characters of HTML, the value follows typing after a short pause instead of every frame. */
105
+ const LARGE_HTML = 60_000;
106
+ /**
107
+ * A rich text editor: formatted text edited in place, with a toolbar and shortcuts, text colors and highlights, links,
108
+ * lists, alignment, tables, template tokens as chips, an HTML view, a height handle, and full screen. The value is
109
+ * HTML, sanitized on every change (`sanitizeRichText`), so a page can store it and show it with `RichTextView`.
110
+ * Editing goes through the browser, so Ctrl+Z undoes typing and toolbar changes alike, and typing re-renders nothing.
111
+ * Pastes from Word, Google Docs, and web pages keep their formatting and lose everything else.
112
+ *
113
+ * @example <RichTextEditor name="description" label="Room description" heightKey="room-description" maxLength={5000} />
114
+ */
115
+ export function RichTextEditor(props) {
116
+ const { id, name, form, label, hint, placeholder, defaultValue, value, onValueChange, defaultMode = "edit", mode: modeProp, onModeChange, modes = ALL_MODES, toolbar = RICH_TEXT_TOOLBAR, tokens, tokenFormat = "{{key}}", height = 280, minHeight = 120, maxHeight = 960, resizable = true, heightKey, fullscreen: canFullscreen = true, showCount = true, spellCheck = true, autoFocus, readOnly, disabled, readOnlyMessage = "Read-only: this text cannot be changed.", validateOn = "blur", required, minLength, maxLength, validate, onFocus, onBlur, className, style, ref, } = props;
117
+ const [initialHtml] = useState(() => sanitizeRichText(value ?? defaultValue ?? ""));
118
+ const [modeState, setModeState] = useState(() => (modes.includes(defaultMode) ? defaultMode : (modes[0] ?? "edit")));
119
+ const mode = modeProp ?? modeState;
120
+ const [caret, setCaret] = useState({ block: "p", align: "left", inTable: false });
121
+ const [colors, setColors] = useState({ color: null, background: null });
122
+ const [link, setLink] = useState({ href: null, askText: false });
123
+ const contentRef = useRef(null);
124
+ const sourceRef = useRef(null);
125
+ const toolsRef = useRef(null);
126
+ const countRef = useRef(null);
127
+ const linkPanelRef = useRef(null);
128
+ const savedRangeRef = useRef(null);
129
+ const dirtyRef = useRef(false);
130
+ const syncRef = useRef({ frame: 0, timer: 0 });
131
+ const toolbarFrameRef = useRef(0);
132
+ const editedRef = useRef(false);
133
+ const sourceDirtyRef = useRef(false);
134
+ const draggingRef = useRef(false);
135
+ // Focus went into the editor's own toolbar, menus, or panels: the selection comes back when it returns.
136
+ const restoreRef = useRef(false);
137
+ const caretRef = useRef(caret);
138
+ const editable = !readOnly && !disabled;
139
+ const labelId = `${useId()}-label`;
140
+ const rules = { required, minLength, maxLength, validate };
141
+ const field = useInputField({ ...props, validateOn, onChange: undefined, onBlur: undefined, onInvalid: undefined, ref: undefined }, {
142
+ check: (element, fieldName) => validateRichText(element.value, rules, fieldName),
143
+ checkKeys: [value, required, minLength, maxLength, validate],
144
+ onReset: element => {
145
+ editedRef.current = false;
146
+ showValue(element.value);
147
+ },
148
+ });
149
+ // Tools that render: a token menu needs tokens.
150
+ const items = tidyToolbar(toolbar.filter(item => item !== "token" || Boolean(tokens?.length)));
151
+ const tools = new Set(items.filter((item) => item !== "|"));
152
+ const firstTool = items.findIndex(item => item !== "|");
153
+ const shell = useEditorShell({
154
+ height,
155
+ minHeight,
156
+ maxHeight,
157
+ resizable,
158
+ storageKey: heightKey && `nx-rte-height:${heightKey}`,
159
+ fullscreen: canFullscreen,
160
+ disabled,
161
+ focusTarget: box => box.querySelector(mode === "html" ? ".nx-rte__source" : ".nx-rte__content"),
162
+ });
163
+ const readOnlyNotice = useReadOnlyNotice(Boolean(readOnly) && !disabled, contentRef, readOnlyMessage, {
164
+ keys: ["Enter"],
165
+ anchor: target => target.closest(".nx-editor__frame")?.querySelector(".nx-editor__bar") ?? null,
166
+ align: "end",
167
+ });
168
+ const editorHtml = (html) => (html ? richTextToEditorHtml(html, { tokenFormat, tokens }) : EMPTY_HTML);
169
+ const [initialEditorHtml] = useState(() => editorHtml(initialHtml));
170
+ // ---- Value ----
171
+ /** The value after a change: counts, the form's value, validation, and `onValueChange`. */
172
+ function commit(html) {
173
+ const hidden = field.elementRef.current;
174
+ if (!hidden)
175
+ return;
176
+ if (showCount)
177
+ writeEditorCount(countRef.current, richTextToPlainText(html), maxLength);
178
+ if (hidden.value === html)
179
+ return;
180
+ hidden.value = html;
181
+ field.change();
182
+ onValueChange?.(html);
183
+ }
184
+ /** Reads the editor into the value, if it changed since the last time. */
185
+ function flush() {
186
+ const sync = syncRef.current;
187
+ cancelAnimationFrame(sync.frame);
188
+ window.clearTimeout(sync.timer);
189
+ if (!dirtyRef.current || !contentRef.current)
190
+ return;
191
+ dirtyRef.current = false;
192
+ commit(sanitizeRichText(contentRef.current.innerHTML));
193
+ }
194
+ /** Typing, a paste, or a command changed the editor: the value follows once per frame, or after a pause in long texts. */
195
+ function contentChanged() {
196
+ dirtyRef.current = true;
197
+ editedRef.current = true;
198
+ markEmpty();
199
+ const sync = syncRef.current;
200
+ cancelAnimationFrame(sync.frame);
201
+ window.clearTimeout(sync.timer);
202
+ if ((field.elementRef.current?.value.length ?? 0) > LARGE_HTML)
203
+ sync.timer = window.setTimeout(flush, 300);
204
+ else
205
+ sync.frame = requestAnimationFrame(flush);
206
+ }
207
+ /** Shows a value that did not come from typing: a reset, a controlled value, or `setValue`. */
208
+ function showValue(html) {
209
+ const content = contentRef.current;
210
+ if (content) {
211
+ content.innerHTML = editorHtml(html);
212
+ markEmpty();
213
+ }
214
+ if (sourceRef.current)
215
+ sourceRef.current.value = formatRichTextHtml(html);
216
+ dirtyRef.current = false;
217
+ if (showCount)
218
+ writeEditorCount(countRef.current, richTextToPlainText(html), maxLength);
219
+ }
220
+ /** The placeholder shows while the editor holds nothing but an empty paragraph. */
221
+ function markEmpty() {
222
+ const content = contentRef.current;
223
+ if (!content)
224
+ return;
225
+ const first = content.firstElementChild;
226
+ const empty = !content.textContent &&
227
+ content.children.length <= 1 &&
228
+ (!first || first.tagName === "P" || first.tagName === "BR") &&
229
+ !content.querySelector("hr, table, img, ul, ol, [data-token]");
230
+ content.toggleAttribute("data-empty", empty);
231
+ }
232
+ // The last change is never lost: an editor removed within a frame of typing (a popup that closes) keeps it.
233
+ const flushOnUnmount = useEffectEvent(() => flush());
234
+ useLayoutEffect(() => () => {
235
+ flushOnUnmount();
236
+ cancelAnimationFrame(toolbarFrameRef.current);
237
+ }, []);
238
+ // The toolbar follows the caret while the text has focus. One listener for the editor's life, so none is left behind.
239
+ const onSelectionChange = useEffectEvent(() => {
240
+ if (document.activeElement === contentRef.current)
241
+ queueToolbar();
242
+ });
243
+ useEffect(() => {
244
+ const listener = () => onSelectionChange();
245
+ document.addEventListener("selectionchange", listener);
246
+ return () => document.removeEventListener("selectionchange", listener);
247
+ }, []);
248
+ // A controlled value set by the page.
249
+ useEffect(() => {
250
+ const hidden = field.elementRef.current;
251
+ if (value === undefined || !hidden)
252
+ return;
253
+ const html = sanitizeRichText(value);
254
+ if (html === hidden.value)
255
+ return;
256
+ hidden.value = html;
257
+ showValue(html);
258
+ }, [value]);
259
+ // Browser formatting keys (Ctrl+B on any layout, Cmd+B) work only for the tools the toolbar lists.
260
+ useEffect(() => {
261
+ const content = contentRef.current;
262
+ if (!content)
263
+ return;
264
+ const onBeforeInput = (event) => {
265
+ const tool = NATIVE_FORMATS[event.inputType];
266
+ if (tool && !tools.has(tool))
267
+ event.preventDefault();
268
+ };
269
+ content.addEventListener("beforeinput", onBeforeInput);
270
+ return () => content.removeEventListener("beforeinput", onBeforeInput);
271
+ });
272
+ // The label names the editor; a click on it focuses the text, as a click on an input's label does.
273
+ useEffect(() => {
274
+ const labelElement = document.getElementById(labelId);
275
+ if (!labelElement)
276
+ return;
277
+ const onClick = () => focusEditor();
278
+ labelElement.addEventListener("click", onClick);
279
+ return () => labelElement.removeEventListener("click", onClick);
280
+ });
281
+ useEffect(() => {
282
+ if (autoFocus)
283
+ focusEditor();
284
+ }, []);
285
+ // ---- Views ----
286
+ function changeMode(next) {
287
+ if (next === mode)
288
+ return;
289
+ if (modeProp === undefined)
290
+ setModeState(next);
291
+ onModeChange?.(next);
292
+ }
293
+ useLayoutEffect(() => {
294
+ const hidden = field.elementRef.current;
295
+ if (!hidden)
296
+ return;
297
+ if (mode === "html") {
298
+ flush();
299
+ if (sourceRef.current)
300
+ sourceRef.current.value = formatRichTextHtml(hidden.value);
301
+ }
302
+ else if (sourceDirtyRef.current) {
303
+ sourceDirtyRef.current = false;
304
+ showValue(hidden.value);
305
+ }
306
+ }, [mode]);
307
+ function sourceChanged() {
308
+ const source = sourceRef.current;
309
+ if (!source)
310
+ return;
311
+ sourceDirtyRef.current = true;
312
+ editedRef.current = true;
313
+ commit(sanitizeRichText(source.value));
314
+ }
315
+ // ---- Selection ----
316
+ function focusEditor() {
317
+ if (disabled)
318
+ return;
319
+ if (mode === "html")
320
+ sourceRef.current?.focus();
321
+ else
322
+ contentRef.current?.focus();
323
+ }
324
+ /**
325
+ * Focuses the editor with the selection it had when focus went into its toolbar (a menu, a panel), or at the end of
326
+ * the text when it has none. While the user works in the text, its selection is the live one and stays.
327
+ */
328
+ function restoreSelection() {
329
+ const content = contentRef.current;
330
+ if (!content)
331
+ return;
332
+ if (document.activeElement !== content)
333
+ content.focus({ preventScroll: true });
334
+ if (restoreRef.current)
335
+ applySavedSelection();
336
+ if (!selectionRange(content)) {
337
+ const range = document.createRange();
338
+ range.selectNodeContents(content.lastElementChild ?? content);
339
+ range.collapse(false);
340
+ setSelection(range);
341
+ }
342
+ }
343
+ /** Puts back the selection saved when focus went into the toolbar. */
344
+ function applySavedSelection() {
345
+ restoreRef.current = false;
346
+ const content = contentRef.current;
347
+ const saved = savedRangeRef.current;
348
+ if (content && saved && content.contains(saved.commonAncestorContainer))
349
+ setSelection(saved);
350
+ }
351
+ function queueToolbar() {
352
+ cancelAnimationFrame(toolbarFrameRef.current);
353
+ toolbarFrameRef.current = requestAnimationFrame(updateToolbar);
354
+ }
355
+ /** The toolbar follows the caret: pressed buttons, the block type, the alignment, and whether it is in a table. */
356
+ function updateToolbar() {
357
+ const content = contentRef.current;
358
+ const range = content && selectionRange(content);
359
+ if (!content || !range)
360
+ return;
361
+ savedRangeRef.current = range.cloneRange();
362
+ for (const button of toolsRef.current?.querySelectorAll("[data-state]") ?? []) {
363
+ button.setAttribute("aria-pressed", String(commandState(content, button.dataset.state)));
364
+ }
365
+ const next = readCaret(content, range);
366
+ const current = caretRef.current;
367
+ if (next.block !== current.block || next.align !== current.align || next.inTable !== current.inTable) {
368
+ caretRef.current = next;
369
+ setCaret(next);
370
+ }
371
+ }
372
+ // ---- Editing ----
373
+ /** Runs a browser editing command on the selection, so Ctrl+Z undoes it. */
374
+ function exec(command, argument) {
375
+ if (!editable || !contentRef.current)
376
+ return false;
377
+ restoreSelection();
378
+ document.execCommand("defaultParagraphSeparator", false, "p");
379
+ const done = document.execCommand(command, false, argument);
380
+ queueToolbar();
381
+ return done;
382
+ }
383
+ function insertHtml(html) {
384
+ exec("insertHTML", html);
385
+ }
386
+ function runTool(tool) {
387
+ if (!editable)
388
+ return;
389
+ if (tool === "quote")
390
+ toggleQuote();
391
+ else if (tool === "code")
392
+ toggleCode();
393
+ else if (tool === "link")
394
+ linkPanelRef.current?.open();
395
+ else if (tool === "rule") {
396
+ restoreSelection();
397
+ moveAfterTextBlock();
398
+ exec("insertHorizontalRule");
399
+ }
400
+ else if (COMMANDS[tool])
401
+ exec(COMMANDS[tool]);
402
+ }
403
+ function setBlock(type) {
404
+ const content = contentRef.current;
405
+ if (!content)
406
+ return;
407
+ restoreSelection();
408
+ const current = readCaret(content, selectionRange(content)).block;
409
+ if (type === "blockquote" || (type === "p" && current === "blockquote")) {
410
+ toggleQuote();
411
+ return;
412
+ }
413
+ // Choosing the block it is in already turns it back into a paragraph.
414
+ const target = type !== "p" && current === type ? "p" : type;
415
+ exec("formatBlock", `<${target}>`);
416
+ }
417
+ /**
418
+ * Quotes the selected paragraphs, or takes them out of their quote. The browser's indent command wraps paragraphs
419
+ * in a quote and keeps each one a paragraph; in a list it would nest the list, so a list item is quoted as a block.
420
+ */
421
+ function toggleQuote() {
422
+ const content = contentRef.current;
423
+ if (!content)
424
+ return;
425
+ restoreSelection();
426
+ if (closestInSelection(content, "blockquote"))
427
+ exec("outdent");
428
+ else if (closestInSelection(content, "li"))
429
+ exec("formatBlock", "<blockquote>");
430
+ else
431
+ exec("indent");
432
+ }
433
+ /** A table or a line goes after the heading or the code block the caret is in, not inside it. */
434
+ function moveAfterTextBlock() {
435
+ const content = contentRef.current;
436
+ const block = content && closestInSelection(content, "pre, h1, h2, h3, h4, h5, h6");
437
+ if (!block)
438
+ return;
439
+ const range = document.createRange();
440
+ range.setStartAfter(block);
441
+ setSelection(range);
442
+ }
443
+ /**
444
+ * Enter on an empty last line of a quote or a code block leaves it, as Enter on an empty list item leaves a list.
445
+ * Returns whether it did.
446
+ */
447
+ function leaveBlock(content) {
448
+ const range = selectionRange(content);
449
+ if (!range?.collapsed)
450
+ return false;
451
+ // A quote of paragraphs: its empty last paragraph moves out.
452
+ const line = closestInSelection(content, "blockquote > p");
453
+ if (line) {
454
+ if (line !== line.parentElement.lastElementChild || !isBlank(line))
455
+ return false;
456
+ exec("outdent");
457
+ return true;
458
+ }
459
+ // A code block, or a quote of lines: the empty last line goes, and a paragraph starts after the block.
460
+ const block = closestInSelection(content, "pre, blockquote");
461
+ if (!block || elementOf(range.startContainer)?.closest("p, li, td, th, h1, h2, h3, h4, h5, h6, pre, blockquote") !== block)
462
+ return false;
463
+ const empty = emptyLastLine(block, range);
464
+ if (!empty)
465
+ return false;
466
+ if (empty === "all") {
467
+ if (block.tagName === "PRE")
468
+ exec("formatBlock", "<p>");
469
+ else
470
+ exec("outdent");
471
+ return true;
472
+ }
473
+ setSelection(empty);
474
+ document.execCommand("delete");
475
+ const after = document.createRange();
476
+ after.setStartAfter(block);
477
+ setSelection(after);
478
+ document.execCommand("insertHTML", false, '<p data-nx-caret=""><br></p>');
479
+ placeCaretAtMark();
480
+ return true;
481
+ }
482
+ function setAlign(align) {
483
+ exec(ALIGNS[align].command);
484
+ }
485
+ function toggleCode() {
486
+ const content = contentRef.current;
487
+ if (!content)
488
+ return;
489
+ restoreSelection();
490
+ const code = closestInSelection(content, "code");
491
+ if (code && !code.closest("pre")) {
492
+ selectNode(code);
493
+ exec("insertText", code.textContent ?? "");
494
+ }
495
+ else {
496
+ const text = document.getSelection()?.toString() || "code";
497
+ insertHtml(`<code>${escapeHtml(text)}</code>`);
498
+ }
499
+ }
500
+ function applyColor(kind, color) {
501
+ restoreSelection();
502
+ document.execCommand("styleWithCSS", false, "true");
503
+ // "currentcolor" and "transparent" take the color off: the value leaves them out.
504
+ exec(kind === "color" ? "foreColor" : "hiliteColor", color ?? (kind === "color" ? "currentcolor" : "transparent"));
505
+ document.execCommand("styleWithCSS", false, "false");
506
+ }
507
+ function readColors() {
508
+ const content = contentRef.current;
509
+ if (!content)
510
+ return;
511
+ const range = selectionRange(content) ?? savedRangeRef.current;
512
+ const start = range && elementOf(range.startContainer);
513
+ setColors({ color: styleColor(content, start, "color"), background: styleColor(content, start, "backgroundColor") });
514
+ }
515
+ function readLink() {
516
+ const content = contentRef.current;
517
+ if (!content)
518
+ return;
519
+ const range = selectionRange(content) ?? savedRangeRef.current;
520
+ const anchor = closestInSelection(content, "a[href]", range);
521
+ setLink({ href: anchor?.getAttribute("href") ?? null, askText: !anchor && Boolean(range?.collapsed ?? true) });
522
+ }
523
+ function applyLink(url, text) {
524
+ const content = contentRef.current;
525
+ if (!content)
526
+ return;
527
+ restoreSelection();
528
+ const anchor = closestInSelection(content, "a[href]");
529
+ const href = escapeAttribute(url);
530
+ if (anchor) {
531
+ selectNode(anchor);
532
+ insertHtml(`<a href="${href}">${anchor.innerHTML}</a>`);
533
+ }
534
+ else if (document.getSelection()?.isCollapsed ?? true) {
535
+ insertHtml(`<a href="${href}">${escapeHtml(text || url.replace(/^(mailto|tel):/, ""))}</a>`);
536
+ }
537
+ else
538
+ exec("createLink", url);
539
+ }
540
+ function removeLink() {
541
+ const content = contentRef.current;
542
+ if (!content)
543
+ return;
544
+ restoreSelection();
545
+ const anchor = closestInSelection(content, "a[href]");
546
+ if (anchor)
547
+ selectNode(anchor);
548
+ exec("unlink");
549
+ }
550
+ function insertToken(key) {
551
+ const content = contentRef.current;
552
+ if (!content || !editable)
553
+ return;
554
+ const token = tokens?.find(item => item.key === key) ?? null;
555
+ restoreSelection();
556
+ // Chrome moves a non-editable chip inserted at the start or the end of a block out of the block; between two
557
+ // zero-width spaces it stays inside. The value leaves them out.
558
+ document.execCommand("insertText", false, ZERO_WIDTH + ZERO_WIDTH);
559
+ const range = selectionRange(content);
560
+ if (range?.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
561
+ const between = document.createRange();
562
+ between.setStart(range.startContainer, range.startOffset - 1);
563
+ setSelection(between);
564
+ }
565
+ insertHtml(richTextTokenChip(key, tokenFormat, token, !tokens || Boolean(token)).replace("<span ", '<span data-nx-caret="" '));
566
+ // The caret goes after the chip, past its zero-width space.
567
+ const chip = content.querySelector("[data-nx-caret]");
568
+ if (!chip)
569
+ return;
570
+ chip.removeAttribute("data-nx-caret");
571
+ const after = document.createRange();
572
+ const next = chip.nextSibling;
573
+ if (next?.nodeType === Node.TEXT_NODE && next.textContent?.startsWith(ZERO_WIDTH))
574
+ after.setStart(next, 1);
575
+ else
576
+ after.setStartAfter(chip);
577
+ setSelection(after);
578
+ }
579
+ /** Inserts pasted or dropped content: HTML sanitized, plain text as paragraphs, a URL over selected text as a link. */
580
+ function insertContent(html, text) {
581
+ const selection = document.getSelection();
582
+ const url = text.trim();
583
+ if (!html && selection && !selection.isCollapsed && /^https?:\/\/\S+$/i.test(url) && safeUrl(url) && tools.has("link")) {
584
+ exec("createLink", url);
585
+ return;
586
+ }
587
+ const content = contentRef.current;
588
+ if (!content)
589
+ return;
590
+ restoreSelection();
591
+ const lines = (html ? richTextToPlainText(sanitizeRichText(html)) : text).replace(/\r\n?/g, "\n");
592
+ // A code block takes text.
593
+ if (closestInSelection(content, "pre")) {
594
+ if (lines)
595
+ exec("insertText", lines);
596
+ return;
597
+ }
598
+ if (!html && !lines.includes("\n") && !hasToken(lines, tokenFormat)) {
599
+ if (lines)
600
+ exec("insertText", lines);
601
+ return;
602
+ }
603
+ const clean = richTextToEditorHtml(html || plainTextToHtml(lines), { tokenFormat, tokens });
604
+ if (!clean)
605
+ return;
606
+ // One paragraph goes into the line at the caret; blocks start after a heading, as the browser would put them inside it.
607
+ const single = /^<p>([\s\S]*)<\/p>$/.exec(clean);
608
+ if (single && !BLOCK_MARKUP.test(single[1]))
609
+ insertHtml(single[1]);
610
+ else {
611
+ moveAfterTextBlock();
612
+ insertHtml(clean);
613
+ }
614
+ }
615
+ // ---- Tables ----
616
+ function insertTable() {
617
+ const head = `<thead><tr>${'<th data-nx-caret=""><br></th>'}${"<th><br></th>".repeat(2)}</tr></thead>`;
618
+ const body = `<tbody>${`<tr>${"<td><br></td>".repeat(3)}</tr>`.repeat(2)}</tbody>`;
619
+ restoreSelection();
620
+ moveAfterTextBlock();
621
+ insertHtml(`<table>${head}${body}</table><p><br></p>`);
622
+ placeCaretAtMark();
623
+ }
624
+ /**
625
+ * Changes a table's rows or columns. The table is rebuilt and put back in one step, so Ctrl+Z undoes the change
626
+ * as one edit.
627
+ */
628
+ function editTable(action) {
629
+ const content = contentRef.current;
630
+ if (!content || !editable)
631
+ return;
632
+ if (action === "insert") {
633
+ insertTable();
634
+ return;
635
+ }
636
+ restoreSelection();
637
+ const cell = closestInSelection(content, "td, th");
638
+ const table = cell?.closest("table");
639
+ if (!cell || !table)
640
+ return;
641
+ const row = cell.parentElement;
642
+ const rowIndex = [...table.rows].indexOf(row);
643
+ const column = cell.cellIndex;
644
+ const copy = table.cloneNode(true);
645
+ const rows = [...copy.rows];
646
+ const blankCell = (like) => {
647
+ const next = document.createElement(like?.tagName === "TH" ? "th" : "td");
648
+ next.innerHTML = "<br>";
649
+ return next;
650
+ };
651
+ let target = null;
652
+ switch (action) {
653
+ case "rowAbove":
654
+ case "rowBelow": {
655
+ const source = rows[rowIndex];
656
+ const inserted = document.createElement("tr");
657
+ for (const item of source.cells)
658
+ inserted.append(blankCell(source.parentElement?.tagName === "THEAD" && action === "rowBelow" ? undefined : item));
659
+ // A row below the header row starts the body.
660
+ if (action === "rowBelow" && source.parentElement?.tagName === "THEAD") {
661
+ const body = copy.tBodies[0] ?? copy.appendChild(document.createElement("tbody"));
662
+ body.prepend(inserted);
663
+ }
664
+ else
665
+ source.parentElement.insertBefore(inserted, action === "rowAbove" ? source : source.nextSibling);
666
+ target = inserted.cells[Math.min(column, inserted.cells.length - 1)] ?? null;
667
+ break;
668
+ }
669
+ case "columnLeft":
670
+ case "columnRight":
671
+ for (const item of rows) {
672
+ const reference = item.cells[column] ?? null;
673
+ const added = blankCell(item.cells[0]);
674
+ if (action === "columnLeft")
675
+ item.insertBefore(added, reference);
676
+ else
677
+ item.insertBefore(added, reference?.nextSibling ?? null);
678
+ if (item === rows[rowIndex])
679
+ target = added;
680
+ }
681
+ break;
682
+ case "deleteRow":
683
+ rows[rowIndex].remove();
684
+ target = copy.rows[Math.min(rowIndex, copy.rows.length - 1)]?.cells[column] ?? null;
685
+ break;
686
+ case "deleteColumn":
687
+ for (const item of rows) {
688
+ item.cells[column]?.remove();
689
+ if (!item.cells.length)
690
+ item.remove();
691
+ }
692
+ target = copy.rows[rowIndex]?.cells[Math.max(0, column - 1)] ?? null;
693
+ break;
694
+ case "deleteTable":
695
+ break;
696
+ }
697
+ for (const section of [...copy.querySelectorAll("thead, tbody, tfoot")])
698
+ if (!section.querySelector("tr"))
699
+ section.remove();
700
+ selectNode(table);
701
+ if (action === "deleteTable" || !copy.rows.length) {
702
+ insertHtml(EMPTY_HTML);
703
+ return;
704
+ }
705
+ target?.setAttribute("data-nx-caret", "");
706
+ insertHtml(copy.outerHTML);
707
+ placeCaretAtMark();
708
+ }
709
+ /** Moves the caret into the cell marked while a table was built, and removes the mark. */
710
+ function placeCaretAtMark() {
711
+ const mark = contentRef.current?.querySelector("[data-nx-caret]");
712
+ if (!mark)
713
+ return;
714
+ mark.removeAttribute("data-nx-caret");
715
+ placeCaret(mark);
716
+ queueToolbar();
717
+ }
718
+ /** Tab moves to the next cell, Shift+Tab to the one before; Tab in the last cell adds a row. */
719
+ function moveCell(cell, back) {
720
+ const table = cell.closest("table");
721
+ const cells = [...table.querySelectorAll("th, td")].filter(item => item.closest("table") === table);
722
+ const index = cells.indexOf(cell);
723
+ const next = cells[index + (back ? -1 : 1)];
724
+ if (next) {
725
+ // The next cell's text is selected, so typing replaces it.
726
+ placeCaret(next, true);
727
+ queueToolbar();
728
+ }
729
+ else if (!back) {
730
+ editTable("rowBelow");
731
+ const selection = document.getSelection();
732
+ const row = selection?.rangeCount ? elementOf(selection.getRangeAt(0).startContainer)?.closest("tr") : null;
733
+ if (row?.cells[0])
734
+ placeCaret(row.cells[0]);
735
+ }
736
+ }
737
+ // ---- Events ----
738
+ function handleKeyDown(event) {
739
+ readOnlyNotice.onKeyDown(event);
740
+ if (event.defaultPrevented || !editable || event.nativeEvent.isComposing)
741
+ return;
742
+ const content = event.currentTarget;
743
+ const plain = !event.ctrlKey && !event.metaKey && !event.altKey;
744
+ if (event.key === "Enter" && plain && !event.shiftKey && leaveBlock(content)) {
745
+ event.preventDefault();
746
+ return;
747
+ }
748
+ if (event.key === "Tab" && plain) {
749
+ const cell = closestInSelection(content, "td, th");
750
+ if (cell) {
751
+ event.preventDefault();
752
+ moveCell(cell, event.shiftKey);
753
+ return;
754
+ }
755
+ if (closestInSelection(content, "li") && (tools.has("bulletList") || tools.has("numberedList"))) {
756
+ event.preventDefault();
757
+ exec(event.shiftKey ? "outdent" : "indent");
758
+ }
759
+ return;
760
+ }
761
+ const action = shortcut(event);
762
+ if (!action)
763
+ return;
764
+ if (action.type === "tool") {
765
+ if (!tools.has(action.tool)) {
766
+ // Browser formatting that the toolbar does not offer stays off.
767
+ if (action.tool in NATIVE_FORMATS_BY_TOOL)
768
+ event.preventDefault();
769
+ return;
770
+ }
771
+ event.preventDefault();
772
+ runTool(action.tool);
773
+ }
774
+ else if (action.type === "block" && tools.has("block")) {
775
+ event.preventDefault();
776
+ setBlock(action.block);
777
+ }
778
+ else if (action.type === "align" && tools.has("align")) {
779
+ event.preventDefault();
780
+ setAlign(action.align);
781
+ }
782
+ }
783
+ function handlePaste(event) {
784
+ event.preventDefault();
785
+ if (!editable)
786
+ return;
787
+ insertContent(event.clipboardData.getData("text/html"), event.clipboardData.getData("text/plain"));
788
+ }
789
+ function handleDrop(event) {
790
+ // A move inside the editor stays the browser's own.
791
+ if (draggingRef.current && editable)
792
+ return;
793
+ event.preventDefault();
794
+ if (!editable)
795
+ return;
796
+ const html = event.dataTransfer.getData("text/html");
797
+ const text = event.dataTransfer.getData("text/plain");
798
+ if (!html && !text)
799
+ return;
800
+ const range = caretRangeAt(event.clientX, event.clientY);
801
+ if (range && event.currentTarget.contains(range.startContainer)) {
802
+ event.currentTarget.focus({ preventScroll: true });
803
+ const selection = document.getSelection();
804
+ selection?.removeAllRanges();
805
+ selection?.addRange(range);
806
+ savedRangeRef.current = range;
807
+ }
808
+ insertContent(html, text);
809
+ }
810
+ function handleFocus(event) {
811
+ // Back from a menu or a panel (Esc, or a choice): the selection it acted on comes back.
812
+ if (restoreRef.current)
813
+ applySavedSelection();
814
+ if (editable)
815
+ document.execCommand("defaultParagraphSeparator", false, "p");
816
+ queueToolbar();
817
+ onFocus?.(event);
818
+ }
819
+ function handleBlur(event) {
820
+ // The selection a menu or a panel acts on: the one at this moment, not the last one the toolbar saw.
821
+ const range = selectionRange(event.currentTarget);
822
+ if (range)
823
+ savedRangeRef.current = range.cloneRange();
824
+ flush();
825
+ readOnlyNotice.hide();
826
+ // Focus that moves into the editor's own toolbar or panels has not left the field.
827
+ const next = event.relatedTarget;
828
+ const inFrame = Boolean(next && event.currentTarget.closest(".nx-editor__frame")?.contains(next));
829
+ restoreRef.current = inFrame;
830
+ if (!inFrame && validateOn !== "submit" && (editedRef.current || field.elementRef.current?.value))
831
+ field.touch();
832
+ onBlur?.(event);
833
+ }
834
+ /** A press in the text places the caret there, whatever selection a menu left to come back to. */
835
+ function handleMouseDown() {
836
+ restoreRef.current = false;
837
+ }
838
+ function handleClick(event) {
839
+ if (document.getSelection()?.isCollapsed ?? true)
840
+ readOnlyNotice.onClick(event);
841
+ }
842
+ function handleInvalid(event) {
843
+ field.controlProps.onInvalid(event);
844
+ if (isFirstInvalid(event.currentTarget))
845
+ focusEditor();
846
+ }
847
+ useImperativeHandle(ref, () => ({
848
+ getValue: () => {
849
+ flush();
850
+ return field.elementRef.current?.value ?? "";
851
+ },
852
+ setValue: html => {
853
+ const hidden = field.elementRef.current;
854
+ if (!hidden)
855
+ return;
856
+ const clean = sanitizeRichText(html);
857
+ hidden.defaultValue = clean;
858
+ hidden.value = clean;
859
+ showValue(clean);
860
+ field.change();
861
+ },
862
+ insertText: text => exec("insertText", text),
863
+ insertHtml: html => insertHtml(richTextToEditorHtml(html, { tokenFormat, tokens })),
864
+ insertToken,
865
+ focus: focusEditor,
866
+ get element() {
867
+ return contentRef.current;
868
+ },
869
+ }));
870
+ // ---- Toolbar ----
871
+ const blockItems = Object.keys(BLOCKS).map(type => ({
872
+ value: type,
873
+ label: BLOCKS[type].label,
874
+ icon: _jsx(InlineIcon, { icon: BLOCKS[type].icon }),
875
+ shortcut: BLOCKS[type].keys,
876
+ checked: caret.block === type,
877
+ }));
878
+ const alignItems = Object.keys(ALIGNS).map(align => ({
879
+ value: align,
880
+ label: ALIGNS[align].label,
881
+ icon: _jsx(InlineIcon, { icon: ALIGNS[align].icon }),
882
+ shortcut: ALIGNS[align].keys,
883
+ checked: caret.align === align,
884
+ }));
885
+ const tableItems = [
886
+ { value: "insert", label: "Insert table", icon: _jsx(InlineIcon, { icon: iconTable }), disabled: caret.inTable },
887
+ { separator: true },
888
+ { value: "rowAbove", label: "Row above", icon: _jsx(InlineIcon, { icon: iconRowInsertTop }), disabled: !caret.inTable },
889
+ { value: "rowBelow", label: "Row below", icon: _jsx(InlineIcon, { icon: iconRowInsertBottom }), disabled: !caret.inTable },
890
+ { value: "columnLeft", label: "Column left", icon: _jsx(InlineIcon, { icon: iconColumnInsertLeft }), disabled: !caret.inTable },
891
+ { value: "columnRight", label: "Column right", icon: _jsx(InlineIcon, { icon: iconColumnInsertRight }), disabled: !caret.inTable },
892
+ { separator: true },
893
+ { value: "deleteRow", label: "Delete row", icon: _jsx(InlineIcon, { icon: iconRowRemove }), disabled: !caret.inTable },
894
+ { value: "deleteColumn", label: "Delete column", icon: _jsx(InlineIcon, { icon: iconColumnRemove }), disabled: !caret.inTable },
895
+ { value: "deleteTable", label: "Delete table", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger", disabled: !caret.inTable },
896
+ ];
897
+ const tokenItems = [];
898
+ let group;
899
+ for (const token of tokens ?? []) {
900
+ if (token.group && token.group !== group)
901
+ tokenItems.push({ heading: token.group });
902
+ group = token.group;
903
+ tokenItems.push({ value: token.key, label: token.label ?? token.key, description: token.description ?? tokenText(token.key, tokenFormat) });
904
+ }
905
+ /** Props of a toolbar button that opens a menu: it keeps the editor's focus and selection on press. */
906
+ const menuButton = (index, label, keys) => ({
907
+ type: "button",
908
+ className: "nx-editor__tool",
909
+ tabIndex: index === firstTool ? 0 : -1,
910
+ "aria-label": label,
911
+ title: keys ? `${label} (${keys})` : label,
912
+ onMouseDown: (event) => event.preventDefault(),
913
+ });
914
+ function renderTool(item, index) {
915
+ const tabIndex = index === firstTool ? 0 : -1;
916
+ switch (item) {
917
+ case "block": {
918
+ const current = BLOCKS[caret.block]?.label ?? "Heading";
919
+ return (_jsx(Menu, { items: blockItems, kind: "radio", "aria-label": "Text style", onSelect: setBlock, children: _jsxs("button", { ...menuButton(index, `Text style: ${current}`), className: "nx-editor__tool nx-rte__block", title: "Text style", children: [_jsx("span", { className: "nx-rte__block-label", children: current }), _jsx(InlineIcon, { icon: iconChevronDown })] }) }, index));
920
+ }
921
+ case "align":
922
+ return (_jsx(Menu, { items: alignItems, kind: "radio", "aria-label": "Alignment", onSelect: setAlign, children: _jsx("button", { ...menuButton(index, "Alignment"), children: _jsx(InlineIcon, { icon: ALIGNS[caret.align].icon }) }) }, index));
923
+ case "table":
924
+ return (_jsx(Menu, { items: tableItems, "aria-label": "Table", onSelect: editTable, children: _jsx("button", { ...menuButton(index, "Table"), "aria-pressed": caret.inTable, children: _jsx(InlineIcon, { icon: iconTable }) }) }, index));
925
+ case "token":
926
+ return (_jsx(Menu, { items: tokenItems, "aria-label": "Insert a field", onSelect: insertToken, children: _jsxs("button", { ...menuButton(index, "Insert a field"), className: "nx-editor__tool nx-rte__insert", children: [_jsx(InlineIcon, { icon: iconBraces }), _jsx("span", { className: "nx-rte__insert-label", children: "Field" })] }) }, index));
927
+ case "color":
928
+ return (_jsx(ToolPanel, { label: "Text color and highlight", icon: _jsx(InlineIcon, { icon: iconTextColor }), tabIndex: tabIndex, onOpen: readColors, children: close => (_jsx(ColorPanel, { color: colors.color, background: colors.background, onPick: (kind, color) => {
929
+ close();
930
+ applyColor(kind, color);
931
+ } })) }, index));
932
+ case "link":
933
+ return (_jsx(ToolPanel, { ref: linkPanelRef, label: "Link", shortcut: "Ctrl+K", icon: _jsx(InlineIcon, { icon: iconLink }), tabIndex: tabIndex, onOpen: readLink, "data-state": "link", children: close => (_jsx(LinkPanel, { href: link.href, askText: link.askText, onApply: (url, text) => {
934
+ close();
935
+ applyLink(url, text);
936
+ }, onRemove: () => {
937
+ close();
938
+ removeLink();
939
+ }, onCancel: () => {
940
+ close();
941
+ restoreSelection();
942
+ } })) }, index));
943
+ default: {
944
+ const button = BUTTONS[item];
945
+ if (!button)
946
+ return null;
947
+ return (_jsx("button", { type: "button", className: "nx-editor__tool", tabIndex: tabIndex, "aria-label": button.label, "aria-keyshortcuts": button.keys?.replace("Ctrl", "Control"), "aria-pressed": button.state ? false : undefined, "data-state": button.state, title: button.keys ? `${button.label} (${button.keys})` : button.label,
948
+ // The editor keeps focus and its selection.
949
+ onMouseDown: event => event.preventDefault(), onClick: () => runTool(item), children: _jsx(InlineIcon, { icon: button.icon }) }, index));
950
+ }
951
+ }
952
+ }
953
+ const describedBy = field.controlProps["aria-describedby"];
954
+ return (_jsxs(_Fragment, { children: [_jsxs(Field, { controlId: field.controlId, labelId: labelId, messageId: field.messageId, label: label, required: required, hint: hint, error: field.error, disabled: disabled, readOnly: readOnly, multiline: true, className: cx("nx-editor", "nx-rte", className), style: style, children: [_jsxs("div", { ref: shell.frameRef, className: "nx-editor__frame", "data-fullscreen": shell.fullscreen || undefined, onKeyDown: shell.onFrameKeyDown, children: [_jsxs("div", { className: "nx-editor__bar", children: [modes.length > 1 && (_jsx(TabButtons, { "aria-label": "View", size: "sm", labels: "selected", options: modes.map(option => ({ value: option, label: MODES[option].label, icon: _jsx(InlineIcon, { icon: MODES[option].icon }), disabled })), value: mode, onChange: changeMode,
955
+ // An id that matches no form: the view is never sent with the form around the editor.
956
+ form: `${field.controlId}-view`, className: "nx-editor__modes" })), editable && mode === "edit" && firstTool >= 0 && (_jsx("div", { ref: toolsRef, role: "toolbar", "aria-label": "Formatting", "aria-controls": field.controlId, className: "nx-editor__tools", onKeyDown: shell.moveTool, children: items.map((item, index) => (item === "|" ? _jsx("span", { className: "nx-editor__divider", "aria-hidden": "true" }, index) : renderTool(item, index))) })), _jsxs("span", { className: "nx-editor__end", children: [readOnlyNotice.lock, shell.fullscreenButton] })] }), _jsxs("div", { ref: shell.bodyRef, className: "nx-editor__body", "data-mode": mode, style: shell.bodyStyle, children: [_jsx("div", { ref: contentRef, id: field.controlId, className: "nx-rte__content nx-richtext", role: "textbox", "aria-multiline": "true", "aria-labelledby": label !== undefined && label !== null && label !== "" ? labelId : undefined, "aria-label": label ? undefined : props["aria-label"], "aria-describedby": describedBy, "aria-invalid": field.controlProps["aria-invalid"], "aria-required": required || undefined, "aria-readonly": readOnly || undefined, "aria-disabled": disabled || undefined, "aria-placeholder": placeholder, "data-placeholder": placeholder, "data-empty": initialHtml ? undefined : "", contentEditable: editable, suppressContentEditableWarning: true, spellCheck: spellCheck, tabIndex: disabled ? -1 : 0, dangerouslySetInnerHTML: { __html: initialEditorHtml }, onInput: contentChanged, onKeyDown: handleKeyDown, onKeyUp: queueToolbar, onPaste: handlePaste, onDrop: handleDrop, onDragStart: () => {
957
+ draggingRef.current = true;
958
+ }, onDragEnd: () => {
959
+ draggingRef.current = false;
960
+ }, onFocus: handleFocus, onBlur: handleBlur, onMouseDown: handleMouseDown, onClick: handleClick }), mode === "html" && (_jsx("textarea", { ref: sourceRef, className: "nx-field__control nx-rte__source", "aria-label": "HTML source", "aria-describedby": describedBy, spellCheck: false, readOnly: !editable, disabled: disabled, onInput: sourceChanged, onKeyDown: readOnlyNotice.onKeyDown, onBlur: () => {
961
+ if (validateOn !== "submit" && editedRef.current)
962
+ field.touch();
963
+ } }))] }), shell.footer(_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconTextSize }), _jsx("span", { children: "Rich text" })] }), showCount && _jsx(EditorCount, { ref: countRef, text: richTextToPlainText(initialHtml), maxLength: maxLength }))] }), _jsx("textarea", { ref: field.controlProps.ref, name: name, form: form, defaultValue: initialHtml, disabled: disabled, readOnly: readOnly, className: "nx-rte__value", tabIndex: -1, "aria-hidden": "true", onInvalid: handleInvalid })] }), readOnlyNotice.notice] }));
964
+ }
965
+ // ---------------------------------------------------------------------------------------------------------------
966
+ // Helpers
967
+ const NATIVE_FORMATS_BY_TOOL = Object.fromEntries(Object.values(NATIVE_FORMATS).map(tool => [tool, true]));
968
+ /** The action of a shortcut, by key position for digits and punctuation so it works on any keyboard layout. */
969
+ function shortcut(event) {
970
+ if (!(event.ctrlKey || event.metaKey) || event.getModifierState("AltGraph"))
971
+ return null;
972
+ const tool = (name) => ({ type: "tool", tool: name });
973
+ if (event.altKey) {
974
+ if (event.shiftKey)
975
+ return null;
976
+ const blocks = { Digit0: "p", Digit1: "h1", Digit2: "h2", Digit3: "h3" };
977
+ const block = blocks[event.code];
978
+ return block ? { type: "block", block } : null;
979
+ }
980
+ if (event.shiftKey) {
981
+ switch (event.code) {
982
+ case "KeyX":
983
+ return tool("strikethrough");
984
+ case "Digit7":
985
+ return tool("numberedList");
986
+ case "Digit8":
987
+ return tool("bulletList");
988
+ case "Period":
989
+ return tool("quote");
990
+ case "KeyL":
991
+ return { type: "align", align: "left" };
992
+ case "KeyE":
993
+ return { type: "align", align: "center" };
994
+ case "KeyR":
995
+ return { type: "align", align: "right" };
996
+ case "KeyJ":
997
+ return { type: "align", align: "justify" };
998
+ default:
999
+ return null;
1000
+ }
1001
+ }
1002
+ switch (event.key.toLowerCase()) {
1003
+ case "b":
1004
+ return tool("bold");
1005
+ case "i":
1006
+ return tool("italic");
1007
+ case "u":
1008
+ return tool("underline");
1009
+ case "k":
1010
+ return tool("link");
1011
+ case "e":
1012
+ return tool("code");
1013
+ case "\\":
1014
+ return tool("clear");
1015
+ }
1016
+ if (event.code === "Comma")
1017
+ return tool("subscript");
1018
+ if (event.code === "Period")
1019
+ return tool("superscript");
1020
+ return null;
1021
+ }
1022
+ /** Drops dividers at the ends and next to each other. */
1023
+ function tidyToolbar(items) {
1024
+ const out = [];
1025
+ for (const item of items) {
1026
+ if (item === "|" && (!out.length || out[out.length - 1] === "|"))
1027
+ continue;
1028
+ out.push(item);
1029
+ }
1030
+ while (out[out.length - 1] === "|")
1031
+ out.pop();
1032
+ return out;
1033
+ }
1034
+ function elementOf(node) {
1035
+ if (!node)
1036
+ return null;
1037
+ return node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
1038
+ }
1039
+ /** The selection's range, when it is inside `content`. */
1040
+ function selectionRange(content) {
1041
+ const selection = document.getSelection();
1042
+ if (!selection?.rangeCount)
1043
+ return null;
1044
+ const range = selection.getRangeAt(0);
1045
+ return content.contains(range.commonAncestorContainer) ? range : null;
1046
+ }
1047
+ /** The nearest element matching `selector` around the start of the selection, inside `content`. */
1048
+ function closestInSelection(content, selector, range = selectionRange(content)) {
1049
+ const found = elementOf(range?.startContainer)?.closest(selector);
1050
+ return found && found !== content && content.contains(found) ? found : null;
1051
+ }
1052
+ /** Puts the caret at the start of an element, or selects its content. */
1053
+ function placeCaret(element, select = false) {
1054
+ const range = document.createRange();
1055
+ range.selectNodeContents(element);
1056
+ if (!select)
1057
+ range.collapse(true);
1058
+ setSelection(range);
1059
+ }
1060
+ function selectNode(node) {
1061
+ const range = document.createRange();
1062
+ range.selectNode(node);
1063
+ setSelection(range);
1064
+ }
1065
+ function setSelection(range) {
1066
+ const selection = document.getSelection();
1067
+ selection?.removeAllRanges();
1068
+ selection?.addRange(range);
1069
+ }
1070
+ /** A line with nothing a reader sees: no text, token, image, or table. */
1071
+ function isBlank(element) {
1072
+ return !element.textContent?.replaceAll(ZERO_WIDTH, "").trim() && !element.querySelector("img, table, hr, [data-token]");
1073
+ }
1074
+ /**
1075
+ * In a block whose lines are separated by line breaks (a code block, a quote without paragraphs), the range from the
1076
+ * break before the caret to the block's end, when the caret is on an empty last line. "all" when the block has no
1077
+ * text at all, `null` otherwise.
1078
+ */
1079
+ function emptyLastLine(block, caret) {
1080
+ const after = document.createRange();
1081
+ after.setStart(caret.startContainer, caret.startOffset);
1082
+ after.setEnd(block, block.childNodes.length);
1083
+ const rest = after.cloneContents();
1084
+ // Only the browser's placeholder break may follow the caret, maybe inside a format.
1085
+ if (rest.textContent?.replaceAll(ZERO_WIDTH, "") || rest.querySelector("img, table, hr, [data-token]") || rest.querySelectorAll("br").length > 1)
1086
+ return null;
1087
+ if (!block.textContent?.replaceAll(ZERO_WIDTH, "").trim())
1088
+ return "all";
1089
+ const before = document.createRange();
1090
+ before.setStart(block, 0);
1091
+ before.setEnd(caret.startContainer, caret.startOffset);
1092
+ let last = before.cloneContents().lastChild;
1093
+ while (last?.lastChild)
1094
+ last = last.lastChild;
1095
+ if (last?.nodeName !== "BR")
1096
+ return null;
1097
+ const breaks = [...block.querySelectorAll("br")].filter(br => caret.comparePoint(br, 0) < 0);
1098
+ const lastBreak = breaks[breaks.length - 1];
1099
+ if (!lastBreak)
1100
+ return null;
1101
+ const line = document.createRange();
1102
+ line.setStartBefore(lastBreak);
1103
+ line.setEnd(block, block.childNodes.length);
1104
+ return line;
1105
+ }
1106
+ function readCaret(content, range) {
1107
+ const start = elementOf(range?.startContainer);
1108
+ const inside = (element) => (element && element !== content && content.contains(element) ? element : null);
1109
+ const heading = inside(start?.closest("h1, h2, h3, h4, h5, h6, pre"));
1110
+ const block = heading ? heading.tagName.toLowerCase() : inside(start?.closest("blockquote")) ? "blockquote" : "p";
1111
+ const aligned = inside(start?.closest("p, h1, h2, h3, h4, h5, h6, li, td, th, blockquote, pre"));
1112
+ const textAlign = aligned ? getComputedStyle(aligned).textAlign : "left";
1113
+ const align = textAlign === "center" || textAlign === "right" || textAlign === "justify" ? textAlign : "left";
1114
+ return { block, align, inTable: Boolean(inside(start?.closest("td, th"))) };
1115
+ }
1116
+ /** Whether a toolbar button's command is on at the selection. */
1117
+ function commandState(content, state) {
1118
+ if (state === "link")
1119
+ return Boolean(closestInSelection(content, "a[href]"));
1120
+ if (state === "code")
1121
+ return Boolean(closestInSelection(content, "code"));
1122
+ if (state === "quote")
1123
+ return Boolean(closestInSelection(content, "blockquote"));
1124
+ try {
1125
+ return document.queryCommandState(state);
1126
+ }
1127
+ catch {
1128
+ return false;
1129
+ }
1130
+ }
1131
+ /** The color set on the nearest element around `start` that has one, as `#rrggbb`. */
1132
+ function styleColor(content, start, property) {
1133
+ for (let element = start; element && element !== content; element = element.parentElement) {
1134
+ const color = element.style?.[property];
1135
+ if (color)
1136
+ return parseColor(color);
1137
+ }
1138
+ return null;
1139
+ }
1140
+ function caretRangeAt(x, y) {
1141
+ const doc = document;
1142
+ if (doc.caretPositionFromPoint) {
1143
+ const position = doc.caretPositionFromPoint(x, y);
1144
+ if (!position)
1145
+ return null;
1146
+ const range = document.createRange();
1147
+ range.setStart(position.offsetNode, position.offset);
1148
+ return range;
1149
+ }
1150
+ return doc.caretRangeFromPoint?.(x, y) ?? null;
1151
+ }
1152
+ function hasToken(text, format) {
1153
+ return format === "|key|" ? /\|[A-Za-z_][A-Za-z0-9_.]*\|/.test(text) : /\{\{\s*[A-Za-z_][A-Za-z0-9_.]*\s*\}\}/.test(text);
1154
+ }
1155
+ function tokenText(key, format) {
1156
+ return format === "|key|" ? `|${key}|` : `{{${key}}}`;
1157
+ }
1158
+ /** Plain text as paragraphs: a blank line starts a paragraph, a single line break stays a line break. */
1159
+ function plainTextToHtml(text) {
1160
+ return text
1161
+ .split(/\n{2,}/)
1162
+ .map(paragraph => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
1163
+ .join("");
1164
+ }
1165
+ function escapeHtml(text) {
1166
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1167
+ }
1168
+ function escapeAttribute(text) {
1169
+ return escapeHtml(text).replace(/"/g, "&quot;");
1170
+ }