tiptapify 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +15909 -15553
- package/dist/tiptapify.umd.js +55 -55
- package/index.d.ts +95 -0
- package/package.json +2 -1
- package/src/components/ImageBubbleMenu.vue +16 -7
- package/src/components/MenuBubble.vue +45 -7
- package/src/components/SkinScope.test.ts +74 -0
- package/src/components/SkinScope.vue +18 -0
- package/src/components/Tiptapify.skin.test.ts +186 -0
- package/src/components/Tiptapify.vue +115 -21
- package/src/components/Toolbar/Index.vue +43 -62
- package/src/components/Toolbar/Items.test.ts +58 -2
- package/src/components/Toolbar/Items.vue +93 -7
- package/src/components/UI/TiptapifyDialog.test.ts +132 -0
- package/src/components/UI/TiptapifyDialog.vue +90 -40
- package/src/composables/useTiptapifyConfig.test.ts +25 -2
- package/src/composables/useTiptapifyConfig.ts +21 -2
- package/src/constants/defaults.ts +2 -1
- package/src/extensions/components/alignment/menu/Button.vue +21 -17
- package/src/extensions/components/list/bullet/Button.vue +20 -16
- package/src/extensions/components/list/styles/ListStyleButton.vue +25 -25
- package/src/extensions/components/style/heading/Button.vue +33 -29
- package/src/index.test.ts +88 -0
- package/src/index.ts +15 -0
- package/src/skins/__fixtures__/skin-baseline.json +805 -0
- package/src/skins/builtins.ts +81 -0
- package/src/skins/menu.css +87 -0
- package/src/skins/menu.ts +10 -0
- package/src/skins/registry.test.ts +180 -0
- package/src/skins/registry.ts +132 -0
- package/src/skins/tokens.reachability.test.ts +43 -0
- package/src/skins/tokens.ts +92 -0
- package/src/types/editor.ts +99 -0
- package/src/utils/validateHref.test.ts +25 -0
- package/src/utils/validateHref.ts +17 -13
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { registerSkin } from './registry'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The built-in skins are registered skins, not special cases: the custom-skin
|
|
5
|
+
* path resolves through exactly the same chain (spec: anti-goals).
|
|
6
|
+
* 'default' carries the classic shape and the base tokens, so it emits none.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function registerBuiltins(): void {
|
|
10
|
+
registerSkin('default', {})
|
|
11
|
+
|
|
12
|
+
registerSkin('pastel', {
|
|
13
|
+
toolbar: 'flat',
|
|
14
|
+
groups: 'flat',
|
|
15
|
+
buttons: 'quiet',
|
|
16
|
+
bubble: 'flat',
|
|
17
|
+
dialog: 'soft',
|
|
18
|
+
menu: 'soft',
|
|
19
|
+
colors: {
|
|
20
|
+
toolbarBg: 'rgba(var(--v-theme-on-surface), 0.03)',
|
|
21
|
+
toolbarBorderBottom: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))',
|
|
22
|
+
toolbarFg: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
|
23
|
+
groupBg: 'transparent',
|
|
24
|
+
groupShadow: 'none',
|
|
25
|
+
dividerColor: 'rgba(var(--v-border-color), var(--v-border-opacity))',
|
|
26
|
+
buttonFg: 'rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))',
|
|
27
|
+
buttonHoverBg: 'rgba(var(--v-theme-on-surface), 0.07)',
|
|
28
|
+
buttonHoverFg: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
|
29
|
+
buttonActiveBg: 'rgba(var(--v-theme-on-surface), 0.1)',
|
|
30
|
+
buttonActiveHoverBg: 'rgba(var(--v-theme-on-surface), 0.14)',
|
|
31
|
+
bubbleBorder: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))',
|
|
32
|
+
bubbleShadow: '0 6px 20px rgba(0, 0, 0, 0.12)',
|
|
33
|
+
menuBorder: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))',
|
|
34
|
+
menuShadow: '0 4px 16px rgba(0, 0, 0, 0.08)',
|
|
35
|
+
menuItemActiveBg: 'rgba(var(--v-theme-on-surface), 0.06)',
|
|
36
|
+
dialogShadow: '0 20px 48px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.06)',
|
|
37
|
+
frameBg: 'rgb(var(--v-theme-surface))',
|
|
38
|
+
frameShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
|
39
|
+
imageBg: 'rgba(var(--v-theme-on-surface), 0.04)',
|
|
40
|
+
imageOutline: '2px solid rgb(var(--v-theme-primary))',
|
|
41
|
+
imageOutlineHover: 'rgba(var(--v-theme-primary), 0.35)',
|
|
42
|
+
handleBg: 'rgb(var(--v-theme-primary))',
|
|
43
|
+
handleBgHover: 'rgb(var(--v-theme-primary))',
|
|
44
|
+
handleBorder: '2px solid rgb(var(--v-theme-surface))',
|
|
45
|
+
handleShadow: '0 0 0 1px rgba(0, 0, 0, 0.15)',
|
|
46
|
+
},
|
|
47
|
+
metrics: {
|
|
48
|
+
itemsPadding: '6px 8px',
|
|
49
|
+
itemsGap: '6px',
|
|
50
|
+
itemsRowGap: '4px',
|
|
51
|
+
groupGap: '2px',
|
|
52
|
+
groupHeight: 'auto',
|
|
53
|
+
groupRadius: '0px',
|
|
54
|
+
dividerWidth: '1px',
|
|
55
|
+
dividerHeight: '20px',
|
|
56
|
+
buttonRadius: '6px',
|
|
57
|
+
bubbleRadius: '10px',
|
|
58
|
+
bubblePadding: '4px',
|
|
59
|
+
bubbleGap: '2px',
|
|
60
|
+
menuListPadding: '4px',
|
|
61
|
+
menuRadius: '8px',
|
|
62
|
+
menuItemRadius: '6px',
|
|
63
|
+
menuItemPadding: '4px 8px',
|
|
64
|
+
menuItemMinHeight: '34px',
|
|
65
|
+
dialogRadius: '12px',
|
|
66
|
+
dialogHeaderPadding: '16px 12px 12px 20px',
|
|
67
|
+
dialogContentPadding: '4px 20px',
|
|
68
|
+
dialogActionsPadding: '16px 20px 20px',
|
|
69
|
+
dialogTitleSize: '1.125rem',
|
|
70
|
+
dialogTitleWeight: '600',
|
|
71
|
+
dialogFieldRadius: '8px',
|
|
72
|
+
dialogActionBtnMinHeight: '38px',
|
|
73
|
+
dialogActionBtnRadius: '8px',
|
|
74
|
+
dialogGridGap: '16px',
|
|
75
|
+
imageRadius: '4px',
|
|
76
|
+
handleRadius: '50%',
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
registerBuiltins()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Menu skin rules (spec 2026-10-18-skins-design.md, "Menu CSS strategy").
|
|
3
|
+
* Each tiptapify menu wraps its content in SkinScope: the scope div carries
|
|
4
|
+
* the skin tokens, so the menu rules hang off the content-class element
|
|
5
|
+
* through it. The soft block rides the `tiptapify-menu--soft` carrier, which
|
|
6
|
+
* the menu shape flag drives (`shape.menu === 'soft'`); tokens paint it, the
|
|
7
|
+
* fallbacks are the soft shape's base (the 0.3.3 look). Doubled classes: the
|
|
8
|
+
* admin theme pads menu lists and items with its own !important rules (the
|
|
9
|
+
* defense of the base commit).
|
|
10
|
+
*
|
|
11
|
+
* The five remaining !importants are the admin-theme defense set, kept
|
|
12
|
+
* deliberately (spec: "no new !important beyond what exists today"):
|
|
13
|
+
* - list `padding`, item `margin`, item `padding`: the consumer admin theme
|
|
14
|
+
* pads Vuetify lists with its own !important rules (documented in the
|
|
15
|
+
* Toolbar/Index.vue comment); token-reading does not beat !important,
|
|
16
|
+
* only !important does.
|
|
17
|
+
* - spacer `width`: the admin theme sizes list spacers; the soft menu owns
|
|
18
|
+
* this pixel (icon-to-label gap).
|
|
19
|
+
* - active item `color`: the admin theme repaints active list items
|
|
20
|
+
* (`.v-list-item--active { color: ... !important }`-style rules); the
|
|
21
|
+
* soft menu's high-emphasis text is part of the skin contract.
|
|
22
|
+
* Everything else (border, radius, shadow, min-height, active background) is
|
|
23
|
+
* plain specificity: the doubled classes + scope segment (0-5-0+) outrank
|
|
24
|
+
* Vuetify's component rules and need no !important.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* Classic skin: Vuetify's own `.v-menu > .v-overlay__content > .v-list` radius/elevation
|
|
29
|
+
* is a child rule (0-3-0), and the scope div inserts itself between them, so the child
|
|
30
|
+
* combinator no longer matches. This restoration rule re-arms those two pixels for the
|
|
31
|
+
* default menu (0-4-0 beats what the content class alone gets), reading the menu tokens
|
|
32
|
+
* with the fixture-canonical classic fallbacks. Under the soft shape the block below
|
|
33
|
+
* paints the same properties from the shape's base (equal specificity, later source order).
|
|
34
|
+
* The classic menu list is therefore a skin rule today: the reachability note "classic
|
|
35
|
+
* menu metrics = native Vuetify" holds for the item pixels only.
|
|
36
|
+
*/
|
|
37
|
+
.tiptapify-menu > .tiptapify-skin-scope > .v-list.v-list {
|
|
38
|
+
border-radius: var(--tf-menu-radius, 4px);
|
|
39
|
+
box-shadow: var(--tf-menu-shadow, 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list {
|
|
43
|
+
padding: var(--tf-menu-list-padding, 4px) !important;
|
|
44
|
+
border: var(--tf-menu-border, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
|
|
45
|
+
border-radius: var(--tf-menu-radius, 8px);
|
|
46
|
+
box-shadow: var(--tf-menu-shadow, 0 4px 16px rgba(0, 0, 0, 0.08));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item {
|
|
50
|
+
min-height: var(--tf-menu-item-min-height, 34px);
|
|
51
|
+
margin: 0 !important;
|
|
52
|
+
padding: var(--tf-menu-item-padding, 4px 8px) !important;
|
|
53
|
+
border-radius: var(--tf-menu-item-radius, 6px);
|
|
54
|
+
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item .v-list-item__prepend > .v-icon {
|
|
58
|
+
margin-inline-end: 0;
|
|
59
|
+
opacity: 0.6;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item .v-list-item__spacer {
|
|
63
|
+
width: 10px !important;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item .v-list-item-title {
|
|
67
|
+
font-size: 14px;
|
|
68
|
+
font-weight: 500;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item.v-list-item--active {
|
|
72
|
+
background: var(--tf-menu-item-active-bg, rgba(var(--v-theme-on-surface), 0.06));
|
|
73
|
+
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.tiptapify-menu--soft > .tiptapify-skin-scope > .v-list.v-list > .v-list-item.v-list-item.v-list-item--active > .v-list-item__overlay {
|
|
77
|
+
opacity: 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* The list-style grid inside the soft menu; the feature layout stays in ListStyleButton.vue */
|
|
81
|
+
.tiptapify-menu--soft > .tiptapify-skin-scope > .tiptapify-list-style-grid {
|
|
82
|
+
padding: 6px;
|
|
83
|
+
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
84
|
+
border-radius: 8px;
|
|
85
|
+
background: rgb(var(--v-theme-surface));
|
|
86
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
87
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The shared `content-class` of the four tiptapify menus (heading, align,
|
|
3
|
+
* bullet, list-styles): the base class plus the soft menu carrier, which
|
|
4
|
+
* anchors the soft menu rules in src/skins/menu.css through the SkinScope
|
|
5
|
+
* wrapper. Driven by the menu shape flag (`shape.menu === 'soft'`), not the
|
|
6
|
+
* skin name: a custom skin extending pastel keeps the carrier.
|
|
7
|
+
*/
|
|
8
|
+
export function menuContentClass(menuSoft: boolean): string {
|
|
9
|
+
return menuSoft ? 'tiptapify-menu tiptapify-menu--soft' : 'tiptapify-menu'
|
|
10
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { registerSkin, defineSkin, resolveSkin, resetSkins, registeredSkins, CLASSIC_SHAPE } from '@tiptapify/skins/registry'
|
|
3
|
+
import { baseColors, baseMetrics, skinColorKeys, skinMetricKeys, cssVarName } from '@tiptapify/skins/tokens'
|
|
4
|
+
import { registerBuiltins } from '@tiptapify/skins/builtins'
|
|
5
|
+
|
|
6
|
+
describe('skin registry', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
resetSkins()
|
|
9
|
+
registerBuiltins()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.restoreAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('resolves the default skin to the classic shape and emits no tokens', () => {
|
|
17
|
+
const skin = resolveSkin('default')
|
|
18
|
+
|
|
19
|
+
expect(skin.name).toBe('default')
|
|
20
|
+
expect(skin.className).toBe('tiptapify-skin-default')
|
|
21
|
+
expect(skin.shape).toEqual(CLASSIC_SHAPE)
|
|
22
|
+
expect(skin.tokens).toEqual({})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('resolves the pastel skin to the quiet shape with its tokens', () => {
|
|
26
|
+
const skin = resolveSkin('pastel')
|
|
27
|
+
|
|
28
|
+
expect(skin.shape).toEqual({
|
|
29
|
+
toolbar: 'flat', groups: 'flat', buttons: 'quiet',
|
|
30
|
+
bubble: 'flat', dialog: 'soft', menu: 'soft',
|
|
31
|
+
})
|
|
32
|
+
expect(skin.tokens['--tf-toolbar-bg']).toBe('rgba(var(--v-theme-on-surface), 0.03)')
|
|
33
|
+
expect(skin.tokens['--tf-button-radius']).toBe('6px')
|
|
34
|
+
expect(skin.tokens['--tf-dialog-grid-gap']).toBe('16px')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('falls back to default for an unregistered name with a dev warning', () => {
|
|
38
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
39
|
+
|
|
40
|
+
const skin = resolveSkin('nope')
|
|
41
|
+
|
|
42
|
+
expect(skin.name).toBe('default')
|
|
43
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('nope'))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('merges an inline skin over the default base', () => {
|
|
47
|
+
const skin = resolveSkin({ colors: { toolbarBg: 'rebeccapurple' }, metrics: { groupRadius: '10px' } })
|
|
48
|
+
|
|
49
|
+
expect(skin.className).toBe('tiptapify-skin-inline')
|
|
50
|
+
expect(skin.tokens['--tf-toolbar-bg']).toBe('rebeccapurple')
|
|
51
|
+
expect(skin.tokens['--tf-group-radius']).toBe('10px')
|
|
52
|
+
expect(skin.shape).toEqual(CLASSIC_SHAPE)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('drops an override that equals the base value (emit-skip-equal-to-base)', () => {
|
|
56
|
+
// a skin that re-states the classic values is a twin of default: nothing to emit
|
|
57
|
+
registerSkin('twin', {
|
|
58
|
+
colors: { toolbarBg: baseColors.toolbarBg, handleBg: baseColors.handleBg },
|
|
59
|
+
metrics: { itemsGap: baseMetrics.itemsGap, buttonRadius: '9px' },
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const skin = resolveSkin('twin')
|
|
63
|
+
|
|
64
|
+
// the base-valued keys are excluded; the one real override rides through
|
|
65
|
+
expect(skin.tokens).toEqual({ '--tf-button-radius': '9px' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('inherits shape and tokens through extends, own members win', () => {
|
|
69
|
+
registerSkin('mint', {
|
|
70
|
+
extends: 'pastel',
|
|
71
|
+
colors: { toolbarBg: 'rgba(0, 128, 0, 0.05)', handleBg: 'rgb(0, 100, 0)' },
|
|
72
|
+
metrics: { dialogRadius: '14px' },
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const skin = resolveSkin('mint')
|
|
76
|
+
|
|
77
|
+
expect(skin.shape).toEqual(resolveSkin('pastel').shape)
|
|
78
|
+
expect(skin.tokens['--tf-toolbar-bg']).toBe('rgba(0, 128, 0, 0.05)')
|
|
79
|
+
expect(skin.tokens['--tf-handle-bg']).toBe('rgb(0, 100, 0)')
|
|
80
|
+
expect(skin.tokens['--tf-dialog-radius']).toBe('14px')
|
|
81
|
+
// untouched pastel tokens are inherited
|
|
82
|
+
expect(skin.tokens['--tf-button-radius']).toBe('6px')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('an inline object may extend a registered skin', () => {
|
|
86
|
+
const skin = resolveSkin({ extends: 'pastel', metrics: { bubbleRadius: '2px' } })
|
|
87
|
+
|
|
88
|
+
expect(skin.className).toBe('tiptapify-skin-inline')
|
|
89
|
+
expect(skin.shape.bubble).toBe('flat')
|
|
90
|
+
expect(skin.tokens['--tf-bubble-radius']).toBe('2px')
|
|
91
|
+
expect(skin.tokens['--tf-bubble-padding']).toBe('4px')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('ignores unknown token keys in dev with a warning', () => {
|
|
95
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
96
|
+
|
|
97
|
+
const skin = resolveSkin({ colors: { notAToken: 'red' } as never })
|
|
98
|
+
|
|
99
|
+
expect(skin.tokens['--tf-not-a-token']).toBeUndefined()
|
|
100
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('notAToken'))
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('resolves a missing extends to default with a dev warning', () => {
|
|
104
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
105
|
+
|
|
106
|
+
const skin = resolveSkin({ extends: 'ghost' })
|
|
107
|
+
|
|
108
|
+
expect(skin.shape).toEqual(CLASSIC_SHAPE)
|
|
109
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('ghost'))
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('resolves a circular extends chain to default with a dev warning', () => {
|
|
113
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
114
|
+
registerSkin('a', { extends: 'b' })
|
|
115
|
+
registerSkin('b', { extends: 'a' })
|
|
116
|
+
|
|
117
|
+
const skin = resolveSkin('a')
|
|
118
|
+
|
|
119
|
+
expect(skin.name).toBe('a')
|
|
120
|
+
expect(skin.shape).toEqual(CLASSIC_SHAPE)
|
|
121
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('a'))
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('inline skins never enter the global registry', () => {
|
|
125
|
+
resolveSkin({ name: 'mint', colors: { toolbarBg: 'green' } })
|
|
126
|
+
|
|
127
|
+
expect(registeredSkins()).not.toContain('mint')
|
|
128
|
+
const registered = registerSkin('mint', { colors: { toolbarBg: 'lime' } })
|
|
129
|
+
expect(registered).toBeUndefined()
|
|
130
|
+
expect(registeredSkins()).toContain('mint')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('defineSkin is identity typed', () => {
|
|
134
|
+
const cfg = defineSkin({ extends: 'pastel', metrics: { itemsPadding: '1px 2px' } })
|
|
135
|
+
|
|
136
|
+
expect(cfg).toEqual({ extends: 'pastel', metrics: { itemsPadding: '1px 2px' } })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('emits css variables only for keys present in the typed catalogs', () => {
|
|
140
|
+
expect(cssVarName('toolbarBg')).toBe('--tf-toolbar-bg')
|
|
141
|
+
expect(cssVarName('itemsRowGap')).toBe('--tf-items-row-gap')
|
|
142
|
+
expect(skinColorKeys.length).toBeGreaterThan(10)
|
|
143
|
+
expect(skinMetricKeys.length).toBeGreaterThan(10)
|
|
144
|
+
// every catalog key has a base value
|
|
145
|
+
for (const key of skinColorKeys) expect(typeof baseColors[key]).toBe('string')
|
|
146
|
+
for (const key of skinMetricKeys) expect(typeof baseMetrics[key]).toBe('string')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('skin registry without builtins', () => {
|
|
151
|
+
// The registry used standalone (no builtins.ts load): 'default' is the implicit
|
|
152
|
+
// root of every extends chain, so walking to it must not warn about a missing link
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
resetSkins()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
afterEach(() => {
|
|
158
|
+
vi.restoreAllMocks()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('treats the implicit default root as present, no dev warning', () => {
|
|
162
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
163
|
+
|
|
164
|
+
const skin = resolveSkin({ extends: 'default', colors: { toolbarBg: 'red' } })
|
|
165
|
+
const inline = resolveSkin({ colors: { toolbarBg: 'blue' } })
|
|
166
|
+
|
|
167
|
+
expect(skin.shape).toEqual(CLASSIC_SHAPE)
|
|
168
|
+
expect(skin.tokens['--tf-toolbar-bg']).toBe('red')
|
|
169
|
+
expect(inline.tokens['--tf-toolbar-bg']).toBe('blue')
|
|
170
|
+
expect(warn).not.toHaveBeenCalled()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('still warns for a genuinely missing extends link', () => {
|
|
174
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
175
|
+
|
|
176
|
+
resolveSkin({ extends: 'ghost' })
|
|
177
|
+
|
|
178
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('ghost'))
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { TiptapifyShape, TiptapifySkinConfig, TiptapifySkinInput } from '@tiptapify/types/editor'
|
|
2
|
+
import { baseColors, baseMetrics, skinColorKeys, skinMetricKeys, cssVarName } from './tokens'
|
|
3
|
+
|
|
4
|
+
export type ResolvedSkin = {
|
|
5
|
+
name: string
|
|
6
|
+
// root class for the escape hatch: `tiptapify-skin-{name}`
|
|
7
|
+
className: string
|
|
8
|
+
shape: Required<TiptapifyShape>
|
|
9
|
+
// css variable name -> value; only overrides, base values are the css fallbacks
|
|
10
|
+
tokens: Record<string, string>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The classic structure: every skin resolves over it (base tokens → extends chain → own)
|
|
14
|
+
export const CLASSIC_SHAPE: Required<TiptapifyShape> = {
|
|
15
|
+
toolbar: 'raised',
|
|
16
|
+
groups: 'box',
|
|
17
|
+
buttons: 'vuetify',
|
|
18
|
+
bubble: 'card',
|
|
19
|
+
dialog: 'classic',
|
|
20
|
+
menu: 'vuetify',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const registry = new Map<string, TiptapifySkinConfig>()
|
|
24
|
+
|
|
25
|
+
export function registerSkin(name: string, skin: TiptapifySkinConfig): void {
|
|
26
|
+
registry.set(name, { ...skin, name })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Identity for typed const definitions; the object never enters the registry
|
|
30
|
+
export function defineSkin(skin: TiptapifySkinConfig): TiptapifySkinConfig {
|
|
31
|
+
return skin
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function registeredSkins(): string[] {
|
|
35
|
+
return [...registry.keys()]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Dev/test isolation: drop all registrations
|
|
39
|
+
export function resetSkins(): void {
|
|
40
|
+
registry.clear()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const devWarn = (msg: string) => {
|
|
44
|
+
if (import.meta.env.DEV) console.warn(`[tiptapify] ${msg}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isConfig = (skin: TiptapifySkinInput): skin is TiptapifySkinConfig => typeof skin === 'object' && skin !== null
|
|
48
|
+
|
|
49
|
+
const SHAPE_KEYS = ['toolbar', 'groups', 'buttons', 'bubble', 'dialog', 'menu'] as const
|
|
50
|
+
|
|
51
|
+
// Walk the extends chain root-first: [default, base…, self]. A missing link
|
|
52
|
+
// resolves to the accumulated chain (warn); a cycle resolves to classic (warn).
|
|
53
|
+
// 'default' is the implicit root of every chain: a bare registry without the
|
|
54
|
+
// builtins must not warn about walking to it.
|
|
55
|
+
function chainOf(config: TiptapifySkinConfig): { chain: TiptapifySkinConfig[], cyclic: boolean } {
|
|
56
|
+
const chain: TiptapifySkinConfig[] = []
|
|
57
|
+
const visited = new Set<string>()
|
|
58
|
+
let link: string | undefined = config.extends ?? 'default'
|
|
59
|
+
while (link) {
|
|
60
|
+
if (visited.has(link)) return { chain, cyclic: true }
|
|
61
|
+
visited.add(link)
|
|
62
|
+
const registered = registry.get(link)
|
|
63
|
+
if (!registered) {
|
|
64
|
+
if (link !== 'default') devWarn(`skin extends "${link}" is not registered; resolving without it`)
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
chain.unshift(registered)
|
|
68
|
+
link = registered.extends ?? 'default'
|
|
69
|
+
if (link === 'default' && registered.name === 'default') break
|
|
70
|
+
}
|
|
71
|
+
return { chain, cyclic: false }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveSkin(skin?: TiptapifySkinInput): ResolvedSkin {
|
|
75
|
+
let config: TiptapifySkinConfig
|
|
76
|
+
let name: string
|
|
77
|
+
|
|
78
|
+
if (skin === undefined || skin === null || skin === '') {
|
|
79
|
+
config = {}
|
|
80
|
+
name = 'default'
|
|
81
|
+
} else if (typeof skin === 'string') {
|
|
82
|
+
const registered = registry.get(skin)
|
|
83
|
+
if (!registered) {
|
|
84
|
+
devWarn(`unknown skin "${skin}"; falling back to "default"`)
|
|
85
|
+
config = {}
|
|
86
|
+
name = 'default'
|
|
87
|
+
} else {
|
|
88
|
+
config = registered
|
|
89
|
+
name = skin
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
config = skin
|
|
93
|
+
name = config.name ?? 'inline'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { chain, cyclic } = chainOf(config)
|
|
97
|
+
if (cyclic) devWarn(`circular "extends" in skin "${name}"; falling back to classic`)
|
|
98
|
+
|
|
99
|
+
// base: classic shape + classic tokens (not emitted — they are the css fallbacks)
|
|
100
|
+
const shape: Required<TiptapifyShape> = { ...CLASSIC_SHAPE }
|
|
101
|
+
const colors: Record<string, string> = {}
|
|
102
|
+
const metrics: Record<string, string> = {}
|
|
103
|
+
const sources = cyclic ? [] : [...chain, config]
|
|
104
|
+
for (const source of sources) {
|
|
105
|
+
for (const key of SHAPE_KEYS) {
|
|
106
|
+
const value = source[key]
|
|
107
|
+
if (value) shape[key] = value as never
|
|
108
|
+
}
|
|
109
|
+
for (const [key, value] of Object.entries(source.colors ?? {})) {
|
|
110
|
+
if (!skinColorKeys.includes(key as never)) {
|
|
111
|
+
devWarn(`unknown skin color token "${key}" on skin "${name}"; ignored`)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (value !== undefined) colors[key] = value
|
|
115
|
+
}
|
|
116
|
+
for (const [key, value] of Object.entries(source.metrics ?? {})) {
|
|
117
|
+
if (!skinMetricKeys.includes(key as never)) {
|
|
118
|
+
devWarn(`unknown skin metric token "${key}" on skin "${name}"; ignored`)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (value !== undefined) metrics[key] = value
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const tokens: Record<string, string> = {}
|
|
126
|
+
for (const [key, value] of Object.entries({ ...colors, ...metrics })) {
|
|
127
|
+
const base = (baseColors as Record<string, string>)[key] ?? (baseMetrics as Record<string, string>)[key]
|
|
128
|
+
if (value !== base) tokens[cssVarName(key)] = value
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { name, className: `tiptapify-skin-${name}`, shape, tokens }
|
|
132
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { skinColorKeys, skinMetricKeys, cssVarName } from '@tiptapify/skins/tokens'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Reachability contract guard (spec 2026-10-18-skins-design.md, task 7): every catalog
|
|
7
|
+
* token must have at least one CSS consumer in the skin layer. Gating is per shape:
|
|
8
|
+
* quiet-button tokens are consumed under the quiet gates, menu-soft tokens under the
|
|
9
|
+
* `--soft` menu rules, dialog-soft tokens under the `--soft` dialog rules, and the
|
|
10
|
+
* default-path tokens in the base rules — this guard checks consumption, the fixture
|
|
11
|
+
* gate (skin-baseline.json) checks the painted result.
|
|
12
|
+
*
|
|
13
|
+
* Parsing the SOURCE files (not dist): the source is where a token can be dropped;
|
|
14
|
+
* dist adds noise (bundling, minification) and is produced by the build anyway.
|
|
15
|
+
*/
|
|
16
|
+
const SOURCES = [
|
|
17
|
+
'../components/Tiptapify.vue',
|
|
18
|
+
'../components/Toolbar/Index.vue',
|
|
19
|
+
'../components/Toolbar/Items.vue',
|
|
20
|
+
'../components/MenuBubble.vue',
|
|
21
|
+
'../components/ImageBubbleMenu.vue',
|
|
22
|
+
'../components/UI/TiptapifyDialog.vue',
|
|
23
|
+
'./menu.css',
|
|
24
|
+
'../extensions/components/list/styles/ListStyleButton.vue',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const css = SOURCES.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n')
|
|
28
|
+
|
|
29
|
+
describe('skin token reachability', () => {
|
|
30
|
+
for (const key of [...skinColorKeys, ...skinMetricKeys]) {
|
|
31
|
+
it(`consumes ${cssVarName(key)} (from ${key})`, () => {
|
|
32
|
+
expect(css).toContain(`var(${cssVarName(key)}`)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it('has no library rule keyed on a built-in skin name (escape hatch has no consumers)', () => {
|
|
37
|
+
// `.tiptapify-skin-{name}` is the documented escape hatch for consumer CSS; the
|
|
38
|
+
// library itself gates on shape classes and tokens only (spec: Layered model).
|
|
39
|
+
const rule = css.match(/\.tiptapify-skin-(pastel|default)\b[^{]*\{/g)
|
|
40
|
+
|
|
41
|
+
expect(rule).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { TiptapifySkinColors, TiptapifySkinMetrics } from '@tiptapify/types/editor'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The typed token catalog (public contract; spec 2026-10-18-skins-design.md).
|
|
5
|
+
* Every skin rule consumes its tokens as CSS custom properties with a fallback
|
|
6
|
+
* equal to the base (classic) value: the values below. Cells of the spec table
|
|
7
|
+
* reading "Vuetify" / "VCard elevation" are filled by the committed baseline
|
|
8
|
+
* fixture (src/skins/__fixtures__/skin-baseline.json) — its measured value
|
|
9
|
+
* at that selector is canonical and is the fallback stored here.
|
|
10
|
+
*
|
|
11
|
+
* Key naming: the camelCase key is the CSS suffix — `groupShadow` ↔
|
|
12
|
+
* `--tf-group-shadow`. Composite values split by nature: `--tf-items-gap`
|
|
13
|
+
* expands to `itemsGap` (column) + `itemsRowGap` (the classic row gap).
|
|
14
|
+
* Colors are paint (including full border shorthands and shadows); lengths,
|
|
15
|
+
* paddings, gaps, radii, min-heights are metrics.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Base values = the classic look. Button tokens paint only when the shape
|
|
19
|
+
// activates the quiet buttons; the classic values below are the native vuetify
|
|
20
|
+
// rendering the fixture measures (reachability contract: inert under
|
|
21
|
+
// buttons:'vuetify'). The quiet/pastel values ship with the pastel skin.
|
|
22
|
+
export const baseColors: TiptapifySkinColors = {
|
|
23
|
+
toolbarBg: 'rgb(var(--v-theme-surface))',
|
|
24
|
+
toolbarBorderBottom: 'none',
|
|
25
|
+
toolbarFg: 'inherit',
|
|
26
|
+
groupBg: 'rgb(var(--v-theme-surface))',
|
|
27
|
+
groupShadow: '0 3px 1px -2px var(--v-shadow-key-umbra-opacity, rgba(0, 0, 0, 0.2)), 0 2px 2px 0 var(--v-shadow-key-penumbra-opacity, rgba(0, 0, 0, 0.14)), 0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12))',
|
|
28
|
+
dividerColor: 'transparent',
|
|
29
|
+
buttonFg: 'inherit',
|
|
30
|
+
buttonHoverBg: 'rgba(var(--v-theme-on-surface), var(--v-hover-opacity))',
|
|
31
|
+
buttonHoverFg: 'inherit',
|
|
32
|
+
buttonActiveBg: 'rgb(var(--v-theme-primary))',
|
|
33
|
+
buttonActiveHoverBg: 'rgb(var(--v-theme-primary))',
|
|
34
|
+
bubbleBg: 'rgb(var(--v-theme-surface))',
|
|
35
|
+
bubbleBorder: 'none',
|
|
36
|
+
bubbleShadow: '4px 4px 20px var(--dark-gray)',
|
|
37
|
+
menuBorder: 'none',
|
|
38
|
+
menuShadow: '0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12)',
|
|
39
|
+
menuItemActiveBg: 'rgba(0, 0, 0, 0)',
|
|
40
|
+
dialogShadow: '0 11px 15px -7px rgba(0, 0, 0, 0.2), 0 24px 38px 3px rgba(0, 0, 0, 0.14), 0 9px 46px 8px rgba(0, 0, 0, 0.12)',
|
|
41
|
+
frameBg: 'transparent',
|
|
42
|
+
frameShadow: 'none',
|
|
43
|
+
// the classic frame: a hairline around the editor, the toolbar edge on top of it (border-top removed by the rule)
|
|
44
|
+
frameBorder: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))',
|
|
45
|
+
imageBg: 'transparent',
|
|
46
|
+
imageOutline: '1px solid rgba(0, 0, 0, 0.25)',
|
|
47
|
+
imageOutlineHover: 'transparent',
|
|
48
|
+
handleBg: 'rgba(0, 0, 0, 0.5)',
|
|
49
|
+
handleBgHover: 'rgba(0, 0, 0, 0.8)',
|
|
50
|
+
handleBorder: 'none',
|
|
51
|
+
handleShadow: 'none',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const baseMetrics: TiptapifySkinMetrics = {
|
|
55
|
+
itemsPadding: '12px 16px',
|
|
56
|
+
itemsGap: '0px',
|
|
57
|
+
itemsRowGap: '5px',
|
|
58
|
+
groupGap: '0px',
|
|
59
|
+
groupHeight: '32px',
|
|
60
|
+
groupRadius: '4px',
|
|
61
|
+
dividerWidth: '0px',
|
|
62
|
+
dividerHeight: '0px',
|
|
63
|
+
buttonRadius: '4px',
|
|
64
|
+
bubbleRadius: '6px',
|
|
65
|
+
bubblePadding: '0px',
|
|
66
|
+
bubbleGap: '0px',
|
|
67
|
+
menuListPadding: '8px 0px',
|
|
68
|
+
menuRadius: '4px',
|
|
69
|
+
menuItemRadius: '0px',
|
|
70
|
+
menuItemPadding: '4px 16px',
|
|
71
|
+
menuItemMinHeight: '40px',
|
|
72
|
+
dialogRadius: '4px',
|
|
73
|
+
dialogHeaderPadding: '8px 16px',
|
|
74
|
+
dialogContentPadding: '16px 24px 24px',
|
|
75
|
+
dialogActionsPadding: '8px',
|
|
76
|
+
dialogTitleSize: '1.25rem',
|
|
77
|
+
dialogTitleWeight: '400',
|
|
78
|
+
dialogFieldRadius: '4px',
|
|
79
|
+
dialogActionBtnMinHeight: 'auto',
|
|
80
|
+
dialogActionBtnRadius: '4px',
|
|
81
|
+
dialogGridGap: '24px',
|
|
82
|
+
imageRadius: '0.125rem',
|
|
83
|
+
handleRadius: '2px',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const skinColorKeys = Object.keys(baseColors) as (keyof TiptapifySkinColors)[]
|
|
87
|
+
export const skinMetricKeys = Object.keys(baseMetrics) as (keyof TiptapifySkinMetrics)[]
|
|
88
|
+
|
|
89
|
+
// 'toolbarBg' -> '--tf-toolbar-bg', 'itemsRowGap' -> '--tf-items-row-gap'
|
|
90
|
+
export function cssVarName(key: string): string {
|
|
91
|
+
return '--tf-' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())
|
|
92
|
+
}
|