@wikex/admin-kit 0.2.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.
@@ -0,0 +1,202 @@
1
+ 'use client'
2
+
3
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
4
+ import { $forEachSelectedTextNode } from '@lexical/selection'
5
+ import type { ToolbarGroupItem } from '@payloadcms/richtext-lexical'
6
+ import { createClientFeature } from '@payloadcms/richtext-lexical/client'
7
+ import {
8
+ $getNodeByKey,
9
+ $getSelection,
10
+ $getState,
11
+ $isRangeSelection,
12
+ $isTextNode,
13
+ $setSelection,
14
+ $setState,
15
+ createState,
16
+ type LexicalEditor,
17
+ type RangeSelection,
18
+ TextNode,
19
+ } from 'lexical'
20
+ import { useCallback, useEffect, useRef, useState } from 'react'
21
+
22
+ import {
23
+ getTextStyleCSS,
24
+ normalizeTextStyleValue,
25
+ TEXT_FONT_OPTIONS,
26
+ TEXT_LINE_HEIGHT_OPTIONS,
27
+ type TextFontFamily,
28
+ type TextLineHeight,
29
+ } from '../textStyles'
30
+
31
+ const fontFamilyState = createState('fontFamily', {
32
+ parse: (value) => normalizeTextStyleValue({ fontFamily: value }).fontFamily,
33
+ })
34
+ const lineHeightState = createState('lineHeight', {
35
+ parse: (value) => normalizeTextStyleValue({ lineHeight: value }).lineHeight,
36
+ })
37
+
38
+ type TypographyValue = {
39
+ fontFamily: TextFontFamily
40
+ lineHeight: TextLineHeight
41
+ }
42
+
43
+ const DEFAULT_TYPOGRAPHY: TypographyValue = {
44
+ fontFamily: 'default',
45
+ lineHeight: 'default',
46
+ }
47
+
48
+ const readSelectedTypography = (editor: LexicalEditor): TypographyValue =>
49
+ editor.getEditorState().read(() => {
50
+ const selection = $getSelection()
51
+ if (!$isRangeSelection(selection)) return DEFAULT_TYPOGRAPHY
52
+
53
+ const textNode = selection.getNodes().find($isTextNode)
54
+ if (!textNode) return DEFAULT_TYPOGRAPHY
55
+
56
+ return {
57
+ fontFamily: $getState(textNode, fontFamilyState) ?? 'default',
58
+ lineHeight: $getState(textNode, lineHeightState) ?? 'default',
59
+ }
60
+ })
61
+
62
+ const updateSelectedTypography = (
63
+ editor: LexicalEditor,
64
+ property: keyof TypographyValue,
65
+ value: TextFontFamily | TextLineHeight,
66
+ savedSelection: null | RangeSelection,
67
+ ) => {
68
+ editor.update(() => {
69
+ if (!$isRangeSelection($getSelection()) && savedSelection) {
70
+ $setSelection(savedSelection.clone())
71
+ }
72
+
73
+ $forEachSelectedTextNode((textNode) => {
74
+ if (property === 'fontFamily') {
75
+ const fontFamily = value as TextFontFamily
76
+ $setState(textNode, fontFamilyState, fontFamily === 'default' ? undefined : fontFamily)
77
+ } else {
78
+ const lineHeight = value as TextLineHeight
79
+ $setState(textNode, lineHeightState, lineHeight === 'default' ? undefined : lineHeight)
80
+ }
81
+ })
82
+ })
83
+ }
84
+
85
+ const TypographyToolbarItem = ({ editor }: { editor: LexicalEditor }) => {
86
+ const [value, setValue] = useState<TypographyValue>(DEFAULT_TYPOGRAPHY)
87
+ const savedSelection = useRef<null | RangeSelection>(null)
88
+
89
+ const captureSelection = useCallback(() => {
90
+ editor.getEditorState().read(() => {
91
+ const selection = $getSelection()
92
+ if ($isRangeSelection(selection)) savedSelection.current = selection.clone()
93
+ })
94
+ }, [editor])
95
+
96
+ useEffect(() => {
97
+ const syncValue = () => {
98
+ captureSelection()
99
+ setValue(readSelectedTypography(editor))
100
+ }
101
+
102
+ syncValue()
103
+ return editor.registerUpdateListener(syncValue)
104
+ }, [captureSelection, editor])
105
+
106
+ return (
107
+ <div
108
+ aria-label="Kiểu chữ"
109
+ className="wikex-typography-selects"
110
+ onMouseDown={(event) => {
111
+ event.stopPropagation()
112
+ captureSelection()
113
+ }}
114
+ >
115
+ <select
116
+ aria-label="Phông chữ"
117
+ className="wikex-minimal-select wikex-minimal-select--font"
118
+ onChange={(event) => {
119
+ const fontFamily = event.target.value as TextFontFamily
120
+ setValue((current) => ({ ...current, fontFamily }))
121
+ updateSelectedTypography(editor, 'fontFamily', fontFamily, savedSelection.current)
122
+ }}
123
+ title="Phông chữ"
124
+ value={value.fontFamily}
125
+ >
126
+ {TEXT_FONT_OPTIONS.map((option) => (
127
+ <option key={option.value} value={option.value}>
128
+ {option.label}
129
+ </option>
130
+ ))}
131
+ </select>
132
+ <select
133
+ aria-label="Chiều cao dòng"
134
+ className="wikex-minimal-select wikex-minimal-select--line-height"
135
+ onChange={(event) => {
136
+ const lineHeight = event.target.value as TextLineHeight
137
+ setValue((current) => ({ ...current, lineHeight }))
138
+ updateSelectedTypography(editor, 'lineHeight', lineHeight, savedSelection.current)
139
+ }}
140
+ title="Chiều cao dòng"
141
+ value={value.lineHeight}
142
+ >
143
+ {TEXT_LINE_HEIGHT_OPTIONS.map((option) => (
144
+ <option key={option.value} value={option.value}>
145
+ {option.label}
146
+ </option>
147
+ ))}
148
+ </select>
149
+ </div>
150
+ )
151
+ }
152
+
153
+ const TypographyStatePlugin = () => {
154
+ const [editor] = useLexicalComposerContext()
155
+
156
+ useEffect(
157
+ () =>
158
+ editor.registerMutationListener(TextNode, (mutatedNodes) => {
159
+ editor.getEditorState().read(() => {
160
+ for (const [nodeKey, mutation] of mutatedNodes) {
161
+ if (mutation === 'destroyed') continue
162
+
163
+ const node = $getNodeByKey(nodeKey)
164
+ const element = editor.getElementByKey(nodeKey)
165
+ if (!$isTextNode(node) || !element) continue
166
+
167
+ const style = getTextStyleCSS({
168
+ fontFamily: $getState(node, fontFamilyState),
169
+ lineHeight: $getState(node, lineHeightState),
170
+ })
171
+
172
+ if (style.fontFamily) element.style.fontFamily = style.fontFamily
173
+ else element.style.removeProperty('font-family')
174
+
175
+ if (style.lineHeight) element.style.lineHeight = style.lineHeight
176
+ else element.style.removeProperty('line-height')
177
+ }
178
+ })
179
+ }),
180
+ [editor],
181
+ )
182
+
183
+ return null
184
+ }
185
+
186
+ const toolbarItem: ToolbarGroupItem = {
187
+ Component: TypographyToolbarItem,
188
+ key: 'wikex-typography-controls',
189
+ }
190
+
191
+ const typographyToolbarGroup = {
192
+ items: [toolbarItem],
193
+ key: 'wikexTypography',
194
+ order: 34,
195
+ type: 'buttons' as const,
196
+ }
197
+
198
+ export const RichTextTypographyFeatureClient = createClientFeature({
199
+ plugins: [{ Component: TypographyStatePlugin, position: 'normal' }],
200
+ toolbarFixed: { groups: [typographyToolbarGroup] },
201
+ toolbarInline: { groups: [typographyToolbarGroup] },
202
+ })
@@ -0,0 +1,10 @@
1
+ import { createServerFeature } from '@payloadcms/richtext-lexical'
2
+
3
+ const TypographyFeature = createServerFeature({
4
+ feature: {
5
+ ClientFeature: '@wikex/admin-kit/rich-text/client#RichTextTypographyFeatureClient',
6
+ },
7
+ key: 'wikexTypography',
8
+ })
9
+
10
+ export const RichTextTypographyFeature = () => TypographyFeature()
@@ -0,0 +1,22 @@
1
+ 'use client'
2
+
3
+ import { configureAdminBrand } from '../admin/Brand'
4
+ import LivePreviewEditor, { configureLivePreview } from '../live-preview'
5
+ import type { WikexStarter } from '../starter'
6
+
7
+ type LivePreviewConfig = WikexStarter['livePreview'] & {
8
+ getVisualEditorRoute: WikexStarter['getVisualEditorRoute']
9
+ }
10
+
11
+ /** Configure and return the shared client-side Live Preview provider for a Wikex starter. */
12
+ export const createWikexLivePreviewAdapter = ({
13
+ brand,
14
+ getVisualEditorRoute,
15
+ pageBlocks,
16
+ postBlocks,
17
+ }: LivePreviewConfig) => {
18
+ configureAdminBrand({ logo: { alt: brand.name, ...brand.logo }, siteName: brand.name })
19
+ configureLivePreview({ getVisualEditorRoute, pageBlocks, postBlocks })
20
+
21
+ return LivePreviewEditor
22
+ }
package/src/starter.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { createContentModule, type ContentModuleOptions } from '@wikex/content-kit'
2
+ import { adminExperiencePlugin } from './adminExperience'
3
+ import type { BlockPickerOption, VisualEditorRoute } from './live-preview/runtimeConfig'
4
+ import { createVisualEditorRouteResolver, defineWikexProject } from './project'
5
+ import { wikexAdminPlugin } from './plugin'
6
+
7
+ export type WikexStarterBrand = {
8
+ description: string
9
+ logo: {
10
+ height: number
11
+ url: string
12
+ width: number
13
+ }
14
+ name: string
15
+ }
16
+
17
+ export type WikexStarterOptions = {
18
+ brand: WikexStarterBrand
19
+ content: ContentModuleOptions
20
+ globals?: Readonly<Record<string, 'content' | 'footer' | 'header'>>
21
+ id: string
22
+ modules?: readonly string[]
23
+ provider: string
24
+ }
25
+
26
+ export type WikexStarter = {
27
+ collections: ReturnType<typeof createContentModule>['collections']
28
+ getVisualEditorRoute: (pathname: string) => VisualEditorRoute | null
29
+ livePreview: {
30
+ brand: WikexStarterBrand
31
+ pageBlocks: readonly BlockPickerOption[]
32
+ postBlocks: readonly BlockPickerOption[]
33
+ }
34
+ plugins: ReturnType<typeof createContentModule>['plugins']
35
+ project: ReturnType<typeof defineWikexProject>
36
+ }
37
+
38
+ const getLabel = (block: { labels?: unknown; slug: string }) => {
39
+ if (typeof block.labels === 'object' && block.labels && 'singular' in block.labels) {
40
+ const singular = block.labels.singular
41
+ if (typeof singular === 'string') return singular
42
+ }
43
+ return block.slug
44
+ }
45
+
46
+ const isStarterBlock = (block: unknown): block is { labels?: unknown; slug: string } =>
47
+ typeof block === 'object' && block !== null && 'slug' in block && typeof block.slug === 'string'
48
+
49
+ const toBlockPickerOptions = (blocks: unknown): readonly BlockPickerOption[] => {
50
+ if (!Array.isArray(blocks)) return []
51
+ return blocks.filter(isStarterBlock).map((block) => {
52
+ const label = getLabel(block)
53
+ return { description: label, label, payloadLabel: label, slug: block.slug }
54
+ })
55
+ }
56
+
57
+ /**
58
+ * One factory for the conventional Wikex Payload setup. The consuming project still owns
59
+ * its visual block components and frontend renderer, but no longer has to wire plugins,
60
+ * content schema, branding and preview routing independently.
61
+ */
62
+ export const createWikexStarter = (options: WikexStarterOptions): WikexStarter => {
63
+ const seo = options.content.seo === undefined ? { siteName: options.brand.name } : options.content.seo
64
+ const content = createContentModule({ ...options.content, seo })
65
+ const collections = [content.pageSlug, content.postSlug].filter(
66
+ (slug): slug is string => Boolean(slug),
67
+ )
68
+ const getVisualEditorRoute = createVisualEditorRouteResolver({
69
+ collections,
70
+ globals: options.globals,
71
+ })
72
+ const project = defineWikexProject({
73
+ brand: options.brand,
74
+ id: options.id,
75
+ modules: options.modules ?? ['website', 'blog'],
76
+ })
77
+ const pageBlocks = toBlockPickerOptions(
78
+ options.content.page === false ? undefined : options.content.page?.blocks,
79
+ )
80
+ const postBlocks = toBlockPickerOptions(
81
+ options.content.post === false ? undefined : options.content.post?.blocks,
82
+ )
83
+
84
+ return {
85
+ collections: content.collections,
86
+ getVisualEditorRoute,
87
+ livePreview: { brand: options.brand, pageBlocks, postBlocks },
88
+ plugins: [
89
+ adminExperiencePlugin,
90
+ ...content.plugins,
91
+ wikexAdminPlugin({ components: { provider: options.provider } }),
92
+ ],
93
+ project,
94
+ }
95
+ }
@@ -0,0 +1,45 @@
1
+ /* Self-hosted Google fonts used by the Lexical typography controls. */
2
+ @mixin wikex-font($family, $file, $weight, $range) {
3
+ @font-face {
4
+ font-family: $family;
5
+ font-style: normal;
6
+ font-weight: $weight;
7
+ font-display: swap;
8
+ src: url('/fonts/google/#{$file}.woff2') format('woff2');
9
+ unicode-range: $range;
10
+ }
11
+ }
12
+
13
+ $vietnamese-range:
14
+ U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301,
15
+ U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
16
+ $latin-range:
17
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
18
+ U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
19
+
20
+ @include wikex-font('Wikex Inter', 'inter-vietnamese', 100 900, $vietnamese-range);
21
+ @include wikex-font('Wikex Inter', 'inter-latin', 100 900, $latin-range);
22
+ @include wikex-font('Wikex Lora', 'lora-vietnamese', 400 700, $vietnamese-range);
23
+ @include wikex-font('Wikex Lora', 'lora-latin', 400 700, $latin-range);
24
+ @include wikex-font('Wikex Manrope', 'manrope-vietnamese', 200 800, $vietnamese-range);
25
+ @include wikex-font('Wikex Manrope', 'manrope-latin', 200 800, $latin-range);
26
+ @include wikex-font('Wikex Montserrat', 'montserrat-vietnamese', 100 900, $vietnamese-range);
27
+ @include wikex-font('Wikex Montserrat', 'montserrat-latin', 100 900, $latin-range);
28
+ @include wikex-font(
29
+ 'Wikex Playfair Display',
30
+ 'playfair-display-vietnamese',
31
+ 400 900,
32
+ $vietnamese-range
33
+ );
34
+ @include wikex-font('Wikex Playfair Display', 'playfair-display-latin', 400 900, $latin-range);
35
+ @include wikex-font('Wikex Roboto', 'roboto-vietnamese', 100 900, $vietnamese-range);
36
+ @include wikex-font('Wikex Roboto', 'roboto-latin', 100 900, $latin-range);
37
+
38
+ :root {
39
+ --font-inter: 'Wikex Inter', sans-serif;
40
+ --font-lora: 'Wikex Lora', serif;
41
+ --font-manrope: 'Wikex Manrope', sans-serif;
42
+ --font-montserrat: 'Wikex Montserrat', sans-serif;
43
+ --font-playfair-display: 'Wikex Playfair Display', serif;
44
+ --font-roboto: 'Wikex Roboto', sans-serif;
45
+ }