toolbar.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. import type { Editor } from '@tiptap/vue-3';
  2. import type { ToolbarAction, ToolbarMenuItem } from './types';
  3. import {
  4. AlignCenter,
  5. AlignLeft,
  6. AlignRight,
  7. Bold,
  8. Highlighter,
  9. ImagePlus,
  10. Italic,
  11. Link2,
  12. List,
  13. ListOrdered,
  14. MessageSquareCode,
  15. Paintbrush,
  16. Redo2,
  17. RemoveFormatting,
  18. SquareCode,
  19. Strikethrough,
  20. TextQuote,
  21. Underline,
  22. Undo2,
  23. Unlink2,
  24. } from '@vben/icons';
  25. import { $t } from '@vben/locales';
  26. import { COLOR_PRESETS } from '@vben/preferences';
  27. import { prompt } from '@vben-core/popup-ui';
  28. const headingLevels = [1, 2, 3, 4] as const;
  29. const editorColorPresets = [
  30. 'hsl(var(--foreground))',
  31. 'hsl(var(--warning))',
  32. 'hsl(var(--success))',
  33. 'hsl(var(--destructive))',
  34. ...COLOR_PRESETS.map((item) => item.color),
  35. ];
  36. const editorHighlightPresets = [
  37. withAlpha('hsl(var(--warning))', 0.45),
  38. withAlpha('hsl(var(--success))', 0.35),
  39. withAlpha('hsl(var(--primary))', 0.3),
  40. withAlpha('hsl(var(--destructive))', 0.3),
  41. ...COLOR_PRESETS.map((item) => withAlpha(item.color, 0.4)),
  42. ];
  43. function createHeadingMenuItems(): ToolbarMenuItem[] {
  44. return [
  45. {
  46. action: (editor) => editor.chain().focus().setParagraph().run(),
  47. can: (editor) => editor.can().chain().focus().setParagraph().run(),
  48. isActive: (editor) => editor.isActive('paragraph'),
  49. label: $t('ui.tiptap.toolbar.paragraph'),
  50. shortLabel: 'P',
  51. },
  52. ...headingLevels.map((level) => ({
  53. action: (editor: Editor) =>
  54. editor.chain().focus().toggleHeading({ level }).run(),
  55. can: (editor: Editor) =>
  56. editor.can().chain().focus().toggleHeading({ level }).run(),
  57. isActive: (editor: Editor) => editor.isActive('heading', { level }),
  58. label: $t(`ui.tiptap.toolbar.heading${level}`),
  59. shortLabel: `H${level}`,
  60. })),
  61. ];
  62. }
  63. function getHeadingTriggerText(editor?: Editor) {
  64. if (editor?.isActive('paragraph')) {
  65. return 'P';
  66. }
  67. const level = headingLevels.find((headingLevel) =>
  68. editor?.isActive('heading', { level: headingLevel }),
  69. );
  70. return level ? `H${level}` : 'H';
  71. }
  72. function normalizeLinkUrl(url: string) {
  73. if (/^(https?:|mailto:|tel:)/i.test(url)) {
  74. return url;
  75. }
  76. return `https://${url}`;
  77. }
  78. function withAlpha(color: string, alpha: number) {
  79. const normalizedAlpha = Math.min(Math.max(alpha, 0), 1);
  80. const hslMatch = color.match(/^hsl\((.+)\)$/);
  81. if (!hslMatch) {
  82. return color;
  83. }
  84. return `hsl(${hslMatch[1]} / ${normalizedAlpha})`;
  85. }
  86. async function handleLinkAction(editor: Editor) {
  87. const currentHref = editor.getAttributes('link').href as string | undefined;
  88. let url: string | undefined;
  89. try {
  90. url = await prompt<string>({
  91. componentProps: {
  92. placeholder: 'https://example.com',
  93. },
  94. content: $t('ui.tiptap.prompts.link'),
  95. defaultValue: currentHref ?? '',
  96. });
  97. } catch {
  98. return;
  99. }
  100. const nextUrl = (url ?? '').trim();
  101. if (!nextUrl) {
  102. editor.chain().focus().extendMarkRange('link').unsetLink().run();
  103. return;
  104. }
  105. editor
  106. .chain()
  107. .focus()
  108. .extendMarkRange('link')
  109. .setLink({
  110. href: normalizeLinkUrl(nextUrl),
  111. })
  112. .run();
  113. }
  114. async function handleImageAction(editor: Editor) {
  115. let url: string | undefined;
  116. try {
  117. url = await prompt<string>({
  118. componentProps: {
  119. placeholder: 'https://example.com/image.png',
  120. },
  121. content: $t('ui.tiptap.prompts.image'),
  122. defaultValue: '',
  123. });
  124. } catch {
  125. return;
  126. }
  127. const nextUrl = (url ?? '').trim();
  128. if (!nextUrl) {
  129. return;
  130. }
  131. editor.chain().focus().setImage({ src: nextUrl }).run();
  132. }
  133. export function createToolbarGroups(): ToolbarAction[][] {
  134. const headingMenuItems = createHeadingMenuItems();
  135. return [
  136. [
  137. {
  138. action: (editor) => editor.chain().focus().undo().run(),
  139. can: (editor) => editor.can().chain().focus().undo().run(),
  140. icon: Undo2,
  141. label: $t('ui.tiptap.toolbar.undo'),
  142. },
  143. {
  144. action: (editor) => editor.chain().focus().redo().run(),
  145. can: (editor) => editor.can().chain().focus().redo().run(),
  146. icon: Redo2,
  147. label: $t('ui.tiptap.toolbar.redo'),
  148. },
  149. {
  150. action: (editor) =>
  151. editor.chain().focus().clearNodes().unsetAllMarks().run(),
  152. icon: RemoveFormatting,
  153. label: $t('ui.tiptap.toolbar.clear'),
  154. },
  155. ],
  156. [
  157. {
  158. action: (editor) => editor.chain().focus().toggleBold().run(),
  159. active: { name: 'bold' },
  160. can: (editor) => editor.can().chain().focus().toggleBold().run(),
  161. icon: Bold,
  162. label: $t('ui.tiptap.toolbar.bold'),
  163. },
  164. {
  165. action: (editor) => editor.chain().focus().toggleItalic().run(),
  166. active: { name: 'italic' },
  167. can: (editor) => editor.can().chain().focus().toggleItalic().run(),
  168. icon: Italic,
  169. label: $t('ui.tiptap.toolbar.italic'),
  170. },
  171. {
  172. action: (editor) => editor.chain().focus().toggleUnderline().run(),
  173. active: { name: 'underline' },
  174. can: (editor) => editor.can().chain().focus().toggleUnderline().run(),
  175. icon: Underline,
  176. label: $t('ui.tiptap.toolbar.underline'),
  177. },
  178. {
  179. action: (editor) => editor.chain().focus().toggleStrike().run(),
  180. active: { name: 'strike' },
  181. can: (editor) => editor.can().chain().focus().toggleStrike().run(),
  182. icon: Strikethrough,
  183. label: $t('ui.tiptap.toolbar.strike'),
  184. },
  185. {
  186. action: (editor) => editor.chain().focus().toggleCode().run(),
  187. active: { name: 'code' },
  188. can: (editor) => editor.can().chain().focus().toggleCode().run(),
  189. icon: SquareCode,
  190. label: $t('ui.tiptap.toolbar.code'),
  191. },
  192. ],
  193. [
  194. {
  195. action: () => {},
  196. can: (editor) =>
  197. headingMenuItems.some((item) => (item.can ? item.can(editor) : true)),
  198. isActive: (editor) =>
  199. headingMenuItems.some((item) => item.isActive?.(editor)),
  200. label: $t('ui.tiptap.toolbar.heading'),
  201. menu: {
  202. items: headingMenuItems,
  203. },
  204. triggerText: (editor) => getHeadingTriggerText(editor),
  205. },
  206. {
  207. action: (editor) => editor.chain().focus().toggleBulletList().run(),
  208. active: { name: 'bulletList' },
  209. can: (editor) => editor.can().chain().focus().toggleBulletList().run(),
  210. icon: List,
  211. label: $t('ui.tiptap.toolbar.bulletList'),
  212. },
  213. {
  214. action: (editor) => editor.chain().focus().toggleOrderedList().run(),
  215. active: { name: 'orderedList' },
  216. can: (editor) => editor.can().chain().focus().toggleOrderedList().run(),
  217. icon: ListOrdered,
  218. label: $t('ui.tiptap.toolbar.orderedList'),
  219. },
  220. {
  221. action: (editor) => editor.chain().focus().toggleBlockquote().run(),
  222. active: { name: 'blockquote' },
  223. can: (editor) => editor.can().chain().focus().toggleBlockquote().run(),
  224. icon: TextQuote,
  225. label: $t('ui.tiptap.toolbar.blockquote'),
  226. },
  227. {
  228. action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
  229. active: { name: 'codeBlock' },
  230. can: (editor) => editor.can().chain().focus().toggleCodeBlock().run(),
  231. icon: MessageSquareCode,
  232. label: $t('ui.tiptap.toolbar.codeBlock'),
  233. },
  234. ],
  235. [
  236. {
  237. action: (editor) => handleLinkAction(editor),
  238. active: { name: 'link' },
  239. can: (editor) =>
  240. editor.can().chain().focus().extendMarkRange('link').run(),
  241. icon: Link2,
  242. label: $t('ui.tiptap.toolbar.link'),
  243. },
  244. {
  245. action: (editor) => editor.chain().focus().unsetLink().run(),
  246. can: (editor) => editor.can().chain().focus().unsetLink().run(),
  247. icon: Unlink2,
  248. isActive: (editor) => editor.isActive('link'),
  249. label: $t('ui.tiptap.toolbar.unlink'),
  250. },
  251. {
  252. action: (editor) => handleImageAction(editor),
  253. icon: ImagePlus,
  254. label: $t('ui.tiptap.toolbar.image'),
  255. },
  256. ],
  257. [
  258. {
  259. action: () => {},
  260. icon: Paintbrush,
  261. indicatorColor: (editor) =>
  262. editor.getAttributes('textStyle').color as string | undefined,
  263. isActive: (editor) => Boolean(editor.getAttributes('textStyle').color),
  264. label: $t('ui.tiptap.toolbar.textColor'),
  265. palette: {
  266. apply: (editor, color) =>
  267. editor.chain().focus().setColor(color).run(),
  268. clear: (editor) => editor.chain().focus().unsetColor().run(),
  269. colors: editorColorPresets,
  270. currentColor: (editor) =>
  271. editor.getAttributes('textStyle').color as string | undefined,
  272. },
  273. },
  274. {
  275. action: () => {},
  276. icon: Highlighter,
  277. indicatorColor: (editor) =>
  278. (editor.getAttributes('highlight').color as string | undefined) ??
  279. '#fef08a',
  280. isActive: (editor) => editor.isActive('highlight'),
  281. label: $t('ui.tiptap.toolbar.highlightColor'),
  282. palette: {
  283. apply: (editor, color) =>
  284. editor.chain().focus().setHighlight({ color }).run(),
  285. clear: (editor) => editor.chain().focus().unsetHighlight().run(),
  286. colors: editorHighlightPresets,
  287. currentColor: (editor) =>
  288. editor.getAttributes('highlight').color as string | undefined,
  289. },
  290. },
  291. ],
  292. [
  293. {
  294. action: (editor) => editor.chain().focus().setTextAlign('left').run(),
  295. can: (editor) =>
  296. editor.can().chain().focus().setTextAlign('left').run(),
  297. icon: AlignLeft,
  298. isActive: (editor) => editor.isActive({ textAlign: 'left' }),
  299. label: $t('ui.tiptap.toolbar.alignLeft'),
  300. },
  301. {
  302. action: (editor) => editor.chain().focus().setTextAlign('center').run(),
  303. can: (editor) =>
  304. editor.can().chain().focus().setTextAlign('center').run(),
  305. icon: AlignCenter,
  306. isActive: (editor) => editor.isActive({ textAlign: 'center' }),
  307. label: $t('ui.tiptap.toolbar.alignCenter'),
  308. },
  309. {
  310. action: (editor) => editor.chain().focus().setTextAlign('right').run(),
  311. can: (editor) =>
  312. editor.can().chain().focus().setTextAlign('right').run(),
  313. icon: AlignRight,
  314. isActive: (editor) => editor.isActive({ textAlign: 'right' }),
  315. label: $t('ui.tiptap.toolbar.alignRight'),
  316. },
  317. ],
  318. ];
  319. }