tiptapify 0.3.4 → 0.4.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 (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110820 -65669
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +34 -0
  6. package/package.json +3 -2
  7. package/src/components/ImageBubbleMenu.vue +5 -33
  8. package/src/components/MenuBubble.test.ts +120 -0
  9. package/src/components/MenuBubble.vue +124 -134
  10. package/src/components/MenuFloating.vue +48 -45
  11. package/src/components/Tiptapify.upload.test.ts +10 -0
  12. package/src/components/Tiptapify.vue +85 -45
  13. package/src/components/Toolbar/Index.vue +10 -0
  14. package/src/components/UI/BubbleRow.vue +41 -0
  15. package/src/components/UI/TiptapifyDialog.test.ts +37 -3
  16. package/src/components/UI/TiptapifyDialog.vue +15 -4
  17. package/src/components/editorExtensions.test.ts +9 -0
  18. package/src/components/editorExtensions.ts +21 -1
  19. package/src/composables/fakeUploadEndpoint.ts +65 -0
  20. package/src/composables/useTiptapifyConfig.ts +19 -0
  21. package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
  22. package/src/composables/useTiptapifyLinkPreview.ts +60 -0
  23. package/src/composables/useTiptapifyUpload.test.ts +96 -0
  24. package/src/composables/useTiptapifyUpload.ts +126 -0
  25. package/src/extensions/components/ai/Dialog.test.ts +335 -0
  26. package/src/extensions/components/ai/Dialog.vue +607 -310
  27. package/src/extensions/components/ai/backend.test.ts +1 -0
  28. package/src/extensions/components/ai/backend.ts +2 -1
  29. package/src/extensions/components/ai/context.test.ts +149 -0
  30. package/src/extensions/components/ai/context.ts +145 -0
  31. package/src/extensions/components/ai/diff.test.ts +65 -0
  32. package/src/extensions/components/ai/diff.ts +120 -0
  33. package/src/extensions/components/ai/errors.test.ts +45 -0
  34. package/src/extensions/components/ai/errors.ts +67 -0
  35. package/src/extensions/components/ai/result.test.ts +144 -0
  36. package/src/extensions/components/ai/result.ts +101 -0
  37. package/src/extensions/components/ai/thinking.test.ts +45 -0
  38. package/src/extensions/components/ai/thinking.ts +58 -0
  39. package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
  40. package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
  41. package/src/extensions/components/media/iframe/Button.vue +6 -7
  42. package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
  43. package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
  44. package/src/extensions/components/media/iframe/embed.test.ts +123 -0
  45. package/src/extensions/components/media/iframe/embed.ts +236 -0
  46. package/src/extensions/components/media/iframe/index.test.ts +123 -0
  47. package/src/extensions/components/media/iframe/index.ts +92 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +430 -132
  49. package/src/extensions/components/media/image/caption.test.ts +66 -1
  50. package/src/extensions/components/media/image/caption.ts +27 -1
  51. package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +114 -0
  53. package/src/extensions/components/media/image/index.ts +2 -1
  54. package/src/extensions/components/media/link/Button.test.ts +96 -0
  55. package/src/extensions/components/media/link/Button.vue +6 -2
  56. package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
  57. package/src/extensions/components/media/link/LinkDialog.vue +687 -61
  58. package/src/extensions/components/media/link/index.ts +111 -6
  59. package/src/extensions/components/media/link/link.marks.test.ts +89 -0
  60. package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
  61. package/src/extensions/components/media/link/linkDialog.ts +154 -0
  62. package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
  63. package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
  64. package/src/extensions/components/media/linkCard/index.test.ts +146 -0
  65. package/src/extensions/components/media/linkCard/index.ts +177 -0
  66. package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
  67. package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
  68. package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
  69. package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
  70. package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
  71. package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
  72. package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
  73. package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
  74. package/src/extensions/components/media/video/Button.vue +2 -1
  75. package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
  76. package/src/extensions/components/media/video/VideoDialog.vue +748 -59
  77. package/src/extensions/components/media/video/index.test.ts +154 -0
  78. package/src/extensions/components/media/video/index.ts +70 -5
  79. package/src/extensions/components/media/video/providers.test.ts +148 -0
  80. package/src/extensions/components/media/video/providers.ts +241 -0
  81. package/src/extensions/components/media/video/videoFile.test.ts +98 -0
  82. package/src/extensions/components/media/video/videoFile.ts +142 -0
  83. package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
  84. package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
  85. package/src/extensions/components/style/StyleColor.test.ts +255 -0
  86. package/src/extensions/components/style/StyleColor.vue +358 -208
  87. package/src/extensions/components/style/color/Button.vue +1 -3
  88. package/src/extensions/components/style/color/colors.test.ts +191 -0
  89. package/src/extensions/components/style/color/colors.ts +168 -0
  90. package/src/extensions/components/style/highlight/Button.vue +1 -3
  91. package/src/i18n/locales/ar.json +226 -23
  92. package/src/i18n/locales/bg.json +225 -22
  93. package/src/i18n/locales/ch.json +226 -23
  94. package/src/i18n/locales/cz.json +226 -23
  95. package/src/i18n/locales/de.json +226 -23
  96. package/src/i18n/locales/el.json +225 -22
  97. package/src/i18n/locales/en.json +225 -22
  98. package/src/i18n/locales/es.json +226 -23
  99. package/src/i18n/locales/et.json +225 -22
  100. package/src/i18n/locales/fi.json +226 -23
  101. package/src/i18n/locales/fr.json +226 -23
  102. package/src/i18n/locales/he.json +225 -22
  103. package/src/i18n/locales/hi.json +225 -22
  104. package/src/i18n/locales/hr.json +225 -22
  105. package/src/i18n/locales/hu.json +226 -23
  106. package/src/i18n/locales/id.json +225 -22
  107. package/src/i18n/locales/it.json +226 -23
  108. package/src/i18n/locales/ja.json +226 -23
  109. package/src/i18n/locales/ko.json +226 -23
  110. package/src/i18n/locales/la.json +226 -23
  111. package/src/i18n/locales/lt.json +226 -23
  112. package/src/i18n/locales/nl.json +226 -23
  113. package/src/i18n/locales/no.json +227 -24
  114. package/src/i18n/locales/pl.json +226 -23
  115. package/src/i18n/locales/pt-BR.json +225 -22
  116. package/src/i18n/locales/pt.json +226 -23
  117. package/src/i18n/locales/ro.json +225 -22
  118. package/src/i18n/locales/ru.json +227 -24
  119. package/src/i18n/locales/se.json +226 -23
  120. package/src/i18n/locales/sk.json +225 -22
  121. package/src/i18n/locales/sl.json +225 -22
  122. package/src/i18n/locales/sr.json +225 -22
  123. package/src/i18n/locales/th.json +226 -23
  124. package/src/i18n/locales/tr.json +226 -23
  125. package/src/i18n/locales/uk.json +227 -24
  126. package/src/i18n/locales/vi.json +226 -23
  127. package/src/index.ts +8 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +72 -2
  137. package/src/utils/validateHref.ts +57 -13
@@ -1,278 +1,428 @@
1
1
  <script setup lang="ts">
2
2
 
3
+ import * as mdi from '@mdi/js'
3
4
  import { Editor } from '@tiptap/vue-3'
4
-
5
- import { computed, inject, Ref, ref } from 'vue'
5
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
6
+ import {
7
+ addRecent,
8
+ ColorKind,
9
+ GridPosition,
10
+ loadRecent,
11
+ moveInGrid,
12
+ normalizeHex,
13
+ palettes,
14
+ sameColor,
15
+ saveRecent,
16
+ Swatch,
17
+ writeColor,
18
+ } from '@tiptapify/extensions/components/style/color/colors'
19
+ import { computed, inject, PropType, Ref, ref, watch } from 'vue'
6
20
  import { ComposerTranslation } from 'vue-i18n'
7
21
 
8
- const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
9
-
10
- defineExpose({ open, close })
11
-
12
22
  const props = defineProps({
13
- fontColor: { type: Boolean, default: false },
14
- backgroundColor: { type: Boolean, default: false },
23
+ kind: { type: String as PropType<ColorKind>, required: true },
24
+ // The toolbar button that opens the popover (a CSS selector)
25
+ activator: { type: String, required: true },
15
26
  color: { type: String, default: '' },
16
27
  })
17
28
 
18
29
  const editor = inject('tiptapifyEditor') as Ref<Editor>
19
30
 
20
- const colorPicker = ref(false)
21
- const initialColor = ref(computed(() => props.color).value)
22
- const customColor = ref(computed(() => props.color).value)
31
+ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
32
+
33
+ type EyeDropperApi = { open: () => Promise<{ sRGBHex: string }> }
34
+ const EyeDropper = (globalThis as { EyeDropper?: new () => EyeDropperApi }).EyeDropper
23
35
 
24
- const colorSelected = ref<boolean>(false)
36
+ const menu = ref(false)
37
+ // The color a preview falls back to: the one the popover opened with, then the last applied one
38
+ const original = ref('')
39
+ const previewing = ref(false)
40
+ const recent = ref<string[]>(loadRecent(props.kind))
41
+ const focused = ref<GridPosition>({ row: 0, col: 0 })
42
+ const swatchesEl = ref<HTMLElement>()
25
43
 
26
- const greyShades = {
27
- black: '#000',
28
- '#222': '#222',
29
- darkgray: '#444',
30
- gray: '#888',
31
- lightgray: '#ccc',
32
- white: '#fff',
33
- }
34
- const blueShades = {
35
- '#000044': '#000044',
36
- '#00006e': '#00006e',
37
- '#0000bb': '#0000bb',
38
- blue: '#0000ff',
39
- lightblue: '#0088ff',
40
- cyan: '#00FFFF',
44
+ const hex = ref('')
45
+ const hexDirty = ref(false)
46
+ const hexError = ref(false)
47
+
48
+ const customOpen = ref(false)
49
+ const pickerColor = ref('')
50
+ const pickerDirty = ref(false)
51
+
52
+ const title = computed(() => t(props.kind === 'text' ? 'style.color.text' : 'style.color.highlight'))
53
+ const palette = computed(() => palettes[props.kind])
54
+ const rows = computed<Swatch[][]>(() => {
55
+ const recentRow = recent.value.map(value => ({ value, name: '' }))
56
+
57
+ return recentRow.length ? [...palette.value, recentRow] : palette.value
58
+ })
59
+ const hexSample = computed(() => normalizeHex(hex.value) ?? (hex.value ? '' : original.value))
60
+
61
+ function swatchLabel(swatch: Swatch): string {
62
+ return swatch.name ? t(`style.color.${swatch.name}`) : String(swatch.value).toUpperCase()
41
63
  }
42
- const greenShades = {
43
- '#003100': '#003100',
44
- '#005200': '#005200',
45
- '#007000': '#007000',
46
- '#00b700': '#00b700',
47
- green: '#00ff00',
48
- '#70ff70': '#70ff70',
64
+
65
+ function isActive(swatch: Swatch): boolean {
66
+ return swatch.value === null ? !original.value : sameColor(swatch.value, original.value)
49
67
  }
50
- const redShades = {
51
- '#520000': '#520000',
52
- '#810000': '#810000',
53
- '#b20000': '#b20000',
54
- red: '#ff0000',
55
- '#ff2323': '#ff2323',
56
- '#ff5c5c': '#ff5c5c',
68
+
69
+ function activePosition(): GridPosition {
70
+ for (const [row, swatches] of rows.value.entries()) {
71
+ const col = swatches.findIndex(isActive)
72
+ if (col !== -1) {
73
+ return { row, col }
74
+ }
75
+ }
76
+
77
+ return { row: 0, col: 0 }
57
78
  }
58
- const otherShades = {
59
- brown: '#964B00',
60
- orange: '#ff9900',
61
- yellow: '#ffff00',
62
- pink: '#ff00ff',
63
- purple: '#800080',
64
- indigo: '#4b0082',
79
+
80
+ function preview(value: string | null) {
81
+ writeColor(editor.value, props.kind, value, { history: false })
82
+ previewing.value = true
65
83
  }
66
- const colors = { ...greyShades, ...blueShades, ...greenShades, ...redShades, ...otherShades }
67
84
 
68
- type Color = { r: number, g: number, b: number, a?: number }
85
+ function endPreview() {
86
+ if (!previewing.value) {
87
+ return
88
+ }
89
+
90
+ previewing.value = false
91
+ writeColor(editor.value, props.kind, original.value || null, { history: false })
92
+ }
69
93
 
70
- function calculateShadowColor(color: string): string {
71
- const rgb: Color = hexToRgb(color)
94
+ /**
95
+ * The preview is reverted first: re-setting the color it already shows would add no step,
96
+ * and undo would then skip this change.
97
+ */
98
+ function apply(value: string | null, close = true) {
99
+ endPreview()
100
+ writeColor(editor.value, props.kind, value, { focus: close })
101
+
102
+ original.value = value ?? ''
103
+ hex.value = value ?? ''
104
+ hexDirty.value = false
105
+ hexError.value = false
106
+
107
+ if (value) {
108
+ recent.value = addRecent(recent.value, value)
109
+ saveRecent(props.kind, recent.value)
110
+ }
72
111
 
73
- for (const channel of Object.keys(rgb)) {
74
- rgb[channel] = Math.round(rgb[channel] * .35)
112
+ if (close) {
113
+ menu.value = false
75
114
  }
115
+ }
76
116
 
77
- return Object.values(rgb).join(', ')
117
+ function swatchElement(position: GridPosition): HTMLElement | null {
118
+ return swatchesEl.value?.querySelector<HTMLElement>(`[data-row="${position.row}"][data-col="${position.col}"]`) ?? null
78
119
  }
79
120
 
80
- function hexToRgb(hex: string): Color {
81
- const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
82
- hex = hex.replace(shorthandRegex, function(m, r, g, b) {
83
- return r + r + g + g + b + b
84
- })
85
-
86
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
87
- return {
88
- r: result ? parseInt(result[1], 16) : 0,
89
- g: result ? parseInt(result[2], 16) : 0,
90
- b: result ? parseInt(result[3], 16) : 0
121
+ function onSwatchKeydown(event: KeyboardEvent, position: GridPosition, swatch: Swatch) {
122
+ // Handled here: the menu turns Enter into "focus the next field"
123
+ if (event.key === 'Enter') {
124
+ event.preventDefault()
125
+ event.stopPropagation()
126
+ apply(swatch.value)
127
+
128
+ return
91
129
  }
92
- }
93
130
 
94
- function hoverColor(color: string) {
95
- colorSelected.value = false
96
-
97
- if (props.fontColor) {
98
- editor.value
99
- .chain()
100
- .focus()
101
- .setColor(color)
102
- .command(({ tr }) => {
103
- tr.setMeta('addToHistory', false)
104
- return true
105
- })
106
- .run()
131
+ const next = moveInGrid(rows.value.map(row => row.length), position, event.key)
132
+ if (!next) {
133
+ return
107
134
  }
108
135
 
109
- if (props.backgroundColor) {
110
- editor.value
111
- .chain()
112
- .focus()
113
- .setHighlight({ color })
114
- .command(({ tr }) => {
115
- tr.setMeta('addToHistory', false)
116
- return true
117
- })
118
- .run()
136
+ event.preventDefault()
137
+ focused.value = next
138
+ swatchElement(next)?.focus()
139
+ preview(rows.value[next.row][next.col].value)
140
+ }
141
+
142
+ function onSwatchesFocusout(event: FocusEvent) {
143
+ if (!swatchesEl.value?.contains(event.relatedTarget as Node | null)) {
144
+ endPreview()
119
145
  }
120
146
  }
121
147
 
122
- function close() {
123
- resetColor()
148
+ function onHexInput() {
149
+ hexDirty.value = true
150
+ const value = normalizeHex(hex.value)
151
+ if (value) {
152
+ hexError.value = false
153
+ preview(value)
154
+ } else {
155
+ endPreview()
156
+ }
124
157
  }
125
158
 
126
- function resetColor() {
127
- if (colorSelected.value) {
159
+ function commitHex(close: boolean) {
160
+ // A blur caused by Esc closing the popover must not apply what was typed
161
+ if (!menu.value || (!close && !hexDirty.value) || !hex.value.trim()) {
128
162
  return
129
163
  }
130
164
 
131
- if (props.fontColor) {
132
- editor.value
133
- .chain()
134
- .focus()
135
- .setColor(initialColor.value)
136
- .command(({ tr }) => {
137
- tr.setMeta('addToHistory', false)
138
- return true
139
- })
140
- .run()
141
- }
165
+ const value = normalizeHex(hex.value)
166
+ if (!value) {
167
+ hexError.value = true
142
168
 
143
- if (props.backgroundColor) {
144
- if (initialColor.value) {
145
- editor.value
146
- .chain()
147
- .focus()
148
- .setHighlight({ color: initialColor.value })
149
- .command(({ tr }) => {
150
- tr.setMeta('addToHistory', false)
151
- return true
152
- })
153
- .run()
154
- } else {
155
- editor.value
156
- .chain()
157
- .focus()
158
- .unsetHighlight()
159
- .command(({ tr }) => {
160
- tr.setMeta('addToHistory', false)
161
- return true
162
- })
163
- .run()
164
- }
169
+ return
165
170
  }
171
+
172
+ apply(value, close)
166
173
  }
167
174
 
168
- function setColor(color: string) {
169
- colorSelected.value = true
175
+ function toggleCustom() {
176
+ customOpen.value = !customOpen.value
177
+ pickerColor.value = normalizeHex(hex.value) ?? normalizeHex(original.value) ?? String(palette.value[1][0].value)
178
+ pickerDirty.value = false
179
+ }
170
180
 
171
- if (props.fontColor) {
172
- editor.value.chain().focus().setColor(color).run()
181
+ function onPick(value: string) {
182
+ const picked = normalizeHex(value)
183
+ if (!picked || picked === normalizeHex(pickerColor.value)) {
184
+ return
173
185
  }
174
186
 
175
- if (props.backgroundColor) {
176
- editor.value.chain().focus().setHighlight({ color }).run()
187
+ pickerColor.value = picked
188
+ pickerDirty.value = true
189
+ hex.value = picked
190
+ hexError.value = false
191
+ preview(picked)
192
+ }
193
+
194
+ // The picker reports every step of a drag; the color is applied once, when the pointer is released
195
+ function onPickerPointerdown() {
196
+ window.addEventListener('pointerup', commitPick, { once: true })
197
+ }
198
+
199
+ function commitPick() {
200
+ if (!menu.value || !pickerDirty.value) {
201
+ return
177
202
  }
203
+
204
+ pickerDirty.value = false
205
+ apply(pickerColor.value, false)
178
206
  }
179
207
 
180
- function unsetColor() {
181
- if (props.fontColor) {
182
- editor.value.chain().focus().unsetColor().run()
208
+ async function pickFromScreen() {
209
+ if (!EyeDropper) {
210
+ return
183
211
  }
184
212
 
185
- if (props.backgroundColor) {
186
- editor.value.chain().focus().unsetHighlight().run()
213
+ try {
214
+ const value = normalizeHex((await new EyeDropper().open()).sRGBHex)
215
+ if (value) {
216
+ apply(value)
217
+ }
218
+ } catch {
219
+ // Picking was cancelled
187
220
  }
188
221
  }
189
222
 
190
- function isColorActive(color: string): boolean {
191
- return props.color === color
192
- }
223
+ watch(menu, open => {
224
+ if (!open) {
225
+ hexDirty.value = false
226
+ customOpen.value = false
227
+ endPreview()
228
+
229
+ return
230
+ }
231
+
232
+ original.value = props.color
233
+ previewing.value = false
234
+ hex.value = normalizeHex(props.color) ?? ''
235
+ hexError.value = false
236
+ // Another editor on the page may have added colors since
237
+ recent.value = loadRecent(props.kind)
238
+ focused.value = activePosition()
239
+ })
240
+
193
241
  </script>
194
242
 
195
243
  <template>
196
- <VSheet class="pa-2">
197
- <div class="tiptapify-style-color-container">
198
- <template v-for="colorCode in colors">
199
- <div
200
- class="tiptapify-style-color-item"
201
- :class="isColorActive(colorCode) ? 'tiptapify-style-color-item-active' : ''"
202
- @mouseenter="hoverColor(colorCode)"
203
- @mouseleave="resetColor()"
204
- @click="setColor(colorCode)"
244
+ <VMenu
245
+ v-model="menu"
246
+ :activator="activator"
247
+ :close-on-content-click="false"
248
+ location="bottom start"
249
+ @after-enter="swatchElement(focused)?.focus()"
250
+ >
251
+ <VSheet class="tiptapify-color pa-2" :width="272" role="dialog" :aria-label="title">
252
+ <div ref="swatchesEl" @focusout="onSwatchesFocusout">
253
+ <template v-for="(row, rowIndex) in rows" :key="rowIndex">
254
+ <div v-if="rowIndex === palette.length" class="tiptapify-color__label text-caption text-medium-emphasis">
255
+ {{ t('style.color.recent') }}
256
+ </div>
257
+ <div class="tiptapify-color__row">
258
+ <button
259
+ v-for="(swatch, colIndex) in row"
260
+ :key="`${swatch.value}-${colIndex}`"
261
+ type="button"
262
+ class="tiptapify-color__swatch"
263
+ :class="{
264
+ 'tiptapify-color__swatch--none': swatch.value === null,
265
+ 'tiptapify-color__swatch--active': isActive(swatch),
266
+ }"
267
+ :style="swatch.value ? { backgroundColor: swatch.value } : undefined"
268
+ :data-row="rowIndex"
269
+ :data-col="colIndex"
270
+ :tabindex="focused.row === rowIndex && focused.col === colIndex ? 0 : -1"
271
+ :aria-label="swatchLabel(swatch)"
272
+ :aria-pressed="isActive(swatch)"
273
+ @mouseenter="preview(swatch.value)"
274
+ @mouseleave="endPreview()"
275
+ @focus="focused = { row: rowIndex, col: colIndex }"
276
+ @keydown="onSwatchKeydown($event, { row: rowIndex, col: colIndex }, swatch)"
277
+ @click="apply(swatch.value)"
278
+ >
279
+ <Tooltip :label="swatchLabel(swatch)" :open-delay="300" />
280
+ </button>
281
+ </div>
282
+ </template>
283
+ </div>
284
+
285
+ <VDivider class="my-2" />
286
+
287
+ <div class="tiptapify-color__custom">
288
+ <VTextField
289
+ v-model="hex"
290
+ class="tiptapify-color__hex"
291
+ variant="outlined"
292
+ density="compact"
293
+ hide-details="auto"
294
+ placeholder="#000000"
295
+ spellcheck="false"
296
+ :aria-label="t('style.color.hex')"
297
+ :error-messages="hexError ? t('style.color.hex_invalid') : []"
298
+ @update:model-value="onHexInput"
299
+ @keydown.enter.stop.prevent="commitHex(true)"
300
+ @blur="commitHex(false)"
205
301
  >
206
- <div
207
- class="tiptapify-style-color-picker"
208
- :style="`background-color: ${colorCode}; box-shadow: 1px 1px 4px rgb(${calculateShadowColor(colorCode)});`"
209
- />
210
- </div>
211
- </template>
212
- </div>
213
-
214
- <VDivider class="mt-2 mb-2" />
215
-
216
- <VMenu v-model="colorPicker" :close-on-content-click="false">
217
- <template #activator="{ props: menuProps }">
218
- <VBtn v-bind="menuProps" color="default" variant="flat" block>
219
- <template #prepend>
220
- <div
221
- class="tiptapify-style-color-picker"
222
- :style="`background-color: ${customColor}; box-shadow: 1px 1px 4px rgb(0, 0, 0, .35);`"
302
+ <template #prepend-inner>
303
+ <span
304
+ class="tiptapify-color__sample"
305
+ :class="{ 'tiptapify-color__swatch--none': !hexSample }"
306
+ :style="hexSample ? { backgroundColor: hexSample } : undefined"
223
307
  />
224
308
  </template>
225
- <template #default>
226
- {{ t('style.color.custom') }}
227
- </template>
309
+ </VTextField>
310
+
311
+ <VBtn
312
+ v-if="EyeDropper"
313
+ class="tiptapify-color__eyedropper"
314
+ variant="text"
315
+ :size="32"
316
+ icon
317
+ :aria-label="t('style.color.eyedropper')"
318
+ @click="pickFromScreen"
319
+ >
320
+ <VIcon :icon="`mdiSvg:${mdi.mdiEyedropperVariant}`" size="small" />
321
+ <Tooltip :label="t('style.color.eyedropper')" />
322
+ </VBtn>
323
+
324
+ <!-- An icon, not a text label: a translated label would squeeze the HEX field -->
325
+ <VBtn
326
+ class="tiptapify-color__custom-toggle"
327
+ variant="text"
328
+ size="small"
329
+ icon
330
+ :color="customOpen ? 'primary' : ''"
331
+ :aria-label="t('style.color.custom')"
332
+ :aria-expanded="customOpen"
333
+ @click="toggleCustom"
334
+ >
335
+ <VIcon :icon="`mdiSvg:${mdi.mdiPaletteOutline}`" size="small" />
336
+ <Tooltip :label="t('style.color.custom')" />
228
337
  </VBtn>
229
- </template>
230
-
231
- <VCard>
232
- <VCardItem>
233
- <VColorPicker v-model="customColor" elevated elevation="24" hide-inputs @update:model-value="hoverColor($event)" />
234
- </VCardItem>
235
-
236
- <VCardActions>
237
- <VBtn variant="flat" color="primary" @click="setColor(customColor)">
238
- OK
239
- </VBtn>
240
- <VBtn variant="flat" color="grey-400" @click="colorPicker = !colorPicker; customColor = initialColor; resetColor()">
241
- Cancel
242
- </VBtn>
243
- </VCardActions>
244
- </VCard>
245
- </VMenu>
246
-
247
- <VDivider class="mt-2 mb-2" />
248
-
249
- <VBtn color="default" block variant="flat" @click="unsetColor">
250
- {{ t('style.color.unset') }}
251
- </VBtn>
252
- </VSheet>
338
+ </div>
339
+
340
+ <div
341
+ v-if="customOpen"
342
+ class="tiptapify-color__picker mt-2"
343
+ @pointerdown="onPickerPointerdown"
344
+ @keydown.enter.stop.prevent="commitPick"
345
+ >
346
+ <VColorPicker
347
+ :model-value="pickerColor"
348
+ mode="hex"
349
+ :modes="['hex']"
350
+ :width="256"
351
+ :canvas-height="120"
352
+ elevation="0"
353
+ hide-inputs
354
+ @update:model-value="onPick"
355
+ />
356
+ </div>
357
+ </VSheet>
358
+ </VMenu>
253
359
  </template>
254
360
 
255
361
  <style lang="scss" scoped>
256
- .tiptapify-style-color-container {
362
+ .tiptapify-color__label {
363
+ margin: 8px 2px 2px;
364
+ }
365
+
366
+ .tiptapify-color__row {
257
367
  display: grid;
258
- grid-template-columns: repeat(6, 1fr);
368
+ grid-template-columns: repeat(8, 1fr);
369
+ justify-items: center;
259
370
  }
260
371
 
261
- .tiptapify-style-color-item {
372
+ .tiptapify-color__swatch {
373
+ width: 24px;
374
+ height: 24px;
375
+ margin: 4px 0;
376
+ border-radius: 4px;
377
+ border: 1px solid rgba(var(--v-border-color), .24);
262
378
  cursor: pointer;
263
- border-radius: 8px;
264
- padding: 5px;
265
- transition: background-color .2s;
379
+ transition: transform .1s;
380
+
381
+ &:hover, &:focus-visible {
382
+ transform: scale(1.15);
383
+ }
384
+
385
+ &:focus-visible {
386
+ outline: 2px solid rgb(var(--v-theme-on-surface));
387
+ outline-offset: 1px;
388
+ }
266
389
  }
267
390
 
268
- .tiptapify-style-color-item:hover, .tiptapify-style-color-item-active {
269
- background-color: #dedede;
391
+ .tiptapify-color__swatch--active {
392
+ box-shadow: 0 0 0 2px rgb(var(--v-theme-surface)), 0 0 0 4px rgb(var(--v-theme-primary));
270
393
  }
271
394
 
272
- .tiptapify-style-color-picker {
273
- width: 24px;
274
- height: 24px;
275
- margin: 2px;
276
- border-radius: 4px;
395
+ // "Default" / "No highlight": an empty cell crossed out in red
396
+ .tiptapify-color__swatch--none {
397
+ background:
398
+ linear-gradient(to top right, transparent calc(50% - 1px), #e53935 calc(50% - 1px), #e53935 calc(50% + 1px), transparent calc(50% + 1px)),
399
+ rgb(var(--v-theme-surface));
400
+ }
401
+
402
+ .tiptapify-color__custom {
403
+ display: flex;
404
+ align-items: flex-start;
405
+ gap: 4px;
406
+ }
407
+
408
+ .tiptapify-color__hex {
409
+ flex: 1 1 auto;
410
+ min-width: 0;
411
+
412
+ :deep(input) {
413
+ font-family: monospace;
414
+ }
415
+ }
416
+
417
+ .tiptapify-color__eyedropper, .tiptapify-color__custom-toggle {
418
+ margin-top: 4px;
419
+ }
420
+
421
+ .tiptapify-color__sample {
422
+ flex: none;
423
+ width: 18px;
424
+ height: 18px;
425
+ border-radius: 3px;
426
+ border: 1px solid rgba(var(--v-border-color), .24);
277
427
  }
278
- </style>
428
+ </style>
@@ -45,9 +45,7 @@ const selectedColor = computed(() => editor.value.getAttributes('textStyle').col
45
45
  />
46
46
  </VBtn>
47
47
 
48
- <VMenu :activator="`#tiptapify-color-button-${editor.instanceId}`">
49
- <StyleColor :font-color="true" :background-color="false" :color="selectedColor" />
50
- </VMenu>
48
+ <StyleColor kind="text" :activator="`#tiptapify-color-button-${editor.instanceId}`" :color="selectedColor" />
51
49
  </template>
52
50
 
53
51
  <style lang="scss" scoped>