slidev-theme-practicum 0.1.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.
- package/README.md +270 -0
- package/components/DebugGrid.vue +50 -0
- package/components/DecorLibrary.vue +1117 -0
- package/components/Header.vue +124 -0
- package/components/Image.vue +82 -0
- package/components/ImageRenderer.vue +126 -0
- package/components/Logo.vue +142 -0
- package/components/Person.vue +89 -0
- package/components/Slide.vue +324 -0
- package/components/Slot.vue +419 -0
- package/components/Text.vue +216 -0
- package/components/Timeline.vue +202 -0
- package/composables/decor-catalog.mjs +339 -0
- package/composables/decor-config.mjs +488 -0
- package/composables/decor-file-store.ts +192 -0
- package/composables/decor-http-store.ts +25 -0
- package/composables/decor-store.ts +39 -0
- package/composables/decor-tuning-overrides.mjs +2 -0
- package/composables/decor-workbench.ts +799 -0
- package/composables/grid-placement.ts +396 -0
- package/composables/image-config.mjs +484 -0
- package/composables/layout-authoring.ts +372 -0
- package/composables/layout-markdown.mjs +56 -0
- package/composables/layout-recipes.ts +347 -0
- package/composables/layout-registry.ts +47 -0
- package/composables/layout-shorthands.ts +891 -0
- package/composables/layout-vnode.ts +43 -0
- package/composables/logo-mark.mjs +5 -0
- package/composables/slide-layout.ts +43 -0
- package/composables/slot-placement.ts +195 -0
- package/composables/slot-rules.ts +147 -0
- package/composables/text-fit-runtime.ts +577 -0
- package/composables/text-priority-fit.mjs +166 -0
- package/composables/text-typography.mjs +138 -0
- package/composables/theme-foundation.ts +192 -0
- package/composables/theme-media.mjs +202 -0
- package/composables/use-theme-config.ts +71 -0
- package/decor-library.md +10 -0
- package/env.d.ts +6 -0
- package/example.md +786 -0
- package/layouts/collection.vue +5 -0
- package/layouts/cover.vue +5 -0
- package/layouts/default.vue +5 -0
- package/layouts/explainer.vue +5 -0
- package/layouts/message.vue +5 -0
- package/layouts/none.vue +5 -0
- package/layouts/section.vue +5 -0
- package/layouts/two-cols.vue +11 -0
- package/package.json +84 -0
- package/public/decor/decor-1.svg +83 -0
- package/public/decor/decor-10.svg +40 -0
- package/public/decor/decor-11.svg +162 -0
- package/public/decor/decor-12.svg +83 -0
- package/public/decor/decor-13.svg +5 -0
- package/public/decor/decor-14.svg +7 -0
- package/public/decor/decor-15.svg +10 -0
- package/public/decor/decor-16.svg +9 -0
- package/public/decor/decor-17.png +0 -0
- package/public/decor/decor-18.png +0 -0
- package/public/decor/decor-19.svg +5 -0
- package/public/decor/decor-2.png +0 -0
- package/public/decor/decor-3.svg +9 -0
- package/public/decor/decor-4.svg +31 -0
- package/public/decor/decor-5.png +0 -0
- package/public/decor/decor-6.png +0 -0
- package/public/decor/decor-7.png +0 -0
- package/public/decor/decor-8.svg +21 -0
- package/public/decor/decor-9.svg +38 -0
- package/public/favicon.svg +11 -0
- package/public/photos/photo-1.png +0 -0
- package/public/photos/photo-10.png +0 -0
- package/public/photos/photo-2.png +0 -0
- package/public/photos/photo-3.png +0 -0
- package/public/photos/photo-4.png +0 -0
- package/public/photos/photo-5.png +0 -0
- package/public/photos/photo-6.png +0 -0
- package/public/photos/photo-7.png +0 -0
- package/public/photos/photo-8.png +0 -0
- package/public/photos/photo-9.png +0 -0
- package/setup/main.ts +3 -0
- package/setup/shiki.ts +10 -0
- package/setup/vite-plugins.ts +106 -0
- package/slidev-theme-practicum.png +0 -0
- package/slidev-theme-practicum.svg +102 -0
- package/styles/example.css +0 -0
- package/styles/index.css +192 -0
- package/styles/vars.css +119 -0
- package/types/slidev-client.d.ts +15 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const TEXT_PRIORITY_MIN = 1
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{
|
|
5
|
+
* minSize: number
|
|
6
|
+
* maxSize: number
|
|
7
|
+
* priority?: number
|
|
8
|
+
* }} PriorityTextSizeItemInput
|
|
9
|
+
* @typedef {{
|
|
10
|
+
* minSize: number
|
|
11
|
+
* maxSize: number
|
|
12
|
+
* priority: number
|
|
13
|
+
* }} PriorityTextSizeItem
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} message
|
|
18
|
+
*/
|
|
19
|
+
function failTextPriorityFit(message) {
|
|
20
|
+
throw new Error(`[Text] ${message}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {number[]} left
|
|
25
|
+
* @param {number[]} right
|
|
26
|
+
* @param {PriorityTextSizeItem[]} items
|
|
27
|
+
*/
|
|
28
|
+
function compareCandidatesByPriority(left, right, items) {
|
|
29
|
+
const order = items
|
|
30
|
+
.map((item, index) => ({ index, priority: item.priority }))
|
|
31
|
+
.sort((leftItem, rightItem) => leftItem.priority - rightItem.priority || leftItem.index - rightItem.index)
|
|
32
|
+
|
|
33
|
+
for (const { index } of order) {
|
|
34
|
+
if (left[index] !== right[index])
|
|
35
|
+
return right[index] - left[index]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return right.reduce((sum, size) => sum + size, 0) - left.reduce((sum, size) => sum + size, 0)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {number[]} candidate
|
|
43
|
+
* @param {PriorityTextSizeItem[]} items
|
|
44
|
+
*/
|
|
45
|
+
function satisfiesPriority(candidate, items) {
|
|
46
|
+
for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
|
|
47
|
+
for (let rightIndex = 0; rightIndex < items.length; rightIndex += 1) {
|
|
48
|
+
if (leftIndex === rightIndex)
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
const priorityGap = items[rightIndex].priority - items[leftIndex].priority
|
|
52
|
+
if (priorityGap <= 0)
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
if (candidate[leftIndex] - candidate[rightIndex] < priorityGap)
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {number[]} candidate
|
|
65
|
+
* @param {PriorityTextSizeItem[]} items
|
|
66
|
+
*/
|
|
67
|
+
function satisfiesAssignedPriority(candidate, items) {
|
|
68
|
+
for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
|
|
69
|
+
if (!Number.isInteger(candidate[leftIndex]))
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
for (let rightIndex = 0; rightIndex < items.length; rightIndex += 1) {
|
|
73
|
+
if (leftIndex === rightIndex || !Number.isInteger(candidate[rightIndex]))
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
const priorityGap = items[rightIndex].priority - items[leftIndex].priority
|
|
77
|
+
if (priorityGap <= 0)
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
if (candidate[leftIndex] - candidate[rightIndex] < priorityGap)
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {PriorityTextSizeItemInput[]} items
|
|
90
|
+
* @returns {PriorityTextSizeItem[]}
|
|
91
|
+
*/
|
|
92
|
+
function normalizePriorityFitItems(items) {
|
|
93
|
+
return items.map((item, index) => {
|
|
94
|
+
const minSize = Number(item.minSize),
|
|
95
|
+
maxSize = Number(item.maxSize),
|
|
96
|
+
priority = Number(item.priority ?? TEXT_PRIORITY_MIN)
|
|
97
|
+
|
|
98
|
+
if (!Number.isInteger(minSize) || !Number.isInteger(maxSize) || minSize > maxSize)
|
|
99
|
+
failTextPriorityFit(`item ${index + 1} has invalid size range.`)
|
|
100
|
+
|
|
101
|
+
if (!Number.isInteger(priority) || priority < TEXT_PRIORITY_MIN)
|
|
102
|
+
failTextPriorityFit(`item ${index + 1} has invalid priority.`)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
minSize,
|
|
106
|
+
maxSize,
|
|
107
|
+
priority,
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {PriorityTextSizeItem[]} normalizedItems
|
|
114
|
+
* @returns {Generator<number[]>}
|
|
115
|
+
*/
|
|
116
|
+
function createPriorityTextSizeCandidatesFromNormalized(normalizedItems) {
|
|
117
|
+
const order = normalizedItems
|
|
118
|
+
.map((item, index) => ({ item, index }))
|
|
119
|
+
.sort((left, right) => left.item.priority - right.item.priority || left.index - right.index)
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {number} orderIndex
|
|
123
|
+
* @param {number[]} candidate
|
|
124
|
+
* @returns {Generator<number[]>}
|
|
125
|
+
*/
|
|
126
|
+
function* visit(orderIndex, candidate) {
|
|
127
|
+
if (orderIndex === order.length) {
|
|
128
|
+
yield candidate.slice()
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { item, index } = order[orderIndex]
|
|
133
|
+
for (let size = item.maxSize; size >= item.minSize; size -= 1) {
|
|
134
|
+
candidate[index] = size
|
|
135
|
+
if (satisfiesAssignedPriority(candidate, normalizedItems))
|
|
136
|
+
yield* visit(orderIndex + 1, candidate)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return visit(0, [])
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {PriorityTextSizeItemInput[]} items
|
|
145
|
+
* @returns {Generator<number[]>}
|
|
146
|
+
*/
|
|
147
|
+
export function createPriorityTextSizeCandidates(items) {
|
|
148
|
+
return createPriorityTextSizeCandidatesFromNormalized(normalizePriorityFitItems(items))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {PriorityTextSizeItemInput[]} items
|
|
153
|
+
* @returns {number[][]}
|
|
154
|
+
*/
|
|
155
|
+
export function resolvePriorityTextSizeCandidates(items) {
|
|
156
|
+
const normalizedItems = normalizePriorityFitItems(items)
|
|
157
|
+
const candidates = [...createPriorityTextSizeCandidatesFromNormalized(normalizedItems)]
|
|
158
|
+
|
|
159
|
+
if (!candidates.length)
|
|
160
|
+
failTextPriorityFit('size ranges cannot satisfy priority hierarchy.')
|
|
161
|
+
|
|
162
|
+
if (!candidates.every(candidate => satisfiesPriority(candidate, normalizedItems)))
|
|
163
|
+
failTextPriorityFit('size ranges cannot satisfy priority hierarchy.')
|
|
164
|
+
|
|
165
|
+
return candidates.sort((left, right) => compareCandidatesByPriority(left, right, normalizedItems))
|
|
166
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const NON_BREAKING_SPACE = '\u00a0'
|
|
2
|
+
const RUSSIAN_SPACED_HYPHEN = /(\p{sc=Cyrillic}) - (?=\p{sc=Cyrillic})/gu
|
|
3
|
+
const TEXT_NODE_FILTER = 4
|
|
4
|
+
const FILTER_ACCEPT = 1
|
|
5
|
+
const FILTER_REJECT = 2
|
|
6
|
+
const TYPOGRAPHY_SKIP_SELECTOR = [
|
|
7
|
+
'script',
|
|
8
|
+
'style',
|
|
9
|
+
'pre',
|
|
10
|
+
'code',
|
|
11
|
+
'kbd',
|
|
12
|
+
'samp',
|
|
13
|
+
'textarea',
|
|
14
|
+
'.shiki',
|
|
15
|
+
'.katex',
|
|
16
|
+
'.mermaid',
|
|
17
|
+
'[data-no-typography]',
|
|
18
|
+
'[data-typography="off"]',
|
|
19
|
+
].join(',')
|
|
20
|
+
|
|
21
|
+
const RUSSIAN_SERVICE_WORDS = [
|
|
22
|
+
'благодаря',
|
|
23
|
+
'вместо',
|
|
24
|
+
'вокруг',
|
|
25
|
+
'вдоль',
|
|
26
|
+
'между',
|
|
27
|
+
'около',
|
|
28
|
+
'перед',
|
|
29
|
+
'после',
|
|
30
|
+
'прежде',
|
|
31
|
+
'против',
|
|
32
|
+
'сквозь',
|
|
33
|
+
'спустя',
|
|
34
|
+
'среди',
|
|
35
|
+
'через',
|
|
36
|
+
'возле',
|
|
37
|
+
'из-за',
|
|
38
|
+
'из-под',
|
|
39
|
+
'кроме',
|
|
40
|
+
'мимо',
|
|
41
|
+
'ради',
|
|
42
|
+
'или',
|
|
43
|
+
'без',
|
|
44
|
+
'для',
|
|
45
|
+
'над',
|
|
46
|
+
'под',
|
|
47
|
+
'при',
|
|
48
|
+
'про',
|
|
49
|
+
'как',
|
|
50
|
+
'обо',
|
|
51
|
+
'во',
|
|
52
|
+
'до',
|
|
53
|
+
'за',
|
|
54
|
+
'из',
|
|
55
|
+
'ко',
|
|
56
|
+
'на',
|
|
57
|
+
'не',
|
|
58
|
+
'ни',
|
|
59
|
+
'но',
|
|
60
|
+
'об',
|
|
61
|
+
'от',
|
|
62
|
+
'по',
|
|
63
|
+
'со',
|
|
64
|
+
'а',
|
|
65
|
+
'в',
|
|
66
|
+
'да',
|
|
67
|
+
'и',
|
|
68
|
+
'к',
|
|
69
|
+
'ли',
|
|
70
|
+
'о',
|
|
71
|
+
'с',
|
|
72
|
+
'у',
|
|
73
|
+
]
|
|
74
|
+
const RUSSIAN_COMPACT_SERVICE_WORDS = RUSSIAN_SERVICE_WORDS.filter(word => word.length <= 2)
|
|
75
|
+
const RUSSIAN_SERVICE_WORD_WITH_SPACE = createServiceWordPattern(RUSSIAN_SERVICE_WORDS)
|
|
76
|
+
const RUSSIAN_COMPACT_SERVICE_WORD_WITH_SPACE = createServiceWordPattern(RUSSIAN_COMPACT_SERVICE_WORDS)
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string[]} words
|
|
80
|
+
*/
|
|
81
|
+
function createServiceWordPattern(words) {
|
|
82
|
+
return new RegExp(`(?<=^|[\\s\\p{Pi}\\p{Ps}])(${words.join('|')})[ \\t]+(?=[\\p{L}\\p{N}\\p{Pi}\\p{Ps}])`, 'giu')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} value
|
|
87
|
+
* @param {{ mode?: 'default' | 'compact' }} [options]
|
|
88
|
+
*/
|
|
89
|
+
export function typographText(value, options = {}) {
|
|
90
|
+
const serviceWordPattern = options.mode === 'compact'
|
|
91
|
+
? RUSSIAN_COMPACT_SERVICE_WORD_WITH_SPACE
|
|
92
|
+
: RUSSIAN_SERVICE_WORD_WITH_SPACE
|
|
93
|
+
|
|
94
|
+
return value
|
|
95
|
+
.replace(serviceWordPattern, `$1${NON_BREAKING_SPACE}`)
|
|
96
|
+
.replace(RUSSIAN_SPACED_HYPHEN, `$1${NON_BREAKING_SPACE}— `)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {Element | null | undefined} root
|
|
101
|
+
*/
|
|
102
|
+
export function typographElement(root) {
|
|
103
|
+
if (!root)
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
const ownerDocument = root.ownerDocument ?? globalThis.document
|
|
107
|
+
if (!ownerDocument?.createTreeWalker)
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
const nodeFilter = ownerDocument.defaultView?.NodeFilter ?? globalThis.NodeFilter
|
|
111
|
+
const walker = ownerDocument.createTreeWalker(root, nodeFilter?.SHOW_TEXT ?? TEXT_NODE_FILTER, {
|
|
112
|
+
acceptNode(node) {
|
|
113
|
+
const parent = node.parentElement
|
|
114
|
+
if (!parent || parent.closest(TYPOGRAPHY_SKIP_SELECTOR))
|
|
115
|
+
return nodeFilter?.FILTER_REJECT ?? FILTER_REJECT
|
|
116
|
+
|
|
117
|
+
return nodeFilter?.FILTER_ACCEPT ?? FILTER_ACCEPT
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
let changed = 0
|
|
121
|
+
|
|
122
|
+
while (walker.nextNode()) {
|
|
123
|
+
const node = walker.currentNode
|
|
124
|
+
const parent = node.parentElement
|
|
125
|
+
const text = node.nodeValue ?? ''
|
|
126
|
+
const typographedText = typographText(text, {
|
|
127
|
+
mode: parent?.closest('[data-typography="compact"]') ? 'compact' : 'default',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
if (typographedText === text)
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
node.nodeValue = typographedText
|
|
134
|
+
changed += 1
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return changed
|
|
138
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
export const THEME_SLIDE_MODES = ['light', 'dark', 'color'] as const
|
|
2
|
+
export const THEME_TONES = ['blue', 'orange', 'green', 'red', 'yellow'] as const
|
|
3
|
+
export const THEME_SLOT_SURFACES = ['none', 'light', 'dark', 'color', 'tint'] as const
|
|
4
|
+
|
|
5
|
+
export type ThemeSlideMode = typeof THEME_SLIDE_MODES[number]
|
|
6
|
+
export type ThemeTone = typeof THEME_TONES[number]
|
|
7
|
+
export type ThemeSlotSurface = typeof THEME_SLOT_SURFACES[number]
|
|
8
|
+
|
|
9
|
+
export type ResolvedThemeMode = {
|
|
10
|
+
mode: ThemeSlideMode
|
|
11
|
+
tone: ThemeTone
|
|
12
|
+
background: string
|
|
13
|
+
text: string
|
|
14
|
+
muted: string
|
|
15
|
+
contrast: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ResolvedSlideTheme = ResolvedThemeMode
|
|
19
|
+
|
|
20
|
+
export type ResolvedSlotTheme = {
|
|
21
|
+
surface: ThemeSlotSurface
|
|
22
|
+
tone: ThemeTone
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ResolveThemeModeOptions = {
|
|
26
|
+
mode?: unknown
|
|
27
|
+
tone?: unknown
|
|
28
|
+
defaultTone?: ThemeTone
|
|
29
|
+
label?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ResolveSurfaceToneOptions = {
|
|
33
|
+
surface?: unknown
|
|
34
|
+
tone?: unknown
|
|
35
|
+
defaultTone?: ThemeTone
|
|
36
|
+
label?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ResolveSlideThemeOptions = ResolveThemeModeOptions
|
|
40
|
+
export type ResolveSlotThemeOptions = ResolveSurfaceToneOptions
|
|
41
|
+
|
|
42
|
+
function fail(label: string, message: string): never {
|
|
43
|
+
throw new Error(`[${label}] ${message}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeString(value: unknown) {
|
|
47
|
+
if (typeof value !== 'string')
|
|
48
|
+
return null
|
|
49
|
+
|
|
50
|
+
const normalized = value.trim().toLowerCase()
|
|
51
|
+
return normalized || null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveEnum<T extends string>(
|
|
55
|
+
value: unknown,
|
|
56
|
+
allowed: readonly T[],
|
|
57
|
+
label: string,
|
|
58
|
+
fallback?: T,
|
|
59
|
+
) {
|
|
60
|
+
const normalized = normalizeString(value)
|
|
61
|
+
if (normalized == null) {
|
|
62
|
+
if (fallback)
|
|
63
|
+
return fallback
|
|
64
|
+
|
|
65
|
+
fail(label, `Значение обязательно. Допустимые варианты: ${allowed.join(', ')}.`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if ((allowed as readonly string[]).includes(normalized))
|
|
69
|
+
return normalized as T
|
|
70
|
+
|
|
71
|
+
fail(label, `Недопустимое значение "${normalized}". Разрешены только: ${allowed.join(', ')}.`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveSlideMode(
|
|
75
|
+
value: unknown,
|
|
76
|
+
label = 'Slide.mode',
|
|
77
|
+
fallback: ThemeSlideMode = 'light',
|
|
78
|
+
) {
|
|
79
|
+
return resolveEnum(value, THEME_SLIDE_MODES, label, fallback)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveTone(
|
|
83
|
+
value: unknown,
|
|
84
|
+
label = 'tone',
|
|
85
|
+
fallback: ThemeTone = 'blue',
|
|
86
|
+
) {
|
|
87
|
+
return resolveEnum(value, THEME_TONES, label, fallback)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveSlotSurface(
|
|
91
|
+
value: unknown,
|
|
92
|
+
label = 'surface',
|
|
93
|
+
fallback: ThemeSlotSurface = 'none',
|
|
94
|
+
) {
|
|
95
|
+
return resolveEnum(value, THEME_SLOT_SURFACES, label, fallback)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isContrastSlideMode(mode: ThemeSlideMode) {
|
|
99
|
+
return mode !== 'light'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveThemeMode(options: ResolveThemeModeOptions = {}): ResolvedThemeMode {
|
|
103
|
+
const label = options.label ?? 'Slide'
|
|
104
|
+
const mode = resolveSlideMode(options.mode, `${label}.mode`, 'light')
|
|
105
|
+
const tone = resolveTone(options.tone, `${label}.tone`, options.defaultTone ?? 'blue')
|
|
106
|
+
|
|
107
|
+
if (mode === 'light') {
|
|
108
|
+
return {
|
|
109
|
+
mode,
|
|
110
|
+
tone,
|
|
111
|
+
background: 'var(--theme-color-light-0)',
|
|
112
|
+
text: 'var(--theme-color-dark-0)',
|
|
113
|
+
muted: 'var(--theme-color-dark-2)',
|
|
114
|
+
contrast: false,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (mode === 'dark') {
|
|
119
|
+
return {
|
|
120
|
+
mode,
|
|
121
|
+
tone,
|
|
122
|
+
background: 'var(--theme-color-dark-0)',
|
|
123
|
+
text: 'var(--theme-color-light-0)',
|
|
124
|
+
muted: 'var(--theme-text-muted-on-contrast)',
|
|
125
|
+
contrast: true,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
mode,
|
|
131
|
+
tone,
|
|
132
|
+
background: `var(--theme-color-${tone}-0)`,
|
|
133
|
+
text: 'var(--theme-color-light-0)',
|
|
134
|
+
muted: 'var(--theme-text-muted-on-contrast)',
|
|
135
|
+
contrast: true,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function resolveSurfaceTone(options: ResolveSurfaceToneOptions = {}): ResolvedSlotTheme {
|
|
140
|
+
const label = options.label ?? 'Slot'
|
|
141
|
+
const surface = resolveSlotSurface(options.surface, `${label}.surface`, 'none')
|
|
142
|
+
const rawTone = normalizeString(options.tone)
|
|
143
|
+
|
|
144
|
+
if (surface !== 'color' && surface !== 'tint') {
|
|
145
|
+
if (rawTone) {
|
|
146
|
+
fail(label, `tone разрешён только для surface="color" или surface="tint". Получено: "${rawTone}".`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
surface,
|
|
151
|
+
tone: options.defaultTone ?? 'blue',
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
surface,
|
|
157
|
+
tone: resolveTone(rawTone, `${label}.tone`, options.defaultTone ?? 'blue'),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const resolveSlideTheme = resolveThemeMode
|
|
162
|
+
export const resolveSlotTheme = resolveSurfaceTone
|
|
163
|
+
|
|
164
|
+
export function themeVars(scope: 'slide', theme: ResolvedSlideTheme): Record<string, string>
|
|
165
|
+
export function themeVars(scope: 'slot', theme: ResolvedSlotTheme): Record<string, string>
|
|
166
|
+
export function themeVars(
|
|
167
|
+
scope: 'slide' | 'slot',
|
|
168
|
+
theme: ResolvedSlideTheme | ResolvedSlotTheme,
|
|
169
|
+
): Record<string, string> {
|
|
170
|
+
if (scope === 'slide') {
|
|
171
|
+
const slideTheme = theme as ResolvedSlideTheme
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
'--theme-bg': slideTheme.background,
|
|
175
|
+
'--theme-text': slideTheme.text,
|
|
176
|
+
'--theme-text-muted': slideTheme.muted,
|
|
177
|
+
'--theme-inline-code-text': slideTheme.text,
|
|
178
|
+
'--theme-text-on-dark': 'var(--theme-color-light-0)',
|
|
179
|
+
'--theme-link': slideTheme.contrast ? 'var(--theme-color-light-0)' : 'var(--theme-current-color)',
|
|
180
|
+
'--theme-current-color': `var(--theme-color-${slideTheme.tone}-0)`,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const slotTheme = theme as ResolvedSlotTheme
|
|
185
|
+
if (slotTheme.surface !== 'color' && slotTheme.surface !== 'tint')
|
|
186
|
+
return {}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
'--theme-slot-color': `var(--theme-color-${slotTheme.tone}-0)`,
|
|
190
|
+
'--theme-slot-tint': `var(--theme-color-${slotTheme.tone}-1, color-mix(in srgb, var(--theme-color-${slotTheme.tone}-0) 40%, var(--theme-color-light-0)))`,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_DECOR_CATALOG,
|
|
3
|
+
hasDecorInput,
|
|
4
|
+
normalizeDecorCatalog,
|
|
5
|
+
resolveDecorImage,
|
|
6
|
+
} from './decor-config.mjs'
|
|
7
|
+
import {
|
|
8
|
+
isSvgImageSource,
|
|
9
|
+
parseImageBackgrounds,
|
|
10
|
+
parseImageConfig,
|
|
11
|
+
resolveImageMaskSize,
|
|
12
|
+
} from './image-config.mjs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Record<string, unknown>} PlainRecord
|
|
16
|
+
* @typedef {import('./image-config.mjs').NormalizedImageData} NormalizedImageData
|
|
17
|
+
* @typedef {import('./decor-config.mjs').NormalizedDecorCatalog} NormalizedDecorCatalog
|
|
18
|
+
* @typedef {{ cols?: number, rows?: number, label?: string }} GridFootprint
|
|
19
|
+
* @typedef {{ key: string, image: NormalizedImageData, render: { kind: 'img' | 'mask', decorative: boolean, alt: string, cssVars: Record<string, string> } }} ThemeMediaLayer
|
|
20
|
+
* @typedef {{ background?: unknown, decor?: unknown }} ThemeSlotLayerInput
|
|
21
|
+
* @typedef {{ label?: string, footprint?: GridFootprint | null, seed?: string, tone?: string, contrast?: boolean }} ThemeSlotMediaContext
|
|
22
|
+
* @typedef {{ baseCatalog?: readonly object[] | NormalizedDecorCatalog, themeCatalog?: readonly object[], warn?: (message: string) => void }} ThemeMediaInput
|
|
23
|
+
* @typedef {{ key?: string, alt?: string, decorative?: boolean }} LayerOptions
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {unknown} value
|
|
28
|
+
* @returns {value is PlainRecord}
|
|
29
|
+
*/
|
|
30
|
+
function isRecord(value) {
|
|
31
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {unknown} value
|
|
36
|
+
* @returns {value is NormalizedDecorCatalog}
|
|
37
|
+
*/
|
|
38
|
+
function isNormalizedDecorCatalog(value) {
|
|
39
|
+
return isRecord(value)
|
|
40
|
+
&& Array.isArray(value.variants)
|
|
41
|
+
&& value.byId instanceof Map
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
*/
|
|
47
|
+
function hasBackgroundInput(value) {
|
|
48
|
+
if (value === undefined || value === null || value === '')
|
|
49
|
+
return false
|
|
50
|
+
|
|
51
|
+
if (Array.isArray(value))
|
|
52
|
+
return value.some(item => item !== undefined && item !== null && item !== '')
|
|
53
|
+
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {readonly object[] | NormalizedDecorCatalog | undefined} baseCatalog
|
|
59
|
+
* @param {readonly object[] | undefined} themeCatalog
|
|
60
|
+
* @returns {NormalizedDecorCatalog}
|
|
61
|
+
*/
|
|
62
|
+
function resolveCatalog(baseCatalog, themeCatalog) {
|
|
63
|
+
const base = isNormalizedDecorCatalog(baseCatalog)
|
|
64
|
+
? baseCatalog
|
|
65
|
+
: normalizeDecorCatalog(Array.isArray(baseCatalog) ? baseCatalog : [])
|
|
66
|
+
|
|
67
|
+
if (!Array.isArray(themeCatalog) || themeCatalog.length === 0)
|
|
68
|
+
return base
|
|
69
|
+
|
|
70
|
+
return normalizeDecorCatalog([...base.variants], themeCatalog)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {NormalizedImageData} image
|
|
75
|
+
* @param {'img' | 'mask'} kind
|
|
76
|
+
* @returns {Record<string, string>}
|
|
77
|
+
*/
|
|
78
|
+
function cssVarsForImage(image, kind) {
|
|
79
|
+
return {
|
|
80
|
+
'--theme-image-background': image.backgroundColor || 'transparent',
|
|
81
|
+
'--theme-image-fit': image.fit,
|
|
82
|
+
'--theme-image-position': image.position,
|
|
83
|
+
'--theme-image-origin': image.anchor,
|
|
84
|
+
'--theme-image-x': image.x,
|
|
85
|
+
'--theme-image-y': image.y,
|
|
86
|
+
'--theme-image-zoom': String(image.zoom),
|
|
87
|
+
'--theme-image-rotate': image.rotate,
|
|
88
|
+
'--theme-image-opacity': String(image.opacity),
|
|
89
|
+
...(kind === 'mask'
|
|
90
|
+
? {
|
|
91
|
+
'--theme-image-color': image.color || 'currentColor',
|
|
92
|
+
'--theme-image-mask': `url("${image.src}")`,
|
|
93
|
+
'--theme-image-mask-size': resolveImageMaskSize(image.fit),
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {NormalizedImageData} image
|
|
101
|
+
* @param {LayerOptions} [options]
|
|
102
|
+
* @returns {ThemeMediaLayer}
|
|
103
|
+
*/
|
|
104
|
+
function createLayer(image, options = {}) {
|
|
105
|
+
const kind = image.color && isSvgImageSource(image.src) ? 'mask' : 'img'
|
|
106
|
+
const alt = typeof options.alt === 'string' ? options.alt : ''
|
|
107
|
+
const decorative = options.decorative ?? !alt
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
key: options.key || `${kind}:${image.src}:${image.fit}:${image.position}:${image.zoom}:${image.color}:${image.opacity}`,
|
|
111
|
+
image,
|
|
112
|
+
render: {
|
|
113
|
+
kind,
|
|
114
|
+
decorative,
|
|
115
|
+
alt,
|
|
116
|
+
cssVars: cssVarsForImage(image, kind),
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {ThemeMediaInput} [input]
|
|
123
|
+
*/
|
|
124
|
+
export function createThemeMedia(input = {}) {
|
|
125
|
+
const catalog = resolveCatalog(input.baseCatalog ?? DEFAULT_DECOR_CATALOG, input.themeCatalog)
|
|
126
|
+
const warn = typeof input.warn === 'function' ? input.warn : undefined
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
/**
|
|
130
|
+
* @param {ThemeSlotLayerInput} [layerInput]
|
|
131
|
+
* @param {ThemeSlotMediaContext} [context]
|
|
132
|
+
* @returns {ThemeMediaLayer[]}
|
|
133
|
+
*/
|
|
134
|
+
resolveSlotLayers(layerInput = {}, context = {}) {
|
|
135
|
+
const hasBackground = hasBackgroundInput(layerInput.background)
|
|
136
|
+
const hasDecor = layerInput.decor ? hasDecorInput(layerInput.decor) : false
|
|
137
|
+
const label = context.label || 'slot'
|
|
138
|
+
|
|
139
|
+
if (hasBackground && hasDecor)
|
|
140
|
+
throw new Error(`[${label}] background and decor cannot be used together.`)
|
|
141
|
+
|
|
142
|
+
if (hasDecor) {
|
|
143
|
+
if (!context.footprint)
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
const image = resolveDecorImage(layerInput.decor, {
|
|
147
|
+
catalog: catalog.variants,
|
|
148
|
+
footprint: context.footprint,
|
|
149
|
+
seed: context.seed,
|
|
150
|
+
warn,
|
|
151
|
+
label: `${label}.decor`,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
return image
|
|
155
|
+
? [createLayer(image, { key: `${label}.decor:${image.src}:${context.seed ?? ''}`, decorative: true })]
|
|
156
|
+
: []
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!hasBackground)
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
return parseImageBackgrounds(
|
|
163
|
+
/** @type {Parameters<typeof parseImageBackgrounds>[0]} */ (layerInput.background),
|
|
164
|
+
{},
|
|
165
|
+
`${label}.background`,
|
|
166
|
+
)
|
|
167
|
+
.map((image, index) =>
|
|
168
|
+
createLayer(image, { key: `${label}.background:${index}:${image.src}`, decorative: true }),
|
|
169
|
+
)
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {unknown} imageInput
|
|
174
|
+
* @param {string} [label]
|
|
175
|
+
* @returns {ThemeMediaLayer | null}
|
|
176
|
+
*/
|
|
177
|
+
resolveImage(imageInput, label = 'image') {
|
|
178
|
+
if (!imageInput)
|
|
179
|
+
return null
|
|
180
|
+
|
|
181
|
+
if (typeof imageInput === 'string' && !imageInput.trim())
|
|
182
|
+
return null
|
|
183
|
+
|
|
184
|
+
if (isRecord(imageInput) && typeof imageInput.src === 'string' && !imageInput.src.trim())
|
|
185
|
+
return null
|
|
186
|
+
|
|
187
|
+
const image = parseImageConfig(
|
|
188
|
+
/** @type {Parameters<typeof parseImageConfig>[0]} */ (imageInput),
|
|
189
|
+
{},
|
|
190
|
+
label,
|
|
191
|
+
)
|
|
192
|
+
if (!image)
|
|
193
|
+
return null
|
|
194
|
+
|
|
195
|
+
return createLayer(image, {
|
|
196
|
+
key: `${label}:${image.src}`,
|
|
197
|
+
alt: image.alt,
|
|
198
|
+
decorative: !image.alt,
|
|
199
|
+
})
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|