tiptapify 0.3.5 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/content.css +28 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +110770 -65623
- package/dist/tiptapify.umd.js +106 -89
- package/index.d.ts +37 -0
- package/package.json +3 -2
- package/src/components/ImageBubbleMenu.vue +5 -33
- package/src/components/MenuBubble.test.ts +120 -0
- package/src/components/MenuBubble.vue +124 -134
- package/src/components/MenuFloating.vue +48 -45
- package/src/components/Tiptapify.upload.test.ts +10 -0
- package/src/components/Tiptapify.vue +85 -45
- package/src/components/Toolbar/Index.vue +10 -0
- package/src/components/UI/BubbleRow.vue +41 -0
- package/src/components/UI/TiptapifyDialog.test.ts +37 -3
- package/src/components/UI/TiptapifyDialog.vue +15 -4
- package/src/components/editorExtensions.test.ts +9 -0
- package/src/components/editorExtensions.ts +21 -1
- package/src/composables/fakeUploadEndpoint.ts +65 -0
- package/src/composables/useTiptapifyConfig.ts +19 -0
- package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
- package/src/composables/useTiptapifyLinkPreview.ts +60 -0
- package/src/composables/useTiptapifyUpload.test.ts +96 -0
- package/src/composables/useTiptapifyUpload.ts +126 -0
- package/src/extensions/components/ai/Dialog.test.ts +335 -0
- package/src/extensions/components/ai/Dialog.vue +607 -310
- package/src/extensions/components/ai/backend.test.ts +1 -0
- package/src/extensions/components/ai/backend.ts +2 -1
- package/src/extensions/components/ai/context.test.ts +149 -0
- package/src/extensions/components/ai/context.ts +145 -0
- package/src/extensions/components/ai/diff.test.ts +65 -0
- package/src/extensions/components/ai/diff.ts +120 -0
- package/src/extensions/components/ai/errors.test.ts +45 -0
- package/src/extensions/components/ai/errors.ts +67 -0
- package/src/extensions/components/ai/result.test.ts +144 -0
- package/src/extensions/components/ai/result.ts +101 -0
- package/src/extensions/components/ai/thinking.test.ts +45 -0
- package/src/extensions/components/ai/thinking.ts +58 -0
- package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
- package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
- package/src/extensions/components/media/iframe/Button.vue +6 -7
- package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
- package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
- package/src/extensions/components/media/iframe/embed.test.ts +123 -0
- package/src/extensions/components/media/iframe/embed.ts +236 -0
- package/src/extensions/components/media/iframe/index.test.ts +123 -0
- package/src/extensions/components/media/iframe/index.ts +92 -10
- package/src/extensions/components/media/image/ImageDialog.vue +430 -132
- package/src/extensions/components/media/image/caption.test.ts +66 -1
- package/src/extensions/components/media/image/caption.ts +27 -1
- package/src/extensions/components/media/image/imageDialog.test.ts +210 -0
- package/src/extensions/components/media/image/imageDialog.ts +117 -0
- package/src/extensions/components/media/image/index.ts +2 -1
- package/src/extensions/components/media/link/Button.test.ts +96 -0
- package/src/extensions/components/media/link/Button.vue +6 -2
- package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
- package/src/extensions/components/media/link/LinkDialog.vue +687 -61
- package/src/extensions/components/media/link/index.ts +111 -6
- package/src/extensions/components/media/link/link.marks.test.ts +89 -0
- package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
- package/src/extensions/components/media/link/linkDialog.ts +154 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
- package/src/extensions/components/media/linkCard/index.test.ts +146 -0
- package/src/extensions/components/media/linkCard/index.ts +177 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
- package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
- package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
- package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
- package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
- package/src/extensions/components/media/video/Button.vue +2 -1
- package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
- package/src/extensions/components/media/video/VideoDialog.vue +748 -59
- package/src/extensions/components/media/video/index.test.ts +154 -0
- package/src/extensions/components/media/video/index.ts +70 -5
- package/src/extensions/components/media/video/providers.test.ts +148 -0
- package/src/extensions/components/media/video/providers.ts +241 -0
- package/src/extensions/components/media/video/videoFile.test.ts +98 -0
- package/src/extensions/components/media/video/videoFile.ts +142 -0
- package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
- package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
- package/src/extensions/components/style/StyleColor.test.ts +255 -0
- package/src/extensions/components/style/StyleColor.vue +358 -208
- package/src/extensions/components/style/color/Button.vue +1 -3
- package/src/extensions/components/style/color/colors.test.ts +191 -0
- package/src/extensions/components/style/color/colors.ts +168 -0
- package/src/extensions/components/style/highlight/Button.vue +1 -3
- package/src/i18n/locales/ar.json +226 -23
- package/src/i18n/locales/bg.json +225 -22
- package/src/i18n/locales/ch.json +226 -23
- package/src/i18n/locales/cz.json +226 -23
- package/src/i18n/locales/de.json +226 -23
- package/src/i18n/locales/el.json +225 -22
- package/src/i18n/locales/en.json +225 -22
- package/src/i18n/locales/es.json +226 -23
- package/src/i18n/locales/et.json +225 -22
- package/src/i18n/locales/fi.json +226 -23
- package/src/i18n/locales/fr.json +226 -23
- package/src/i18n/locales/he.json +225 -22
- package/src/i18n/locales/hi.json +225 -22
- package/src/i18n/locales/hr.json +225 -22
- package/src/i18n/locales/hu.json +226 -23
- package/src/i18n/locales/id.json +225 -22
- package/src/i18n/locales/it.json +226 -23
- package/src/i18n/locales/ja.json +226 -23
- package/src/i18n/locales/ko.json +226 -23
- package/src/i18n/locales/la.json +226 -23
- package/src/i18n/locales/lt.json +226 -23
- package/src/i18n/locales/nl.json +226 -23
- package/src/i18n/locales/no.json +227 -24
- package/src/i18n/locales/pl.json +226 -23
- package/src/i18n/locales/pt-BR.json +225 -22
- package/src/i18n/locales/pt.json +226 -23
- package/src/i18n/locales/ro.json +225 -22
- package/src/i18n/locales/ru.json +227 -24
- package/src/i18n/locales/se.json +226 -23
- package/src/i18n/locales/sk.json +225 -22
- package/src/i18n/locales/sl.json +225 -22
- package/src/i18n/locales/sr.json +225 -22
- package/src/i18n/locales/th.json +226 -23
- package/src/i18n/locales/tr.json +226 -23
- package/src/i18n/locales/uk.json +227 -24
- package/src/i18n/locales/vi.json +226 -23
- package/src/index.ts +10 -0
- package/src/skins/registry.test.ts +1 -1
- package/src/skins/tokens.reachability.test.ts +1 -0
- package/src/skins/tokens.ts +3 -3
- package/src/types/editor.ts +40 -1
- package/src/utils/contentStyles.test.ts +116 -0
- package/src/utils/contentStyles.ts +175 -0
- package/src/utils/mediaSize.test.ts +22 -0
- package/src/utils/mediaSize.ts +27 -0
- package/src/utils/validateHref.test.ts +47 -2
- package/src/utils/validateHref.ts +47 -7
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { Editor } from '@tiptap/vue-3'
|
|
3
|
+
import { Document } from '@tiptap/extension-document'
|
|
4
|
+
import { Highlight } from '@tiptap/extension-highlight'
|
|
5
|
+
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
6
|
+
import { Text } from '@tiptap/extension-text'
|
|
7
|
+
import { TextStyleKit } from '@tiptap/extension-text-style'
|
|
8
|
+
import { UndoRedo } from '@tiptap/extensions'
|
|
9
|
+
import {
|
|
10
|
+
addRecent,
|
|
11
|
+
loadRecent,
|
|
12
|
+
moveInGrid,
|
|
13
|
+
normalizeHex,
|
|
14
|
+
palettes,
|
|
15
|
+
RECENT_LIMIT,
|
|
16
|
+
sameColor,
|
|
17
|
+
saveRecent,
|
|
18
|
+
writeColor,
|
|
19
|
+
} from '@tiptapify/extensions/components/style/color/colors'
|
|
20
|
+
|
|
21
|
+
describe('palettes', () => {
|
|
22
|
+
it('start with the cell that removes the color, then rows of at most 8 valid colors', () => {
|
|
23
|
+
for (const rows of Object.values(palettes)) {
|
|
24
|
+
expect(rows[0][0].value).toBeNull()
|
|
25
|
+
|
|
26
|
+
const colors = rows.flat().slice(1).map(swatch => swatch.value)
|
|
27
|
+
expect(colors.every(color => normalizeHex(color) === color)).toBe(true)
|
|
28
|
+
expect(new Set(colors).size).toBe(colors.length)
|
|
29
|
+
expect(rows.every(row => row.length <= 8)).toBe(true)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('name every swatch', () => {
|
|
34
|
+
const names = Object.values(palettes).flat(2).map(swatch => swatch.name)
|
|
35
|
+
|
|
36
|
+
expect(names.every(Boolean)).toBe(true)
|
|
37
|
+
expect(names).toContain('default')
|
|
38
|
+
expect(names).toContain('no_highlight')
|
|
39
|
+
expect(names).toContain('names.dark_red')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('normalizeHex', () => {
|
|
44
|
+
it.each([
|
|
45
|
+
['#abc', '#aabbcc'],
|
|
46
|
+
['#AABBCC', '#aabbcc'],
|
|
47
|
+
[' #0f0 ', '#00ff00'],
|
|
48
|
+
['1D4ED8', '#1d4ed8'],
|
|
49
|
+
])('reads %s as %s', (input, expected) => {
|
|
50
|
+
expect(normalizeHex(input)).toBe(expected)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it.each(['', '#', '#ab', '#abcd', '#abcdeg', 'red', 'rgb(0, 0, 0)', null, undefined])('rejects %s', input => {
|
|
54
|
+
expect(normalizeHex(input)).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('sameColor', () => {
|
|
59
|
+
it('compares hex colors in any notation and other colors as text', () => {
|
|
60
|
+
expect(sameColor('#FFF', '#ffffff')).toBe(true)
|
|
61
|
+
expect(sameColor('#fff', '#fffffe')).toBe(false)
|
|
62
|
+
expect(sameColor('rgb(0, 0, 0)', 'RGB(0, 0, 0)')).toBe(true)
|
|
63
|
+
expect(sameColor('rgb(0, 0, 0)', '#000000')).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('recent colors', () => {
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
localStorage.clear()
|
|
70
|
+
vi.restoreAllMocks()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('puts the newest first without duplicates and keeps the limit', () => {
|
|
74
|
+
let list: string[] = []
|
|
75
|
+
for (let index = 0; index < RECENT_LIMIT + 2; index++) {
|
|
76
|
+
list = addRecent(list, `#00000${index}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
expect(list).toHaveLength(RECENT_LIMIT)
|
|
80
|
+
expect(list[0]).toBe('#000009')
|
|
81
|
+
expect(addRecent(list, '#000005')[0]).toBe('#000005')
|
|
82
|
+
expect(addRecent(list, '#000005')).toHaveLength(RECENT_LIMIT)
|
|
83
|
+
expect(addRecent(['#aabbcc'], '#ABC')).toEqual(['#aabbcc'])
|
|
84
|
+
expect(addRecent(['#aabbcc'], 'nope')).toEqual(['#aabbcc'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('are remembered per kind', () => {
|
|
88
|
+
saveRecent('text', ['#b91c1c', '#1d4ed8'])
|
|
89
|
+
|
|
90
|
+
expect(loadRecent('text')).toEqual(['#b91c1c', '#1d4ed8'])
|
|
91
|
+
expect(loadRecent('highlight')).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('skip junk in storage', () => {
|
|
95
|
+
localStorage.setItem('tiptapify:recent-colors:text', JSON.stringify(['#ABC', 'red', 42, '#aabbcc']))
|
|
96
|
+
expect(loadRecent('text')).toEqual(['#aabbcc'])
|
|
97
|
+
|
|
98
|
+
localStorage.setItem('tiptapify:recent-colors:text', '{broken')
|
|
99
|
+
expect(loadRecent('text')).toEqual([])
|
|
100
|
+
|
|
101
|
+
localStorage.setItem('tiptapify:recent-colors:text', '{"a":1}')
|
|
102
|
+
expect(loadRecent('text')).toEqual([])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('work without storage', () => {
|
|
106
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
107
|
+
throw new Error('blocked')
|
|
108
|
+
})
|
|
109
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
110
|
+
throw new Error('blocked')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(() => saveRecent('text', ['#000000'])).not.toThrow()
|
|
114
|
+
expect(loadRecent('text')).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('moveInGrid', () => {
|
|
119
|
+
const rows = [3, 8, 8]
|
|
120
|
+
|
|
121
|
+
it('runs left/right through the rows like text', () => {
|
|
122
|
+
expect(moveInGrid(rows, { row: 0, col: 2 }, 'ArrowRight')).toEqual({ row: 1, col: 0 })
|
|
123
|
+
expect(moveInGrid(rows, { row: 1, col: 0 }, 'ArrowLeft')).toEqual({ row: 0, col: 2 })
|
|
124
|
+
expect(moveInGrid(rows, { row: 0, col: 0 }, 'ArrowLeft')).toEqual({ row: 0, col: 0 })
|
|
125
|
+
expect(moveInGrid(rows, { row: 2, col: 7 }, 'ArrowRight')).toEqual({ row: 2, col: 7 })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps the column up/down where the row is long enough', () => {
|
|
129
|
+
expect(moveInGrid(rows, { row: 1, col: 6 }, 'ArrowDown')).toEqual({ row: 2, col: 6 })
|
|
130
|
+
expect(moveInGrid(rows, { row: 1, col: 6 }, 'ArrowUp')).toEqual({ row: 0, col: 2 })
|
|
131
|
+
expect(moveInGrid(rows, { row: 0, col: 1 }, 'ArrowUp')).toEqual({ row: 0, col: 1 })
|
|
132
|
+
expect(moveInGrid(rows, { row: 2, col: 1 }, 'ArrowDown')).toEqual({ row: 2, col: 1 })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('jumps to the row ends with Home/End and ignores other keys', () => {
|
|
136
|
+
expect(moveInGrid(rows, { row: 1, col: 4 }, 'Home')).toEqual({ row: 1, col: 0 })
|
|
137
|
+
expect(moveInGrid(rows, { row: 1, col: 4 }, 'End')).toEqual({ row: 1, col: 7 })
|
|
138
|
+
expect(moveInGrid(rows, { row: 1, col: 4 }, 'a')).toBeNull()
|
|
139
|
+
expect(moveInGrid(rows, { row: 5, col: 0 }, 'ArrowRight')).toBeNull()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('writeColor', () => {
|
|
144
|
+
let editor: Editor
|
|
145
|
+
|
|
146
|
+
function makeEditor(content: string): Editor {
|
|
147
|
+
editor = new Editor({
|
|
148
|
+
element: document.createElement('div'),
|
|
149
|
+
content,
|
|
150
|
+
extensions: [Document, Paragraph, Text, TextStyleKit, Highlight.configure({ multicolor: true }), UndoRedo],
|
|
151
|
+
})
|
|
152
|
+
editor.commands.selectAll()
|
|
153
|
+
|
|
154
|
+
return editor
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
afterEach(() => editor?.destroy())
|
|
158
|
+
|
|
159
|
+
it('sets and removes the text color', () => {
|
|
160
|
+
makeEditor('<p>Hello</p>')
|
|
161
|
+
|
|
162
|
+
writeColor(editor, 'text', '#b91c1c')
|
|
163
|
+
expect(editor.getAttributes('textStyle').color).toBe('#b91c1c')
|
|
164
|
+
|
|
165
|
+
writeColor(editor, 'text', null)
|
|
166
|
+
expect(editor.getAttributes('textStyle').color).toBeFalsy()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('sets and removes the highlight', () => {
|
|
170
|
+
makeEditor('<p>Hello</p>')
|
|
171
|
+
|
|
172
|
+
writeColor(editor, 'highlight', '#fde68a')
|
|
173
|
+
expect(editor.getAttributes('highlight').color).toBe('#fde68a')
|
|
174
|
+
|
|
175
|
+
writeColor(editor, 'highlight', null)
|
|
176
|
+
expect(editor.isActive('highlight')).toBe(false)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('keeps previews out of undo', () => {
|
|
180
|
+
makeEditor('<p><span style="color: #1d4ed8">Hello</span></p>')
|
|
181
|
+
|
|
182
|
+
writeColor(editor, 'text', '#b91c1c', { history: false })
|
|
183
|
+
expect(editor.getAttributes('textStyle').color).toBe('#b91c1c')
|
|
184
|
+
expect(editor.can().undo()).toBe(false)
|
|
185
|
+
|
|
186
|
+
writeColor(editor, 'text', '#1d4ed8', { history: false })
|
|
187
|
+
writeColor(editor, 'text', '#15803d')
|
|
188
|
+
editor.commands.undo()
|
|
189
|
+
expect(editor.getAttributes('textStyle').color).toBe('#1d4ed8')
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/vue-3'
|
|
2
|
+
|
|
3
|
+
export type ColorKind = 'text' | 'highlight'
|
|
4
|
+
|
|
5
|
+
// `value: null` is the "Default" / "No highlight" cell that removes the color; `name` is a key under `style.color`
|
|
6
|
+
export type Swatch = { value: string | null, name: string }
|
|
7
|
+
|
|
8
|
+
export type GridPosition = { row: number, col: number }
|
|
9
|
+
|
|
10
|
+
export const RECENT_LIMIT = 8
|
|
11
|
+
|
|
12
|
+
const hues = ['red', 'orange', 'amber', 'green', 'teal', 'blue', 'purple', 'pink']
|
|
13
|
+
|
|
14
|
+
function hueRow(prefix: string, values: string[]): Swatch[] {
|
|
15
|
+
return values.map((value, index) => ({ value, name: `names.${prefix}${hues[index]}` }))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Rows of 8: text colors stay readable on a light page, highlights are pastel so black text stays readable on them
|
|
19
|
+
export const palettes: Record<ColorKind, Swatch[][]> = {
|
|
20
|
+
text: [
|
|
21
|
+
[
|
|
22
|
+
{ value: null, name: 'default' },
|
|
23
|
+
{ value: '#000000', name: 'names.black' },
|
|
24
|
+
{ value: '#404040', name: 'names.dark_grey' },
|
|
25
|
+
{ value: '#595959', name: 'names.grey' },
|
|
26
|
+
{ value: '#808080', name: 'names.medium_grey' },
|
|
27
|
+
{ value: '#a6a6a6', name: 'names.light_grey' },
|
|
28
|
+
{ value: '#d9d9d9', name: 'names.pale_grey' },
|
|
29
|
+
{ value: '#ffffff', name: 'names.white' },
|
|
30
|
+
],
|
|
31
|
+
hueRow('dark_', ['#b91c1c', '#c2410c', '#a16207', '#15803d', '#0f766e', '#1d4ed8', '#7e22ce', '#be185d']),
|
|
32
|
+
hueRow('', ['#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6', '#a855f7', '#ec4899']),
|
|
33
|
+
],
|
|
34
|
+
highlight: [
|
|
35
|
+
[
|
|
36
|
+
{ value: null, name: 'no_highlight' },
|
|
37
|
+
{ value: '#ededed', name: 'names.pale_grey' },
|
|
38
|
+
{ value: '#d4d4d4', name: 'names.light_grey' },
|
|
39
|
+
],
|
|
40
|
+
hueRow('light_', ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#99f6e4', '#bfdbfe', '#e9d5ff', '#fbcfe8']),
|
|
41
|
+
hueRow('', ['#fca5a5', '#fdba74', '#fcd34d', '#86efac', '#5eead4', '#93c5fd', '#d8b4fe', '#f9a8d4']),
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `#rgb` / `#rrggbb` (the `#` may be omitted, as copied from design tools) to lowercase `#rrggbb`, else null.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeHex(input: string | null | undefined): string | null {
|
|
49
|
+
const match = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(String(input ?? '').trim())
|
|
50
|
+
if (!match) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const digits = match[1].length === 3 ? match[1].replace(/./g, digit => digit + digit) : match[1]
|
|
55
|
+
|
|
56
|
+
return `#${digits.toLowerCase()}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Colors pasted from other sites may be `rgb()` or names: those match only themselves
|
|
60
|
+
export function sameColor(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
61
|
+
const left = normalizeHex(a) ?? String(a ?? '').trim().toLowerCase()
|
|
62
|
+
const right = normalizeHex(b) ?? String(b ?? '').trim().toLowerCase()
|
|
63
|
+
|
|
64
|
+
return left === right
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function addRecent(list: string[], color: string, limit = RECENT_LIMIT): string[] {
|
|
68
|
+
const hex = normalizeHex(color)
|
|
69
|
+
if (!hex) {
|
|
70
|
+
return list
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [hex, ...list.filter(item => item !== hex)].slice(0, limit)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function storage(): Storage | null {
|
|
77
|
+
try {
|
|
78
|
+
return window.localStorage
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function recentKey(kind: ColorKind): string {
|
|
85
|
+
return `tiptapify:recent-colors:${kind}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Storage may be blocked (private mode, sandboxed iframe) or hold junk: the picker then starts without recent colors
|
|
89
|
+
export function loadRecent(kind: ColorKind): string[] {
|
|
90
|
+
try {
|
|
91
|
+
const stored = JSON.parse(storage()?.getItem(recentKey(kind)) ?? '[]')
|
|
92
|
+
if (!Array.isArray(stored)) {
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const colors = stored.map(item => normalizeHex(String(item))).filter((item): item is string => !!item)
|
|
97
|
+
|
|
98
|
+
return [...new Set(colors)].slice(0, RECENT_LIMIT)
|
|
99
|
+
} catch {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function saveRecent(kind: ColorKind, list: string[]): void {
|
|
105
|
+
try {
|
|
106
|
+
storage()?.setItem(recentKey(kind), JSON.stringify(list.slice(0, RECENT_LIMIT)))
|
|
107
|
+
} catch {
|
|
108
|
+
// remembered for this session only
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The next swatch for an arrow / Home / End key in rows of different lengths, or null for other keys.
|
|
114
|
+
* Left/right run through the rows like text, up/down keep the column where the row is long enough.
|
|
115
|
+
*/
|
|
116
|
+
export function moveInGrid(rowLengths: number[], position: GridPosition, key: string): GridPosition | null {
|
|
117
|
+
const flat = rowLengths.flatMap((length, row) => Array.from({ length }, (_, col) => ({ row, col })))
|
|
118
|
+
const index = flat.findIndex(cell => cell.row === position.row && cell.col === position.col)
|
|
119
|
+
if (index === -1 || !flat.length) {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const rowAt = (row: number): GridPosition => ({ row, col: Math.min(position.col, rowLengths[row] - 1) })
|
|
124
|
+
|
|
125
|
+
switch (key) {
|
|
126
|
+
case 'ArrowRight':
|
|
127
|
+
return flat[Math.min(index + 1, flat.length - 1)]
|
|
128
|
+
case 'ArrowLeft':
|
|
129
|
+
return flat[Math.max(index - 1, 0)]
|
|
130
|
+
case 'ArrowDown':
|
|
131
|
+
return position.row < rowLengths.length - 1 ? rowAt(position.row + 1) : position
|
|
132
|
+
case 'ArrowUp':
|
|
133
|
+
return position.row > 0 ? rowAt(position.row - 1) : position
|
|
134
|
+
case 'Home':
|
|
135
|
+
return { row: position.row, col: 0 }
|
|
136
|
+
case 'End':
|
|
137
|
+
return { row: position.row, col: rowLengths[position.row] - 1 }
|
|
138
|
+
default:
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Sets (or with null removes) the text or highlight color of the selection.
|
|
145
|
+
* A preview (`history: false`) is left out of undo, so only applied colors become undo steps.
|
|
146
|
+
*/
|
|
147
|
+
export function writeColor(editor: Editor, kind: ColorKind, value: string | null, { history = true, focus = false } = {}): void {
|
|
148
|
+
let chain = editor.chain()
|
|
149
|
+
if (focus) {
|
|
150
|
+
chain = chain.focus()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (kind === 'text') {
|
|
154
|
+
chain = value ? chain.setColor(value) : chain.unsetColor()
|
|
155
|
+
} else {
|
|
156
|
+
chain = value ? chain.setHighlight({ color: value }) : chain.unsetHighlight()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!history) {
|
|
160
|
+
chain = chain.command(({ tr }) => {
|
|
161
|
+
tr.setMeta('addToHistory', false)
|
|
162
|
+
|
|
163
|
+
return true
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
chain.run()
|
|
168
|
+
}
|
|
@@ -47,9 +47,7 @@ const selectedColor = computed(() => editor.value.getAttributes('highlight').col
|
|
|
47
47
|
/>
|
|
48
48
|
</VBtn>
|
|
49
49
|
|
|
50
|
-
<
|
|
51
|
-
<StyleColor :font-color="false" :background-color="true" :color="selectedColor" />
|
|
52
|
-
</VMenu>
|
|
50
|
+
<StyleColor kind="highlight" :activator="`#tiptapify-highlight-button-${editor.instanceId}`" :color="selectedColor" />
|
|
53
51
|
</template>
|
|
54
52
|
|
|
55
53
|
<style lang="scss" scoped>
|