@wikex/admin-kit 0.2.0 → 0.2.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/admin/before-login.d.ts +5 -0
  3. package/dist/admin/before-login.js +26 -0
  4. package/dist/admin/brand.d.ts +19 -0
  5. package/dist/admin/brand.js +15 -0
  6. package/dist/admin/header.d.ts +5 -0
  7. package/dist/admin/header.js +87 -0
  8. package/dist/admin/logout-button.d.ts +8 -0
  9. package/dist/admin/logout-button.js +7 -0
  10. package/dist/admin/nav.d.ts +19 -0
  11. package/dist/admin/nav.js +285 -0
  12. package/dist/admin/theme-toggle.d.ts +5 -0
  13. package/dist/admin/theme-toggle.js +7 -0
  14. package/dist/admin/view-site.d.ts +5 -0
  15. package/dist/admin/view-site.js +6 -0
  16. package/dist/chunk-7FEACJUN.js +151 -0
  17. package/dist/chunk-BUD6BMFV.js +117 -0
  18. package/dist/chunk-GGSOO4FQ.js +27 -0
  19. package/dist/chunk-IL2CUCDG.js +2062 -0
  20. package/dist/chunk-JJLHSZ3T.js +40 -0
  21. package/dist/chunk-LC2CP5SR.js +13 -0
  22. package/dist/chunk-LP22Y3NO.js +61 -0
  23. package/dist/chunk-MDMKKWPU.js +12 -0
  24. package/dist/chunk-W4ZKDOWJ.js +37 -0
  25. package/dist/chunk-XGKGKCQ6.js +50 -0
  26. package/dist/chunk-YMBJCWN3.js +73 -0
  27. package/dist/index.d.ts +26 -0
  28. package/dist/index.js +44 -0
  29. package/dist/live-preview.d.ts +50 -0
  30. package/dist/live-preview.js +19 -0
  31. package/dist/project.d.ts +34 -0
  32. package/dist/project.js +8 -0
  33. package/dist/rich-text-client.d.ts +5 -0
  34. package/dist/rich-text-client.js +165 -0
  35. package/dist/rich-text.d.ts +5 -0
  36. package/dist/rich-text.js +6 -0
  37. package/dist/runtimeConfig-BwjQepcW.d.ts +30 -0
  38. package/dist/starter-client.d.ts +15 -0
  39. package/dist/starter-client.js +25 -0
  40. package/dist/starter.d.ts +40 -0
  41. package/dist/starter.js +62 -0
  42. package/dist/text-style-runtime.d.ts +5 -0
  43. package/dist/text-style-runtime.js +8 -0
  44. package/dist/text-styles.d.ts +86 -0
  45. package/dist/text-styles.js +22 -0
  46. package/package.json +23 -19
  47. package/src/TextStyleRuntime.tsx +0 -90
  48. package/src/admin/BeforeLogin.tsx +0 -19
  49. package/src/admin/Brand.tsx +0 -74
  50. package/src/admin/Header.tsx +0 -87
  51. package/src/admin/LogoutButton.tsx +0 -41
  52. package/src/admin/Nav.tsx +0 -35
  53. package/src/admin/NavClient.tsx +0 -288
  54. package/src/admin/ThemeToggle.tsx +0 -31
  55. package/src/admin/ViewSite.tsx +0 -11
  56. package/src/admin/useAdminBrand.ts +0 -81
  57. package/src/adminExperience.ts +0 -117
  58. package/src/index.ts +0 -11
  59. package/src/live-preview/GlobalInlineInspector.tsx +0 -334
  60. package/src/live-preview/LivePreviewEditor.tsx +0 -2088
  61. package/src/live-preview/TextStyleInspector.tsx +0 -289
  62. package/src/live-preview/index.ts +0 -23
  63. package/src/live-preview/runtimeConfig.ts +0 -52
  64. package/src/plugin.ts +0 -70
  65. package/src/project.ts +0 -82
  66. package/src/rich-text/client.tsx +0 -202
  67. package/src/rich-text/index.ts +0 -10
  68. package/src/starter/client.tsx +0 -22
  69. package/src/starter.ts +0 -95
  70. package/src/textStyles.ts +0 -161
@@ -1,2088 +0,0 @@
1
- 'use client'
2
-
3
- import { createPortal } from 'react-dom'
4
- import React, {
5
- type KeyboardEvent,
6
- type PointerEvent,
7
- useCallback,
8
- useEffect,
9
- useRef,
10
- useState,
11
- } from 'react'
12
-
13
- import {
14
- type EditableGlobalScope,
15
- GlobalInlineInspector,
16
- type GlobalInspectorSelection,
17
- type InspectorValues,
18
- updateGlobalFields,
19
- } from './GlobalInlineInspector'
20
- import {
21
- saveTextStylePatch,
22
- TextStyleInspector,
23
- type TextInspectorSelection,
24
- } from './TextStyleInspector'
25
- import {
26
- EDITABLE_INTERACTIVE_SELECTOR,
27
- EDITABLE_PREVIEW_SELECTOR,
28
- getEditableElementStyleKey,
29
- getTextStyleCSS,
30
- isSyntheticElementStyleKey,
31
- type TextStyleValue,
32
- } from '../textStyles'
33
- import {
34
- getBlockPickerOptions,
35
- getVisualEditorRoute,
36
- isVisualEditorRoute,
37
- type BlockPickerOption,
38
- type BlockPickerScope,
39
- type VisualEditorScope,
40
- } from './runtimeConfig'
41
-
42
- const DEFAULT_EDIT_RATIO = 0.4
43
- const HANDLE_WIDTH = 12
44
- const LIVE_PREVIEW_TRANSITION_MS = 160
45
- const MIN_EDIT_WIDTH = 320
46
- const MIN_PREVIEW_WIDTH = 320
47
- const STORAGE_KEY = 'wikex-live-preview-edit-ratio'
48
- const LEGACY_STORAGE_KEY = 'aurea-live-preview-edit-ratio'
49
- const MIN_TEXT_WIDTH_PX = 72
50
- const RESIZABLE_TEXT_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li'
51
-
52
- const isEditableGlobalScope = (value?: string): value is EditableGlobalScope =>
53
- value === 'header' || value === 'footer' || value === 'settings'
54
-
55
- type EditorTargets = {
56
- container: HTMLElement
57
- editor: HTMLElement
58
- previewWrapper: HTMLElement
59
- route: string
60
- scope: VisualEditorScope
61
- }
62
-
63
- const normalizeText = (value: string) => value.replace(/\s+/g, ' ').trim()
64
- const readInlineText = (element: HTMLElement) =>
65
- element.innerText
66
- .replace(/\u00a0/g, ' ')
67
- .replace(/\r\n/g, '\n')
68
- .trim()
69
-
70
- const getMatchingPreviewFields = (element: HTMLElement, fieldPath: string) =>
71
- isSyntheticElementStyleKey(fieldPath)
72
- ? Array.from(
73
- element.ownerDocument.querySelectorAll<HTMLElement>(EDITABLE_PREVIEW_SELECTOR),
74
- ).filter((candidate) => getEditableElementStyleKey(candidate) === fieldPath)
75
- : Array.from(element.ownerDocument.querySelectorAll<HTMLElement>('[data-wikex-field]')).filter(
76
- (candidate) => candidate.dataset.wikexField === fieldPath,
77
- )
78
-
79
- const EditorIcon = ({ name }: { name: 'elements' | 'pages' | 'sections' }) => {
80
- if (name === 'elements') {
81
- return (
82
- <svg aria-hidden="true" viewBox="0 0 24 24">
83
- <path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />
84
- </svg>
85
- )
86
- }
87
-
88
- if (name === 'sections') {
89
- return (
90
- <svg aria-hidden="true" viewBox="0 0 24 24">
91
- <path d="M4 5h16v5H4zM4 14h16v5H4z" />
92
- </svg>
93
- )
94
- }
95
-
96
- return (
97
- <svg aria-hidden="true" viewBox="0 0 24 24">
98
- <path d="M6 3h10l3 3v15H6zM16 3v4h4M9 11h7M9 15h7" />
99
- </svg>
100
- )
101
- }
102
-
103
- const getVisibleBlocksField = () => {
104
- const fields = Array.from(
105
- document.querySelectorAll<HTMLElement>('#field-customLayout, #field-layout, .blocks-field'),
106
- )
107
-
108
- return fields[0] ?? null
109
- }
110
-
111
- const getVisibleSectionsTarget = () =>
112
- getVisibleBlocksField() ??
113
- document.querySelector<HTMLElement>('#field-hero, #field-customHero, .collapsible-field')
114
-
115
- const getVisibleRichTextTarget = () =>
116
- document.querySelector<HTMLElement>('.collection-edit__main .rich-text-lexical')
117
-
118
- const scrollEditorTo = (target: HTMLElement | null) => {
119
- if (!target) return
120
-
121
- target.scrollIntoView({ behavior: 'smooth', block: 'center' })
122
- target.classList.remove('wikex-editor-target--active')
123
- window.requestAnimationFrame(() => target.classList.add('wikex-editor-target--active'))
124
- window.setTimeout(() => target.classList.remove('wikex-editor-target--active'), 1200)
125
- }
126
-
127
- const activateEditorTab = (label: 'content' | 'hero') => {
128
- const tabLabels =
129
- label === 'content' ? new Set(['content', 'nội dung']) : new Set(['hero', 'mở đầu'])
130
- const tab = Array.from(
131
- document.querySelectorAll<HTMLButtonElement>('.tabs-field__tab-button'),
132
- ).find((button) => tabLabels.has(normalizeText(button.textContent || '').toLowerCase()))
133
-
134
- if (tab && !tab.classList.contains('tabs-field__tab-button--active')) tab.click()
135
- }
136
-
137
- const getPageBlockTriggerWhenReady = (
138
- onReady: (trigger: HTMLButtonElement) => void,
139
- attempt = 0,
140
- ) => {
141
- const blocksField = getVisibleBlocksField()
142
- if (!blocksField) {
143
- activateEditorTab('content')
144
-
145
- if (attempt < 12) {
146
- window.setTimeout(() => getPageBlockTriggerWhenReady(onReady, attempt + 1), 100)
147
- }
148
-
149
- return
150
- }
151
-
152
- scrollEditorTo(blocksField)
153
-
154
- const trigger = blocksField.querySelector<HTMLButtonElement>(
155
- '.blocks-field__drawer-toggler button, .blocks-field__drawer-toggler',
156
- )
157
-
158
- if (trigger) window.setTimeout(() => onReady(trigger), 180)
159
- }
160
-
161
- const getPageBlockTrigger = (onReady: (trigger: HTMLButtonElement) => void) => {
162
- const customLayoutRadio = document.querySelector<HTMLInputElement>(
163
- '#field-layoutMode-custom, input[type="radio"][value="custom"]',
164
- )
165
-
166
- if (customLayoutRadio && !customLayoutRadio.checked) customLayoutRadio.click()
167
- getPageBlockTriggerWhenReady(onReady)
168
- }
169
-
170
- const getPostBlockTriggerWhenReady = (
171
- onReady: (trigger: HTMLButtonElement) => void,
172
- attempt = 0,
173
- ) => {
174
- activateEditorTab('content')
175
-
176
- const richTextTarget = getVisibleRichTextTarget()
177
- const blockTrigger = richTextTarget?.querySelector<HTMLButtonElement>(
178
- 'button[aria-label="blocks dropdown"], button.add-block-menu',
179
- )
180
-
181
- if (!richTextTarget || !blockTrigger) {
182
- if (attempt < 16) {
183
- window.setTimeout(() => getPostBlockTriggerWhenReady(onReady, attempt + 1), 100)
184
- }
185
- return
186
- }
187
-
188
- scrollEditorTo(richTextTarget)
189
- window.setTimeout(() => onReady(blockTrigger), 180)
190
- }
191
-
192
- const selectPostBlockWhenReady = (
193
- blockTrigger: HTMLButtonElement,
194
- itemKey: string,
195
- attempt = 0,
196
- ) => {
197
- if (!blockTrigger.classList.contains('active')) blockTrigger.click()
198
-
199
- const option = document.querySelector<HTMLButtonElement>(`[data-item-key="${itemKey}"]`)
200
-
201
- if (option) {
202
- option.click()
203
- return
204
- }
205
-
206
- if (attempt < 12) {
207
- window.setTimeout(() => selectPostBlockWhenReady(blockTrigger, itemKey, attempt + 1), 50)
208
- }
209
- }
210
-
211
- const selectPageBlockWhenReady = (payloadLabel: string, attempt = 0) => {
212
- const openDrawer = Array.from(document.querySelectorAll<HTMLElement>('.drawer--is-open')).find(
213
- (drawer) => drawer.querySelector('.blocks-drawer__blocks'),
214
- )
215
- const option = Array.from(
216
- openDrawer?.querySelectorAll<HTMLButtonElement>('.thumbnail-card') ?? [],
217
- ).find((button) => button.title === payloadLabel)
218
-
219
- if (option) {
220
- option.click()
221
- document.documentElement.removeAttribute('data-wikex-forwarding-block')
222
- return
223
- }
224
-
225
- if (attempt < 12) {
226
- window.setTimeout(() => selectPageBlockWhenReady(payloadLabel, attempt + 1), 50)
227
- return
228
- }
229
-
230
- document.documentElement.removeAttribute('data-wikex-forwarding-block')
231
- }
232
-
233
- const BlockPickerModal = ({
234
- onClose,
235
- onSelect,
236
- options,
237
- title,
238
- }: {
239
- onClose: () => void
240
- onSelect: (option: BlockPickerOption) => void
241
- options: readonly BlockPickerOption[]
242
- title: string
243
- }) => {
244
- useEffect(() => {
245
- const handleKeyDown = (event: globalThis.KeyboardEvent) => {
246
- if (event.key === 'Escape') onClose()
247
- }
248
-
249
- document.addEventListener('keydown', handleKeyDown)
250
- return () => document.removeEventListener('keydown', handleKeyDown)
251
- }, [onClose])
252
-
253
- return (
254
- <div className="wikex-block-picker" onMouseDown={onClose} role="presentation">
255
- <section
256
- aria-label={title}
257
- aria-modal="true"
258
- className="wikex-block-picker__dialog"
259
- onMouseDown={(event) => event.stopPropagation()}
260
- role="dialog"
261
- >
262
- <header className="wikex-block-picker__header">
263
- <div>
264
- <span>Thêm khối</span>
265
- <h2>{title}</h2>
266
- </div>
267
- <button aria-label="Đóng bộ chọn component" onClick={onClose} type="button">
268
- <svg aria-hidden="true" viewBox="0 0 24 24">
269
- <path d="M6 6l12 12M18 6 6 18" />
270
- </svg>
271
- </button>
272
- </header>
273
- <div className="wikex-block-picker__grid">
274
- {options.map((option) => (
275
- <button
276
- className="wikex-block-picker__card"
277
- key={option.slug}
278
- onClick={() => onSelect(option)}
279
- type="button"
280
- >
281
- <span
282
- aria-hidden="true"
283
- className={`wikex-block-picker__thumbnail${option.image ? '' : ' wikex-block-picker__thumbnail--empty'}`}
284
- style={option.image ? { backgroundImage: `url(${option.image})` } : undefined}
285
- />
286
- <span className="wikex-block-picker__copy">
287
- <strong>{option.label}</strong>
288
- <span>{option.description}</span>
289
- </span>
290
- </button>
291
- ))}
292
- </div>
293
- </section>
294
- </div>
295
- )
296
- }
297
-
298
- const getAdminBasePath = () => {
299
- const match = window.location.pathname.match(/^(.*\/admin)(?:\/|$)/)
300
- return match?.[1] ?? '/admin'
301
- }
302
-
303
- const getEditorDocumentTarget = () => {
304
- return getVisualEditorRoute(window.location.pathname)?.documentTarget ?? null
305
- }
306
-
307
- const getEditorScope = (pathname = window.location.pathname): VisualEditorScope =>
308
- getVisualEditorRoute(pathname)?.scope ?? 'content'
309
-
310
- type FieldControl = HTMLInputElement | HTMLTextAreaElement
311
-
312
- type EditorDocumentTarget = NonNullable<ReturnType<typeof getEditorDocumentTarget>>
313
-
314
- type RichTextControl = {
315
- editor: HTMLElement
316
- textNode: HTMLElement
317
- }
318
-
319
- const getFieldControl = (fieldPath: string | undefined, text: string) => {
320
- if (fieldPath) {
321
- const namedControl = Array.from(document.getElementsByName(fieldPath)).find(
322
- (control): control is FieldControl =>
323
- control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement,
324
- )
325
-
326
- if (namedControl) return namedControl
327
- }
328
-
329
- if (!text) return null
330
-
331
- const controls = Array.from(
332
- document.querySelectorAll<FieldControl>(
333
- '.collection-edit__main input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), .collection-edit__main textarea',
334
- ),
335
- )
336
-
337
- return controls.find((control) => normalizeText(control.value) === text) ?? null
338
- }
339
-
340
- const getFieldContainer = (control: FieldControl | null, fieldPath?: string) => {
341
- if (control) return control.closest<HTMLElement>('.field-type')
342
- if (!fieldPath) return null
343
-
344
- const blockMatch = fieldPath.match(/^(customLayout|layout)\.(\d+)(?:\.|$)/)
345
- if (blockMatch) {
346
- return document.getElementById(`${blockMatch[1]}-row-${Number(blockMatch[2])}`)
347
- }
348
-
349
- const rowMatch = fieldPath.match(/^([^.]+)\.(\d+)(?:\.|$)/)
350
- if (rowMatch) {
351
- const row = document.getElementById(`${rowMatch[1]}-row-${Number(rowMatch[2])}`)
352
- if (row) return row
353
- }
354
-
355
- return (
356
- document.getElementById(`field-${fieldPath.replaceAll('.', '__')}`) ??
357
- Array.from(document.querySelectorAll<HTMLElement>('[data-field-path]')).find(
358
- (field) => field.dataset.fieldPath === fieldPath,
359
- ) ??
360
- null
361
- )
362
- }
363
-
364
- const getRichTextControl = (
365
- fieldPath: string | undefined,
366
- text: string,
367
- ): RichTextControl | null => {
368
- if (!text) return null
369
-
370
- const scope =
371
- getFieldContainer(null, fieldPath) ?? document.querySelector('.collection-edit__main')
372
- if (!scope) return null
373
-
374
- const textNode = Array.from(
375
- scope.querySelectorAll<HTMLElement>('.rich-text-lexical [data-lexical-text="true"]'),
376
- ).find((node) => normalizeText(node.textContent || '') === normalizeText(text))
377
- const editor = textNode?.closest<HTMLElement>('[contenteditable="true"]')
378
-
379
- return textNode && editor ? { editor, textNode } : null
380
- }
381
-
382
- const expandEditorTarget = (target: HTMLElement | null) => {
383
- const row = target?.matches('[id*="-row-"]')
384
- ? target
385
- : target?.closest<HTMLElement>('[id*="-row-"]')
386
- const collapsible = row?.querySelector<HTMLElement>('.blocks-field__row, .array-field__row')
387
-
388
- if (collapsible?.classList.contains('collapsible--collapsed')) {
389
- collapsible.querySelector<HTMLButtonElement>('.collapsible__toggle')?.click()
390
- }
391
- }
392
-
393
- const focusRichTextValue = (scope: HTMLElement | null, text: string) => {
394
- if (!scope || !text) return false
395
-
396
- const textNode = Array.from(
397
- scope.querySelectorAll<HTMLElement>('.rich-text-lexical [data-lexical-text="true"]'),
398
- ).find((node) => normalizeText(node.textContent || '') === text)
399
- const editor = textNode?.closest<HTMLElement>('[contenteditable="true"]')
400
-
401
- if (!textNode || !editor) return false
402
-
403
- editor.focus({ preventScroll: true })
404
- const selection = window.getSelection()
405
- const range = document.createRange()
406
-
407
- range.selectNodeContents(textNode)
408
- selection?.removeAllRanges()
409
- selection?.addRange(range)
410
-
411
- return true
412
- }
413
-
414
- const inferEditableFieldPath = (element: HTMLElement, fieldPath?: string) => {
415
- if (!fieldPath) return undefined
416
-
417
- if (fieldPath === 'hero' || fieldPath === 'customHero') {
418
- return `${fieldPath}.richText`
419
- }
420
-
421
- const blockType = element.closest<HTMLElement>('[data-block-type]')?.dataset.blockType
422
-
423
- if (/^(customLayout|layout)\.\d+$/.test(fieldPath)) {
424
- if (blockType === 'cta') return `${fieldPath}.richText`
425
- if (blockType === 'archive') return `${fieldPath}.introContent`
426
- }
427
-
428
- return fieldPath
429
- }
430
-
431
- const isAggregateInspectorSelection = (
432
- documentTarget: EditorDocumentTarget | null,
433
- fieldPath?: string,
434
- ) => {
435
- if (!fieldPath) return false
436
-
437
- // A rendered block only exposes its row path until the block component maps a
438
- // concrete child field. Its properties therefore belong to the complete Payload
439
- // row, not to the text-style drawer for the clicked heading or paragraph.
440
- if (/^(customLayout|layout)\.\d+$/.test(fieldPath)) return true
441
-
442
- // Post rich text is one Lexical field containing paragraphs, headings and blocks.
443
- // Keep direct editing for a matched text node, but let "Thuộc tính" reveal the
444
- // complete editor so block controls and formatting remain available.
445
- return (
446
- documentTarget?.kind === 'collection' &&
447
- documentTarget.collection === 'posts' &&
448
- fieldPath === 'content'
449
- )
450
- }
451
-
452
- const updatePayloadControl = (control: FieldControl, value: string) => {
453
- const prototype =
454
- control instanceof HTMLTextAreaElement
455
- ? HTMLTextAreaElement.prototype
456
- : HTMLInputElement.prototype
457
- const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
458
-
459
- setter?.call(control, value)
460
- control.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
461
- control.dispatchEvent(new Event('change', { bubbles: true }))
462
- }
463
-
464
- const updateRichTextControl = ({ editor, textNode }: RichTextControl, value: string) => {
465
- editor.focus({ preventScroll: true })
466
-
467
- const selection = window.getSelection()
468
- const range = document.createRange()
469
- range.selectNodeContents(textNode)
470
- selection?.removeAllRanges()
471
- selection?.addRange(range)
472
-
473
- // Lexical observes this browser edit and updates its editor state, which keeps
474
- // formatting around the selected text intact and marks Payload's form modified.
475
- const updated = document.execCommand('insertText', false, value)
476
-
477
- if (!updated) {
478
- textNode.textContent = value
479
- editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
480
- }
481
-
482
- editor.dispatchEvent(new Event('change', { bubbles: true }))
483
- }
484
-
485
- const WixEditorChrome = ({ container, editor, previewWrapper, route, scope }: EditorTargets) => {
486
- const pagesHref = `${getAdminBasePath()}/collections/pages`
487
- const editorScopeRef = useRef(scope)
488
- editorScopeRef.current = scope
489
- const isGlobalEditor = scope === 'header' || scope === 'footer'
490
- const selectedControl = useRef<FieldControl | null>(null)
491
- const selectedRichTextControl = useRef<RichTextControl | null>(null)
492
- const selectedFieldPath = useRef<string | undefined>(undefined)
493
- const selectedGlobal = useRef<GlobalInspectorSelection | null>(null)
494
- const selectedPreviewElement = useRef<HTMLElement | null>(null)
495
- const selectedTextInspector = useRef<TextInspectorSelection | null>(null)
496
- const selectedTextResize = useRef<TextInspectorSelection | null>(null)
497
- const textWidthSaveController = useRef<AbortController | null>(null)
498
- const blockPickerTrigger = useRef<HTMLButtonElement | null>(null)
499
- const selectedText = useRef('')
500
- const revealPreviewField = useRef<(fieldPath: string, attempt?: number) => void>(() => undefined)
501
- const [globalInspectorSelection, setGlobalInspectorSelection] =
502
- useState<GlobalInspectorSelection | null>(null)
503
- const [textInspectorSelection, setTextInspectorSelection] =
504
- useState<TextInspectorSelection | null>(null)
505
- const [inspectorOpen, setInspectorOpen] = useState(false)
506
- const [blockPickerScope, setBlockPickerScope] = useState<BlockPickerScope | null>(null)
507
- const [selectionLabel, setSelectionLabel] = useState('Chọn một phần tử trên trang')
508
-
509
- useEffect(() => {
510
- selectedControl.current = null
511
- selectedRichTextControl.current = null
512
- selectedFieldPath.current = undefined
513
- selectedGlobal.current = null
514
- selectedPreviewElement.current = null
515
- selectedTextInspector.current = null
516
- selectedTextResize.current = null
517
- textWidthSaveController.current?.abort()
518
- blockPickerTrigger.current = null
519
- selectedText.current = ''
520
- setGlobalInspectorSelection(null)
521
- setTextInspectorSelection(null)
522
- setInspectorOpen(false)
523
- setBlockPickerScope(null)
524
- setSelectionLabel('Chọn một phần tử trên trang')
525
- }, [previewWrapper, route])
526
-
527
- const openInspector = useCallback(() => {
528
- setInspectorOpen(true)
529
-
530
- if (selectedGlobal.current) {
531
- setGlobalInspectorSelection({ ...selectedGlobal.current })
532
- setTextInspectorSelection(null)
533
- return
534
- }
535
-
536
- if (selectedTextInspector.current) {
537
- setGlobalInspectorSelection(null)
538
- setTextInspectorSelection({ ...selectedTextInspector.current })
539
- return
540
- }
541
-
542
- setGlobalInspectorSelection(null)
543
- setTextInspectorSelection(null)
544
-
545
- const revealSelectedField = (attempt = 0) => {
546
- const fieldPath = selectedFieldPath.current
547
- const target = getFieldContainer(selectedControl.current, fieldPath)
548
-
549
- if (!target && attempt < 12) {
550
- if (fieldPath?.startsWith('layout.')) activateEditorTab('content')
551
- if (fieldPath === 'hero' || fieldPath?.startsWith('hero.')) activateEditorTab('hero')
552
- window.setTimeout(() => revealSelectedField(attempt + 1), 100)
553
- return
554
- }
555
-
556
- scrollEditorTo(target)
557
- expandEditorTarget(target)
558
-
559
- window.setTimeout(() => {
560
- const control = getFieldControl(fieldPath, selectedText.current)
561
-
562
- if (control) {
563
- selectedControl.current = control
564
- control.focus({ preventScroll: true })
565
- control.select()
566
- return
567
- }
568
-
569
- const refreshedTarget = getFieldContainer(null, fieldPath) ?? target
570
- focusRichTextValue(refreshedTarget, selectedText.current)
571
- }, 180)
572
- }
573
-
574
- window.setTimeout(revealSelectedField, 80)
575
- }, [])
576
-
577
- const applyGlobalInspectorValues = useCallback((values: InspectorValues) => {
578
- const element = selectedPreviewElement.current
579
- if (!element) return
580
-
581
- const globalSelection = selectedGlobal.current
582
- const matchingElements = globalSelection
583
- ? getMatchingPreviewFields(element, globalSelection.fieldPath)
584
- : [element]
585
-
586
- for (const matchingElement of matchingElements) {
587
- matchingElement.innerText = values.text
588
- const interactiveElement = matchingElement.closest<HTMLElement>('a, button')
589
-
590
- if (interactiveElement) {
591
- interactiveElement.style.backgroundColor = values.backgroundColor
592
- interactiveElement.style.color = values.textColor
593
-
594
- const resolvedURL =
595
- globalSelection?.scope === 'settings' && globalSelection.fieldPath === 'email'
596
- ? `mailto:${values.text}`
597
- : values.url
598
-
599
- if (interactiveElement instanceof HTMLAnchorElement && resolvedURL) {
600
- interactiveElement.setAttribute('href', resolvedURL)
601
- }
602
- }
603
- }
604
-
605
- selectedText.current = values.text
606
- if (globalSelection) {
607
- selectedGlobal.current = {
608
- ...globalSelection,
609
- href: values.url || globalSelection.href,
610
- text: values.text,
611
- }
612
- }
613
- setSelectionLabel(`Đã cập nhật · ${selectedGlobal.current?.scope || 'nội dung dùng chung'}`)
614
- }, [])
615
-
616
- const applyTextInspectorValues = useCallback((values: TextStyleValue) => {
617
- const element = selectedPreviewElement.current
618
- const selection = selectedTextInspector.current
619
- if (!element || !selection) return
620
-
621
- const style = getTextStyleCSS(values)
622
- const matchingElements = getMatchingPreviewFields(element, selection.fieldPath)
623
-
624
- for (const matchingElement of matchingElements.length ? matchingElements : [element]) {
625
- if ('fontFamily' in values) matchingElement.style.fontFamily = style.fontFamily || ''
626
- if ('lineHeight' in values) matchingElement.style.lineHeight = style.lineHeight || ''
627
- }
628
-
629
- setSelectionLabel(`Đã cập nhật kiểu chữ · ${selection.fieldPath}`)
630
- }, [])
631
-
632
- const applyTextInspectorText = useCallback((value: string) => {
633
- const element = selectedPreviewElement.current
634
- const control = selectedControl.current
635
- const selection = selectedTextInspector.current
636
- if (!element || !control || !selection) return
637
-
638
- const matchingElements = getMatchingPreviewFields(element, selection.fieldPath)
639
- for (const matchingElement of matchingElements.length ? matchingElements : [element]) {
640
- matchingElement.innerText = value
641
- }
642
-
643
- updatePayloadControl(
644
- control,
645
- control instanceof HTMLInputElement ? value.replace(/\s+/g, ' ') : value,
646
- )
647
- selectedText.current = value
648
- selectedTextInspector.current = { ...selection, text: value }
649
- setSelectionLabel(`Đang tự động lưu nội dung · ${selection.fieldPath}`)
650
-
651
- window.requestAnimationFrame(() => {
652
- element.ownerDocument.defaultView?.dispatchEvent(new Event('resize'))
653
- })
654
- }, [])
655
-
656
- const openElements = useCallback(() => {
657
- setGlobalInspectorSelection(null)
658
- setTextInspectorSelection(null)
659
- // Pages and posts share the same visual picker. Only the final adapter differs:
660
- // blocks fields use Payload's drawer command, while post content uses Lexical.
661
- setInspectorOpen(false)
662
-
663
- const documentTarget = getEditorDocumentTarget()
664
-
665
- if (documentTarget?.kind === 'collection' && documentTarget.collection === 'posts') {
666
- setSelectionLabel('Thêm component vào nội dung bài viết')
667
- window.setTimeout(
668
- () =>
669
- getPostBlockTriggerWhenReady((trigger) => {
670
- blockPickerTrigger.current = trigger
671
- setBlockPickerScope('post')
672
- }),
673
- 80,
674
- )
675
- return
676
- }
677
-
678
- setSelectionLabel('Thêm component vào bố cục trang')
679
- window.setTimeout(
680
- () =>
681
- getPageBlockTrigger((trigger) => {
682
- blockPickerTrigger.current = trigger
683
- setBlockPickerScope('page')
684
- }),
685
- 80,
686
- )
687
- }, [])
688
-
689
- const closeBlockPicker = useCallback(() => setBlockPickerScope(null), [])
690
-
691
- const selectBlock = useCallback(
692
- (option: BlockPickerOption) => {
693
- const trigger = blockPickerTrigger.current
694
- const scopeToInsert = blockPickerScope
695
- setBlockPickerScope(null)
696
-
697
- if (!trigger?.isConnected || !scopeToInsert) return
698
-
699
- if (scopeToInsert === 'post') {
700
- window.setTimeout(() => selectPostBlockWhenReady(trigger, `block-${option.slug}`), 80)
701
- return
702
- }
703
-
704
- document.documentElement.setAttribute('data-wikex-forwarding-block', 'true')
705
- trigger.click()
706
- window.setTimeout(() => selectPageBlockWhenReady(option.payloadLabel ?? option.label), 50)
707
- },
708
- [blockPickerScope],
709
- )
710
-
711
- const openSections = useCallback(() => {
712
- setGlobalInspectorSelection(null)
713
- setTextInspectorSelection(null)
714
- setInspectorOpen(true)
715
-
716
- const revealSections = (attempt = 0) => {
717
- const documentTarget = getEditorDocumentTarget()
718
- const isPostEditor =
719
- documentTarget?.kind === 'collection' && documentTarget.collection === 'posts'
720
- const target = isPostEditor ? getVisibleRichTextTarget() : getVisibleBlocksField()
721
-
722
- if (!target && attempt < 12) {
723
- activateEditorTab('content')
724
- window.setTimeout(() => revealSections(attempt + 1), 100)
725
- return
726
- }
727
-
728
- const resolvedTarget = target ?? getVisibleSectionsTarget()
729
- scrollEditorTo(resolvedTarget)
730
- if (!isPostEditor) expandEditorTarget(target)
731
- }
732
-
733
- window.setTimeout(revealSections, 80)
734
- }, [])
735
-
736
- useEffect(() => {
737
- editor.classList.add('wikex-visual-editor')
738
- editor.setAttribute('data-wikex-visual-editor', 'true')
739
- editor.setAttribute('data-wikex-editor-route', route)
740
-
741
- return () => {
742
- editor.classList.remove('wikex-visual-editor', 'wikex-editor--inspecting')
743
- editor.removeAttribute('data-wikex-visual-editor')
744
- editor.removeAttribute('data-wikex-editor-route')
745
- editor.removeAttribute('data-wikex-inspecting')
746
- document.documentElement.removeAttribute('data-wikex-forwarding-block')
747
- }
748
- }, [editor, route])
749
-
750
- useEffect(() => {
751
- editor.classList.toggle('wikex-editor--inspecting', inspectorOpen)
752
- editor.setAttribute('data-wikex-inspecting', String(inspectorOpen))
753
- }, [editor, inspectorOpen])
754
-
755
- useEffect(() => {
756
- let activeIframe: HTMLIFrameElement | null = null
757
- let cleanupActiveIframe = () => undefined
758
- let bindFrame = 0
759
-
760
- const bindIframe = () => {
761
- const iframe = previewWrapper.querySelector<HTMLIFrameElement>('#live-preview-iframe')
762
- if (iframe === activeIframe) return
763
-
764
- cleanupActiveIframe()
765
- activeIframe = iframe
766
- if (!iframe) return
767
-
768
- let cleanupFrame = () => undefined
769
-
770
- const installFrameEditor = () => {
771
- cleanupFrame()
772
-
773
- let frameDocument: Document | null = null
774
-
775
- try {
776
- frameDocument = iframe.contentDocument
777
- } catch {
778
- return
779
- }
780
-
781
- if (!frameDocument?.body) return
782
-
783
- const style = frameDocument.createElement('style')
784
- style.dataset.wikexEditor = 'true'
785
- style.textContent = `
786
- #wikex-selection-box {
787
- border: 1.5px solid #a67c3f;
788
- border-radius: 4px;
789
- display: none;
790
- pointer-events: none;
791
- position: fixed;
792
- z-index: 30;
793
- }
794
- .wikex-text-resize-handle {
795
- align-items: center;
796
- background: #fff;
797
- border: 2px solid #a67c3f;
798
- border-radius: 999px;
799
- box-shadow: 0 3px 12px rgba(15,20,17,.22);
800
- cursor: ew-resize;
801
- display: none;
802
- height: 34px;
803
- justify-content: center;
804
- padding: 0;
805
- position: fixed;
806
- touch-action: none;
807
- transform: translate(-50%, -50%);
808
- width: 12px;
809
- z-index: 62;
810
- }
811
- .wikex-text-resize-handle::after {
812
- background: #a67c3f;
813
- border-radius: 99px;
814
- content: "";
815
- height: 18px;
816
- width: 2px;
817
- }
818
- .wikex-text-resize-handle:hover,
819
- .wikex-text-resize-handle[data-active="true"] {
820
- background: #f3dfb5;
821
- box-shadow: 0 0 0 4px rgba(166,124,63,.18), 0 3px 12px rgba(15,20,17,.22);
822
- }
823
- #wikex-text-width-label {
824
- background: #a67c3f;
825
- border-radius: 6px;
826
- color: #fff;
827
- display: none;
828
- font: 700 11px/1 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
829
- padding: 5px 7px;
830
- pointer-events: none;
831
- position: fixed;
832
- transform: translate(-50%, -100%);
833
- white-space: nowrap;
834
- z-index: 63;
835
- }
836
- #wikex-selection-toolbar {
837
- align-items: center;
838
- background: rgba(20,29,23,.96);
839
- border: 1px solid rgba(255,255,255,.12);
840
- border-radius: 12px;
841
- box-shadow: 0 14px 40px rgba(15,20,17,.24);
842
- color: #172019;
843
- display: none;
844
- font: 600 12px/1.2 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
845
- gap: 4px;
846
- max-width: calc(100vw - 24px);
847
- padding: 5px;
848
- position: fixed;
849
- z-index: 60;
850
- }
851
- #wikex-selection-toolbar span {
852
- color: rgba(255,255,255,.7);
853
- max-width: 220px;
854
- overflow: hidden;
855
- padding: 0 8px;
856
- text-overflow: ellipsis;
857
- white-space: nowrap;
858
- }
859
- #wikex-selection-toolbar button {
860
- background: rgba(255,255,255,.1);
861
- border: 1px solid transparent;
862
- border-radius: 8px;
863
- color: #fff;
864
- cursor: pointer;
865
- font: inherit;
866
- min-height: 30px;
867
- padding: 6px 10px;
868
- transition: background-color 140ms ease, border-color 140ms ease;
869
- white-space: nowrap;
870
- }
871
- #wikex-selection-toolbar button:hover { background: rgba(255,255,255,.17); }
872
- #wikex-selection-toolbar button[data-primary="true"] {
873
- background: #d2b477;
874
- color: #162019;
875
- }
876
- #wikex-selection-toolbar button[data-primary="true"]:hover { background: #e4cc9b; }
877
- #wikex-selection-toolbar button:disabled { cursor: not-allowed; opacity: .45; }
878
- [data-wikex-inline-editing="true"] {
879
- caret-color: #a67c3f;
880
- cursor: text !important;
881
- min-width: 1ch;
882
- outline: none !important;
883
- }
884
- [data-wikex-field]:is(h1,h2,h3,h4,h5,h6,p,span,strong),
885
- [data-wikex-field] :is(h1,h2,h3,h4,h5,h6,p,span,strong) { cursor: text; }
886
- [data-wikex-field]:is(h1,h2,h3,h4,h5,h6,p,span,strong):not([data-wikex-selected="true"]):hover {
887
- outline: 1px dashed rgba(166,124,63,.58);
888
- outline-offset: 4px;
889
- }
890
- .admin-bar { display: none !important; }
891
- html { --admin-bar-height: 0px !important; }
892
- body * { cursor: default; }
893
- body a, body button { cursor: pointer; }
894
- `
895
- frameDocument.head.appendChild(style)
896
-
897
- const selectionBox = frameDocument.createElement('div')
898
- selectionBox.id = 'wikex-selection-box'
899
- const leftResizeHandle = frameDocument.createElement('button')
900
- leftResizeHandle.ariaLabel = 'Kéo để đổi chiều rộng từ bên trái'
901
- leftResizeHandle.className = 'wikex-text-resize-handle'
902
- leftResizeHandle.dataset.side = 'left'
903
- leftResizeHandle.title = 'Kéo để đổi chiều rộng · Nhấp đúp để trả về tự động'
904
- leftResizeHandle.type = 'button'
905
- const rightResizeHandle = frameDocument.createElement('button')
906
- rightResizeHandle.ariaLabel = 'Kéo để đổi chiều rộng từ bên phải'
907
- rightResizeHandle.className = 'wikex-text-resize-handle'
908
- rightResizeHandle.dataset.side = 'right'
909
- rightResizeHandle.title = 'Kéo để đổi chiều rộng · Nhấp đúp để trả về tự động'
910
- rightResizeHandle.type = 'button'
911
- const widthLabel = frameDocument.createElement('span')
912
- widthLabel.id = 'wikex-text-width-label'
913
- const toolbar = frameDocument.createElement('div')
914
- toolbar.id = 'wikex-selection-toolbar'
915
- const label = frameDocument.createElement('span')
916
- const propertiesButton = frameDocument.createElement('button')
917
- propertiesButton.dataset.primary = 'true'
918
- propertiesButton.textContent = 'Thuộc tính'
919
- propertiesButton.type = 'button'
920
- toolbar.append(label, propertiesButton)
921
- frameDocument.body.append(
922
- selectionBox,
923
- leftResizeHandle,
924
- rightResizeHandle,
925
- widthLabel,
926
- toolbar,
927
- )
928
-
929
- let selectedElement: HTMLElement | null = null
930
- let inlineInputHandler: (() => void) | null = null
931
- let inlineBlurHandler: (() => void) | null = null
932
- let inlineKeyHandler: ((event: globalThis.KeyboardEvent) => void) | null = null
933
- let inlineOriginalText = ''
934
- const resizeHandles = [leftResizeHandle, rightResizeHandle]
935
-
936
- const hideResizeControls = () => {
937
- for (const handle of resizeHandles) handle.style.display = 'none'
938
- widthLabel.style.display = 'none'
939
- }
940
-
941
- const updateSelectionPosition = () => {
942
- if (!selectedElement?.isConnected) {
943
- selectionBox.style.display = 'none'
944
- toolbar.style.display = 'none'
945
- hideResizeControls()
946
- return
947
- }
948
-
949
- const bounds = selectedElement.getBoundingClientRect()
950
- const isHeaderSelection = Boolean(
951
- selectedElement.closest('header, [data-wikex-scope="header"]'),
952
- )
953
- const obstructedTop = isHeaderSelection
954
- ? 0
955
- : Array.from(
956
- frameDocument?.querySelectorAll<HTMLElement>(
957
- 'header, [data-wikex-scope="header"]',
958
- ) ?? [],
959
- ).reduce((top, candidate) => {
960
- const style = iframe.contentWindow?.getComputedStyle(candidate)
961
- const candidateBounds = candidate.getBoundingClientRect()
962
- const obstructsCanvas =
963
- (style?.position === 'fixed' || style?.position === 'sticky') &&
964
- candidateBounds.top <= top + 1 &&
965
- candidateBounds.bottom > 0
966
-
967
- return obstructsCanvas ? Math.max(top, candidateBounds.bottom) : top
968
- }, 0)
969
- const visibleLeft = Math.max(0, bounds.left)
970
- const visibleTop = Math.max(obstructedTop, 0, bounds.top)
971
- const visibleRight = Math.min(iframe.clientWidth, bounds.right)
972
- const visibleBottom = Math.min(iframe.clientHeight, bounds.bottom)
973
-
974
- if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
975
- selectionBox.style.display = 'none'
976
- toolbar.style.display = 'none'
977
- hideResizeControls()
978
- return
979
- }
980
-
981
- selectionBox.style.display = 'block'
982
- selectionBox.style.height = `${visibleBottom - visibleTop}px`
983
- selectionBox.style.left = `${visibleLeft}px`
984
- selectionBox.style.top = `${visibleTop}px`
985
- selectionBox.style.width = `${visibleRight - visibleLeft}px`
986
-
987
- const canResizeText = Boolean(selectedTextResize.current)
988
- for (const [handle, horizontalPosition] of [
989
- [leftResizeHandle, visibleLeft],
990
- [rightResizeHandle, visibleRight],
991
- ] as const) {
992
- handle.style.display = canResizeText ? 'flex' : 'none'
993
- handle.style.left = `${Math.max(8, Math.min(iframe.clientWidth - 8, horizontalPosition))}px`
994
- handle.style.top = `${visibleTop + (visibleBottom - visibleTop) / 2}px`
995
- }
996
-
997
- toolbar.style.display = 'flex'
998
- const toolbarBounds = toolbar.getBoundingClientRect()
999
- const toolbarWidth = Math.min(toolbarBounds.width || 260, iframe.clientWidth - 16)
1000
- const toolbarHeight = toolbarBounds.height || 42
1001
- const belowTop = visibleBottom + 8
1002
- const aboveTop = visibleTop - toolbarHeight - 8
1003
- const toolbarTop =
1004
- belowTop + toolbarHeight <= iframe.clientHeight
1005
- ? belowTop
1006
- : aboveTop >= obstructedTop + 8
1007
- ? aboveTop
1008
- : Math.min(visibleTop + 8, iframe.clientHeight - toolbarHeight - 8)
1009
-
1010
- toolbar.style.left = `${Math.max(8, Math.min(visibleLeft, iframe.clientWidth - toolbarWidth - 8))}px`
1011
- toolbar.style.top = `${Math.max(obstructedTop + 8, toolbarTop)}px`
1012
- }
1013
-
1014
- type ActiveTextResize = {
1015
- contentLeft: number
1016
- contentWidth: number
1017
- element: HTMLElement
1018
- originalStyle: {
1019
- boxSizing: string
1020
- marginLeft: string
1021
- marginRight: string
1022
- maxWidth: string
1023
- width: string
1024
- }
1025
- selection: TextInspectorSelection
1026
- side: 'left' | 'right'
1027
- startLeft: number
1028
- startRight: number
1029
- width: number
1030
- widthOffset: number
1031
- }
1032
-
1033
- let activeTextResize: ActiveTextResize | null = null
1034
-
1035
- const startTextResize = (
1036
- event: globalThis.PointerEvent,
1037
- side: ActiveTextResize['side'],
1038
- handle: HTMLButtonElement,
1039
- ) => {
1040
- const element = selectedElement
1041
- const selection = selectedTextResize.current
1042
- const parent = element?.parentElement
1043
- if (!element || !selection || !parent) return
1044
-
1045
- event.preventDefault()
1046
- event.stopPropagation()
1047
- event.stopImmediatePropagation()
1048
- stopInlineEditing(true)
1049
-
1050
- const bounds = element.getBoundingClientRect()
1051
- const parentBounds = parent.getBoundingClientRect()
1052
- const parentStyle = iframe.contentWindow?.getComputedStyle(parent)
1053
- const paddingLeft = Number.parseFloat(parentStyle?.paddingLeft || '0') || 0
1054
- const paddingRight = Number.parseFloat(parentStyle?.paddingRight || '0') || 0
1055
- const contentLeft = parentBounds.left + paddingLeft
1056
- const contentWidth = Math.max(
1057
- MIN_TEXT_WIDTH_PX,
1058
- parentBounds.width - paddingLeft - paddingRight,
1059
- )
1060
- const contentRight = contentLeft + contentWidth
1061
- const startLeft = Math.max(contentLeft, Math.min(bounds.left, contentRight))
1062
- const startRight = Math.max(startLeft, Math.min(bounds.right, contentRight))
1063
-
1064
- activeTextResize = {
1065
- contentLeft,
1066
- contentWidth,
1067
- element,
1068
- originalStyle: {
1069
- boxSizing: element.style.boxSizing,
1070
- marginLeft: element.style.marginLeft,
1071
- marginRight: element.style.marginRight,
1072
- maxWidth: element.style.maxWidth,
1073
- width: element.style.width,
1074
- },
1075
- selection,
1076
- side,
1077
- startLeft,
1078
- startRight,
1079
- width: Math.max(MIN_TEXT_WIDTH_PX, startRight - startLeft),
1080
- widthOffset: Math.max(0, startLeft - contentLeft),
1081
- }
1082
-
1083
- handle.dataset.active = 'true'
1084
- handle.setPointerCapture(event.pointerId)
1085
- frameDocument.body.style.cursor = 'ew-resize'
1086
- frameDocument.body.style.userSelect = 'none'
1087
- widthLabel.style.display = 'block'
1088
- widthLabel.style.left = `${bounds.left + bounds.width / 2}px`
1089
- widthLabel.style.top = `${Math.max(28, bounds.top - 8)}px`
1090
- widthLabel.textContent = `${Math.round(activeTextResize.width)} px`
1091
- setSelectionLabel(`Đang chỉnh chiều rộng · ${selection.fieldPath}`)
1092
- }
1093
-
1094
- const moveTextResize = (event: globalThis.PointerEvent) => {
1095
- const resize = activeTextResize
1096
- if (!resize) return
1097
-
1098
- event.preventDefault()
1099
- event.stopPropagation()
1100
- const minimumWidth = Math.min(MIN_TEXT_WIDTH_PX, resize.contentWidth)
1101
- const contentRight = resize.contentLeft + resize.contentWidth
1102
- const nextLeft =
1103
- resize.side === 'left'
1104
- ? Math.max(
1105
- resize.contentLeft,
1106
- Math.min(event.clientX, resize.startRight - minimumWidth),
1107
- )
1108
- : resize.startLeft
1109
- const nextRight =
1110
- resize.side === 'right'
1111
- ? Math.min(contentRight, Math.max(event.clientX, resize.startLeft + minimumWidth))
1112
- : resize.startRight
1113
- const nextWidth = Math.max(minimumWidth, nextRight - nextLeft)
1114
- const width = Math.min(100, Math.max(5, (nextWidth / resize.contentWidth) * 100))
1115
- const widthOffset = Math.min(
1116
- 100 - width,
1117
- Math.max(0, ((nextLeft - resize.contentLeft) / resize.contentWidth) * 100),
1118
- )
1119
-
1120
- resize.width = Math.round(width * 1000) / 1000
1121
- resize.widthOffset = Math.round(widthOffset * 1000) / 1000
1122
- resize.element.style.boxSizing = 'border-box'
1123
- resize.element.style.marginLeft = `${resize.widthOffset}%`
1124
- resize.element.style.marginRight = 'auto'
1125
- resize.element.style.maxWidth = 'none'
1126
- resize.element.style.width = `${resize.width}%`
1127
-
1128
- updateSelectionPosition()
1129
- const resizedBounds = resize.element.getBoundingClientRect()
1130
- widthLabel.style.display = 'block'
1131
- widthLabel.style.left = `${resizedBounds.left + resizedBounds.width / 2}px`
1132
- widthLabel.style.top = `${Math.max(28, resizedBounds.top - 8)}px`
1133
- widthLabel.textContent = `${Math.round(resizedBounds.width)} px`
1134
- }
1135
-
1136
- const finishTextResize = (event: globalThis.PointerEvent) => {
1137
- const resize = activeTextResize
1138
- if (!resize) return
1139
-
1140
- event.preventDefault()
1141
- event.stopPropagation()
1142
- activeTextResize = null
1143
- for (const handle of resizeHandles) delete handle.dataset.active
1144
- frameDocument.body.style.cursor = ''
1145
- frameDocument.body.style.userSelect = ''
1146
- widthLabel.style.display = 'none'
1147
-
1148
- textWidthSaveController.current?.abort()
1149
- const controller = new AbortController()
1150
- textWidthSaveController.current = controller
1151
- setSelectionLabel(`Đang tự động lưu chiều rộng · ${resize.selection.fieldPath}`)
1152
-
1153
- void saveTextStylePatch(
1154
- resize.selection,
1155
- { width: resize.width, widthOffset: resize.widthOffset },
1156
- controller.signal,
1157
- )
1158
- .then(() => {
1159
- if (controller.signal.aborted) return
1160
- setSelectionLabel(`Đã lưu chiều rộng · ${Math.round(resize.width)}%`)
1161
- })
1162
- .catch((error: unknown) => {
1163
- if (controller.signal.aborted) return
1164
- Object.assign(resize.element.style, resize.originalStyle)
1165
- updateSelectionPosition()
1166
- setSelectionLabel(
1167
- error instanceof Error
1168
- ? `Không thể lưu chiều rộng · ${error.message}`
1169
- : 'Không thể lưu chiều rộng · Thử lại',
1170
- )
1171
- })
1172
- }
1173
-
1174
- const bindResizeHandle = (handle: HTMLButtonElement, side: ActiveTextResize['side']) => {
1175
- const handlePointerDown = (event: globalThis.PointerEvent) =>
1176
- startTextResize(event, side, handle)
1177
- const stopHandleClick = (event: Event) => {
1178
- event.preventDefault()
1179
- event.stopPropagation()
1180
- event.stopImmediatePropagation()
1181
- }
1182
- const resetTextWidth = (event: MouseEvent) => {
1183
- stopHandleClick(event)
1184
- const element = selectedElement
1185
- const selection = selectedTextResize.current
1186
- if (!element || !selection) return
1187
-
1188
- const previousStyle = {
1189
- boxSizing: element.style.boxSizing,
1190
- marginLeft: element.style.marginLeft,
1191
- marginRight: element.style.marginRight,
1192
- maxWidth: element.style.maxWidth,
1193
- width: element.style.width,
1194
- }
1195
- element.style.boxSizing = ''
1196
- element.style.marginLeft = ''
1197
- element.style.marginRight = ''
1198
- element.style.maxWidth = ''
1199
- element.style.width = ''
1200
- updateSelectionPosition()
1201
-
1202
- textWidthSaveController.current?.abort()
1203
- const controller = new AbortController()
1204
- textWidthSaveController.current = controller
1205
- setSelectionLabel(`Đang trả chiều rộng về tự động · ${selection.fieldPath}`)
1206
-
1207
- void saveTextStylePatch(
1208
- selection,
1209
- { width: undefined, widthOffset: undefined },
1210
- controller.signal,
1211
- )
1212
- .then(() => {
1213
- if (!controller.signal.aborted) {
1214
- setSelectionLabel(`Chiều rộng tự động · ${selection.fieldPath}`)
1215
- }
1216
- })
1217
- .catch((error: unknown) => {
1218
- if (controller.signal.aborted) return
1219
- Object.assign(element.style, previousStyle)
1220
- updateSelectionPosition()
1221
- setSelectionLabel(
1222
- error instanceof Error
1223
- ? `Không thể đặt lại chiều rộng · ${error.message}`
1224
- : 'Không thể đặt lại chiều rộng · Thử lại',
1225
- )
1226
- })
1227
- }
1228
-
1229
- handle.addEventListener('click', stopHandleClick)
1230
- handle.addEventListener('dblclick', resetTextWidth)
1231
- handle.addEventListener('pointerdown', handlePointerDown)
1232
- handle.addEventListener('pointermove', moveTextResize)
1233
- handle.addEventListener('pointerup', finishTextResize)
1234
- handle.addEventListener('pointercancel', finishTextResize)
1235
-
1236
- return () => {
1237
- handle.removeEventListener('click', stopHandleClick)
1238
- handle.removeEventListener('dblclick', resetTextWidth)
1239
- handle.removeEventListener('pointerdown', handlePointerDown)
1240
- handle.removeEventListener('pointermove', moveTextResize)
1241
- handle.removeEventListener('pointerup', finishTextResize)
1242
- handle.removeEventListener('pointercancel', finishTextResize)
1243
- }
1244
- }
1245
-
1246
- const unbindLeftResizeHandle = bindResizeHandle(leftResizeHandle, 'left')
1247
- const unbindRightResizeHandle = bindResizeHandle(rightResizeHandle, 'right')
1248
-
1249
- const stopInlineEditing = (commit = true) => {
1250
- const element = selectedElement
1251
- if (!element?.hasAttribute('data-wikex-inline-editing')) return
1252
-
1253
- if (inlineInputHandler) element.removeEventListener('input', inlineInputHandler)
1254
- if (inlineBlurHandler) element.removeEventListener('blur', inlineBlurHandler)
1255
- if (inlineKeyHandler) element.removeEventListener('keydown', inlineKeyHandler)
1256
-
1257
- const nextText = commit ? readInlineText(element) : inlineOriginalText
1258
- if (!commit) element.innerText = inlineOriginalText
1259
- element.removeAttribute('contenteditable')
1260
- element.removeAttribute('data-wikex-inline-editing')
1261
-
1262
- if (commit && nextText !== inlineOriginalText) {
1263
- const control = selectedControl.current
1264
- const richTextControl = selectedRichTextControl.current
1265
- const globalSelection = selectedGlobal.current
1266
-
1267
- if (control) {
1268
- updatePayloadControl(
1269
- control,
1270
- control instanceof HTMLInputElement ? normalizeText(nextText) : nextText,
1271
- )
1272
- } else if (richTextControl) {
1273
- updateRichTextControl(richTextControl, nextText)
1274
- } else if (globalSelection) {
1275
- const previousText = inlineOriginalText
1276
- const savedElement = element
1277
-
1278
- setSelectionLabel(`Đang lưu · ${globalSelection.scope}`)
1279
- void updateGlobalFields(globalSelection.scope, [
1280
- { path: globalSelection.fieldPath, value: nextText },
1281
- ])
1282
- .then(() => {
1283
- for (const matchingElement of getMatchingPreviewFields(
1284
- savedElement,
1285
- globalSelection.fieldPath,
1286
- )) {
1287
- matchingElement.innerText = nextText
1288
-
1289
- if (
1290
- globalSelection.scope === 'settings' &&
1291
- globalSelection.fieldPath === 'email'
1292
- ) {
1293
- matchingElement
1294
- .closest<HTMLAnchorElement>('a')
1295
- ?.setAttribute('href', `mailto:${nextText}`)
1296
- }
1297
- }
1298
- selectedGlobal.current = { ...globalSelection, text: nextText }
1299
- setSelectionLabel(`Đã cập nhật · ${globalSelection.scope}`)
1300
- })
1301
- .catch(() => {
1302
- if (savedElement.isConnected && readInlineText(savedElement) === nextText) {
1303
- savedElement.innerText = previousText
1304
- }
1305
- selectedText.current = previousText
1306
- setSelectionLabel('Không thể lưu nội dung dùng chung · Thử lại')
1307
- })
1308
- }
1309
-
1310
- selectedText.current = nextText
1311
- if (!globalSelection) {
1312
- setSelectionLabel(`Đã cập nhật · ${selectedFieldPath.current || 'nội dung'}`)
1313
- }
1314
- } else {
1315
- setSelectionLabel(selectedFieldPath.current || 'Chọn một phần tử trên trang')
1316
- }
1317
-
1318
- propertiesButton.textContent = 'Thuộc tính'
1319
- inlineInputHandler = null
1320
- inlineBlurHandler = null
1321
- inlineKeyHandler = null
1322
- }
1323
-
1324
- const startInlineEditing = () => {
1325
- if (!selectedElement) return
1326
-
1327
- if (
1328
- !selectedControl.current &&
1329
- !selectedRichTextControl.current &&
1330
- !selectedGlobal.current
1331
- ) {
1332
- openInspector()
1333
- return
1334
- }
1335
-
1336
- stopInlineEditing(false)
1337
- inlineOriginalText = readInlineText(selectedElement)
1338
- selectedElement.setAttribute('contenteditable', 'plaintext-only')
1339
- selectedElement.dataset.wikexInlineEditing = 'true'
1340
- selectedElement.focus()
1341
- inlineInputHandler = () => {
1342
- updateSelectionPosition()
1343
- setSelectionLabel('Đang sửa trực tiếp · Enter để lưu · Esc để hủy')
1344
- }
1345
- inlineBlurHandler = () => stopInlineEditing(true)
1346
- inlineKeyHandler = (event) => {
1347
- if (event.key === 'Escape') {
1348
- event.preventDefault()
1349
- stopInlineEditing(false)
1350
- return
1351
- }
1352
-
1353
- if (event.key === 'Enter' && !event.shiftKey) {
1354
- event.preventDefault()
1355
- stopInlineEditing(true)
1356
- }
1357
- }
1358
- selectedElement.addEventListener('input', inlineInputHandler)
1359
- selectedElement.addEventListener('blur', inlineBlurHandler)
1360
- selectedElement.addEventListener('keydown', inlineKeyHandler)
1361
- propertiesButton.textContent = 'Thuộc tính'
1362
-
1363
- const selection = frameDocument?.getSelection()
1364
- const range = frameDocument?.createRange()
1365
- if (selection && range) {
1366
- range.selectNodeContents(selectedElement)
1367
- selection.removeAllRanges()
1368
- selection.addRange(range)
1369
- }
1370
- }
1371
-
1372
- const selectElement = (element: HTMLElement) => {
1373
- stopInlineEditing(true)
1374
- selectedElement?.removeAttribute('data-wikex-selected')
1375
- selectedElement = element
1376
- selectedElement.dataset.wikexSelected = 'true'
1377
- selectedPreviewElement.current = element
1378
- const baseFieldPath =
1379
- element.dataset.wikexField ??
1380
- element.closest<HTMLElement>('[data-wikex-field]')?.dataset.wikexField
1381
- const text = normalizeText(element.innerText || element.textContent || '')
1382
- const fieldPath = inferEditableFieldPath(element, baseFieldPath)
1383
- const documentTarget = getEditorDocumentTarget()
1384
- const isAggregateSelection = isAggregateInspectorSelection(documentTarget, fieldPath)
1385
- const supportsTextStyles = Boolean(text) && element.matches(EDITABLE_PREVIEW_SELECTOR)
1386
- const elementStyleKey = supportsTextStyles
1387
- ? fieldPath || getEditableElementStyleKey(element)
1388
- : undefined
1389
- const resizeStyleKey = isAggregateSelection
1390
- ? getEditableElementStyleKey(element)
1391
- : elementStyleKey
1392
- const elementScope =
1393
- element.closest<HTMLElement>('[data-wikex-scope]')?.dataset.wikexScope
1394
- const configuredGlobalScope =
1395
- element.dataset.wikexGlobal ??
1396
- element.closest<HTMLElement>('[data-wikex-global]')?.dataset.wikexGlobal
1397
- const fieldGlobalScope = isEditableGlobalScope(configuredGlobalScope)
1398
- ? configuredGlobalScope
1399
- : isEditableGlobalScope(elementScope)
1400
- ? elementScope
1401
- : undefined
1402
- const externalGlobalScope =
1403
- fieldGlobalScope && fieldGlobalScope !== editorScopeRef.current
1404
- ? fieldGlobalScope
1405
- : undefined
1406
- const control = externalGlobalScope ? null : getFieldControl(fieldPath, text)
1407
- const richTextControl = control ? null : getRichTextControl(fieldPath, text)
1408
- const interactiveElement = element.closest<HTMLAnchorElement | HTMLButtonElement>(
1409
- 'a, button',
1410
- )
1411
- const globalSelection =
1412
- externalGlobalScope && fieldPath
1413
- ? {
1414
- fieldPath,
1415
- href:
1416
- interactiveElement instanceof HTMLAnchorElement
1417
- ? interactiveElement.getAttribute('href') || undefined
1418
- : undefined,
1419
- scope: externalGlobalScope,
1420
- supportsLink: Boolean(interactiveElement),
1421
- text,
1422
- }
1423
- : null
1424
- const textInspector =
1425
- !globalSelection && !isAggregateSelection && documentTarget && elementStyleKey
1426
- ? {
1427
- ...documentTarget,
1428
- canEditText: Boolean(control),
1429
- fieldPath: elementStyleKey,
1430
- multiline: control instanceof HTMLTextAreaElement,
1431
- tagName: element.tagName.toLowerCase(),
1432
- text,
1433
- }
1434
- : null
1435
- const textResize =
1436
- !globalSelection &&
1437
- documentTarget &&
1438
- resizeStyleKey &&
1439
- element.matches(RESIZABLE_TEXT_SELECTOR)
1440
- ? {
1441
- ...documentTarget,
1442
- canEditText: false,
1443
- fieldPath: resizeStyleKey,
1444
- multiline: false,
1445
- tagName: element.tagName.toLowerCase(),
1446
- text,
1447
- }
1448
- : null
1449
- const tagLabel = element.tagName.toLowerCase()
1450
- const semanticLabel =
1451
- tagLabel === 'img' || element.querySelector('img')
1452
- ? 'Hình ảnh'
1453
- : /^h[1-6]$/.test(tagLabel)
1454
- ? 'Tiêu đề'
1455
- : tagLabel === 'p'
1456
- ? 'Đoạn văn'
1457
- : interactiveElement
1458
- ? 'Liên kết / nút'
1459
- : 'Nội dung'
1460
- const readableLabel =
1461
- fieldPath || control?.name || `${semanticLabel}${text ? ` · ${text.slice(0, 36)}` : ''}`
1462
-
1463
- selectedControl.current = control
1464
- selectedRichTextControl.current = externalGlobalScope ? null : richTextControl
1465
- selectedFieldPath.current = fieldPath
1466
- selectedGlobal.current = globalSelection
1467
- selectedTextInspector.current = textInspector
1468
- selectedTextResize.current = textResize
1469
- setGlobalInspectorSelection(globalSelection)
1470
- setTextInspectorSelection(textInspector)
1471
- selectedText.current = text
1472
- // Every selectable preview element must have a way back to its editor.
1473
- // Elements without a precise CMS mapping fall back to the aggregate form.
1474
- propertiesButton.disabled = false
1475
- label.textContent = readableLabel
1476
- setSelectionLabel(readableLabel)
1477
- updateSelectionPosition()
1478
- }
1479
-
1480
- const revealFieldInPreview = (fieldPath: string, attempt = 0) => {
1481
- const target = Array.from(
1482
- frameDocument?.querySelectorAll<HTMLElement>('[data-wikex-field]') ?? [],
1483
- ).find((element) => element.dataset.wikexField === fieldPath)
1484
-
1485
- if (!target) {
1486
- if (attempt < 24) {
1487
- window.setTimeout(() => revealFieldInPreview(fieldPath, attempt + 1), 150)
1488
- }
1489
- return
1490
- }
1491
-
1492
- target.scrollIntoView({ behavior: 'smooth', block: 'center' })
1493
- selectElement(target)
1494
- }
1495
-
1496
- revealPreviewField.current = revealFieldInPreview
1497
-
1498
- const handleFrameClick = (event: Event) => {
1499
- const rawTarget = event.target
1500
- if (!rawTarget || typeof (rawTarget as HTMLElement).closest !== 'function') return
1501
- const rawElement = rawTarget as HTMLElement
1502
- if (rawElement.closest('#wikex-selection-toolbar, .wikex-text-resize-handle')) {
1503
- return
1504
- }
1505
- if (rawElement.closest('[data-wikex-inline-editing="true"]')) return
1506
-
1507
- const interactiveCandidate = rawElement.closest<HTMLElement>(
1508
- EDITABLE_INTERACTIVE_SELECTOR,
1509
- )
1510
- const fieldCandidate =
1511
- rawElement.closest<HTMLElement>('[data-wikex-field]') ??
1512
- interactiveCandidate?.querySelector<HTMLElement>('[data-wikex-field]')
1513
- const semanticCandidate = rawElement.closest<HTMLElement>(EDITABLE_PREVIEW_SELECTOR)
1514
- const candidate = fieldCandidate?.matches(EDITABLE_PREVIEW_SELECTOR)
1515
- ? fieldCandidate
1516
- : (semanticCandidate ?? fieldCandidate)
1517
- if (!candidate || candidate.closest('[data-wikex-no-edit]')) return
1518
-
1519
- const candidateScope =
1520
- candidate.closest<HTMLElement>('[data-wikex-scope]')?.dataset.wikexScope
1521
- const isSharedGlobalInPage =
1522
- editorScopeRef.current === 'content' &&
1523
- (candidateScope === 'header' || candidateScope === 'footer')
1524
- if (
1525
- candidateScope &&
1526
- candidateScope !== editorScopeRef.current &&
1527
- !isSharedGlobalInPage
1528
- ) {
1529
- return
1530
- }
1531
-
1532
- if (interactiveCandidate) event.preventDefault()
1533
- event.stopPropagation()
1534
- event.stopImmediatePropagation()
1535
- selectElement(candidate)
1536
- }
1537
-
1538
- const handleDoubleClick = (event: Event) => {
1539
- const eventTarget = event.target
1540
- if (eventTarget instanceof Element && eventTarget.closest('.wikex-text-resize-handle')) {
1541
- return
1542
- }
1543
- event.preventDefault()
1544
- startInlineEditing()
1545
- }
1546
-
1547
- const stopToolbarEvent = (event: Event) => event.stopPropagation()
1548
- const keepEditorFocus = (event: Event) => event.preventDefault()
1549
- const handlePropertiesClick = () => {
1550
- stopInlineEditing(true)
1551
- openInspector()
1552
- }
1553
- propertiesButton.addEventListener('click', handlePropertiesClick)
1554
- toolbar.addEventListener('click', stopToolbarEvent)
1555
- toolbar.addEventListener('pointerdown', keepEditorFocus)
1556
- frameDocument.addEventListener('click', handleFrameClick, true)
1557
- frameDocument.addEventListener('dblclick', handleDoubleClick, true)
1558
- frameDocument.addEventListener('scroll', updateSelectionPosition, true)
1559
- iframe.contentWindow?.addEventListener('resize', updateSelectionPosition)
1560
-
1561
- cleanupFrame = () => {
1562
- stopInlineEditing(false)
1563
- textWidthSaveController.current?.abort()
1564
- activeTextResize = null
1565
- if (frameDocument?.body) {
1566
- frameDocument.body.style.cursor = ''
1567
- frameDocument.body.style.userSelect = ''
1568
- }
1569
- selectedElement?.removeAttribute('data-wikex-selected')
1570
- selectedTextResize.current = null
1571
- unbindLeftResizeHandle()
1572
- unbindRightResizeHandle()
1573
- propertiesButton.removeEventListener('click', handlePropertiesClick)
1574
- toolbar.removeEventListener('click', stopToolbarEvent)
1575
- toolbar.removeEventListener('pointerdown', keepEditorFocus)
1576
- frameDocument?.removeEventListener('click', handleFrameClick, true)
1577
- frameDocument?.removeEventListener('dblclick', handleDoubleClick, true)
1578
- frameDocument?.removeEventListener('scroll', updateSelectionPosition, true)
1579
- iframe.contentWindow?.removeEventListener('resize', updateSelectionPosition)
1580
- selectionBox.remove()
1581
- leftResizeHandle.remove()
1582
- rightResizeHandle.remove()
1583
- widthLabel.remove()
1584
- toolbar.remove()
1585
- style.remove()
1586
-
1587
- if (revealPreviewField.current === revealFieldInPreview) {
1588
- revealPreviewField.current = () => undefined
1589
- }
1590
-
1591
- selectedPreviewElement.current = null
1592
- selectedTextInspector.current = null
1593
- }
1594
- }
1595
-
1596
- iframe.addEventListener('load', installFrameEditor)
1597
- installFrameEditor()
1598
-
1599
- cleanupActiveIframe = () => {
1600
- iframe.removeEventListener('load', installFrameEditor)
1601
- cleanupFrame()
1602
- }
1603
- }
1604
-
1605
- const scheduleBindIframe = () => {
1606
- if (bindFrame) return
1607
-
1608
- bindFrame = window.requestAnimationFrame(() => {
1609
- bindFrame = 0
1610
- bindIframe()
1611
- })
1612
- }
1613
-
1614
- bindIframe()
1615
- const observer = new MutationObserver(scheduleBindIframe)
1616
- observer.observe(previewWrapper, { childList: true, subtree: true })
1617
-
1618
- return () => {
1619
- observer.disconnect()
1620
- window.cancelAnimationFrame(bindFrame)
1621
- cleanupActiveIframe()
1622
- }
1623
- }, [openInspector, previewWrapper])
1624
-
1625
- useEffect(() => {
1626
- let activeBlocksFieldID = ''
1627
- let initialized = false
1628
- let knownRows = new Set<string>()
1629
- let revealTimer: number | undefined
1630
- let scanFrame = 0
1631
-
1632
- const scanRows = () => {
1633
- const blocksField = getVisibleBlocksField()
1634
- if (!blocksField) return
1635
-
1636
- const rows = Array.from(
1637
- blocksField.querySelectorAll<HTMLElement>(
1638
- '.blocks-field__rows > [id^="layout-row-"], .blocks-field__rows > [id^="customLayout-row-"]',
1639
- ),
1640
- )
1641
-
1642
- if (!initialized || activeBlocksFieldID !== blocksField.id) {
1643
- activeBlocksFieldID = blocksField.id
1644
- knownRows = new Set(rows.map((row) => row.id))
1645
- initialized = true
1646
- return
1647
- }
1648
-
1649
- if (!rows.length) {
1650
- knownRows.clear()
1651
- return
1652
- }
1653
-
1654
- const addedRows = rows.filter((row) => !knownRows.has(row.id))
1655
- knownRows = new Set(rows.map((row) => row.id))
1656
-
1657
- const addedRow = addedRows.at(-1)
1658
- const match = addedRow?.id.match(/^(customLayout|layout)-row-(\d+)$/)
1659
- if (!addedRow || !match) return
1660
-
1661
- const fieldPath = `${match[1]}.${match[2]}`
1662
- setSelectionLabel(fieldPath)
1663
- scrollEditorTo(addedRow)
1664
- window.clearTimeout(revealTimer)
1665
- revealTimer = window.setTimeout(() => revealPreviewField.current(fieldPath), 300)
1666
- }
1667
-
1668
- const scheduleScanRows = () => {
1669
- if (scanFrame) return
1670
-
1671
- scanFrame = window.requestAnimationFrame(() => {
1672
- scanFrame = 0
1673
- scanRows()
1674
- })
1675
- }
1676
-
1677
- scanRows()
1678
- const observer = new MutationObserver(scheduleScanRows)
1679
- observer.observe(document.body, { childList: true, subtree: true })
1680
-
1681
- return () => {
1682
- observer.disconnect()
1683
- window.cancelAnimationFrame(scanFrame)
1684
- window.clearTimeout(revealTimer)
1685
- }
1686
- }, [])
1687
-
1688
- return (
1689
- <>
1690
- {createPortal(
1691
- <>
1692
- <aside aria-label="Công cụ dựng trang" className="wikex-editor-rail">
1693
- <div aria-label="Wikex Editor" className="wikex-editor-rail__brand">
1694
- W
1695
- </div>
1696
- <button
1697
- className="wikex-editor-tool"
1698
- onClick={isGlobalEditor ? openInspector : openElements}
1699
- type="button"
1700
- >
1701
- <EditorIcon name="elements" />
1702
- <span>{isGlobalEditor ? 'Nội dung' : 'Thêm khối'}</span>
1703
- </button>
1704
- <button
1705
- className="wikex-editor-tool"
1706
- onClick={isGlobalEditor ? openInspector : openSections}
1707
- type="button"
1708
- >
1709
- <EditorIcon name="sections" />
1710
- <span>{isGlobalEditor ? 'Liên kết' : 'Bố cục'}</span>
1711
- </button>
1712
- <a className="wikex-editor-tool" href={pagesHref}>
1713
- <EditorIcon name="pages" />
1714
- <span>Trang</span>
1715
- </a>
1716
- </aside>
1717
- <div className="wikex-editor-canvas-title">
1718
- <strong>Trình chỉnh sửa Wikex</strong>
1719
- <span>{selectionLabel}</span>
1720
- <span className="wikex-editor-canvas-title__hint">Nhấp đúp văn bản để sửa</span>
1721
- </div>
1722
- </>,
1723
- previewWrapper,
1724
- )}
1725
- {inspectorOpen &&
1726
- globalInspectorSelection &&
1727
- createPortal(
1728
- <GlobalInlineInspector
1729
- key={`${globalInspectorSelection.scope}:${globalInspectorSelection.fieldPath}`}
1730
- onApply={applyGlobalInspectorValues}
1731
- selection={globalInspectorSelection}
1732
- />,
1733
- container,
1734
- )}
1735
- {inspectorOpen &&
1736
- textInspectorSelection &&
1737
- createPortal(
1738
- <TextStyleInspector
1739
- key={`${
1740
- textInspectorSelection.kind === 'collection'
1741
- ? `${textInspectorSelection.collection}:${textInspectorSelection.documentID}`
1742
- : `global:${textInspectorSelection.global}`
1743
- }:${textInspectorSelection.fieldPath}`}
1744
- onApply={applyTextInspectorValues}
1745
- onTextApply={applyTextInspectorText}
1746
- selection={textInspectorSelection}
1747
- />,
1748
- container,
1749
- )}
1750
- {inspectorOpen &&
1751
- createPortal(
1752
- <button
1753
- aria-label="Đóng bảng thuộc tính"
1754
- className="wikex-inspector-close"
1755
- onClick={() => setInspectorOpen(false)}
1756
- title="Đóng bảng thuộc tính"
1757
- type="button"
1758
- >
1759
- <svg aria-hidden="true" viewBox="0 0 24 24">
1760
- <path d="M6 6l12 12M18 6 6 18" />
1761
- </svg>
1762
- </button>,
1763
- container,
1764
- )}
1765
- {blockPickerScope &&
1766
- createPortal(
1767
- <BlockPickerModal
1768
- onClose={closeBlockPicker}
1769
- onSelect={selectBlock}
1770
- options={getBlockPickerOptions(blockPickerScope)}
1771
- title={
1772
- blockPickerScope === 'post'
1773
- ? 'Chọn component cho bài viết'
1774
- : 'Chọn component cho trang'
1775
- }
1776
- />,
1777
- document.body,
1778
- )}
1779
- </>
1780
- )
1781
- }
1782
-
1783
- const clampEditWidth = (width: number, containerWidth: number) => {
1784
- const maximumWidth = Math.max(MIN_EDIT_WIDTH, containerWidth - HANDLE_WIDTH - MIN_PREVIEW_WIDTH)
1785
-
1786
- return Math.min(Math.max(width, MIN_EDIT_WIDTH), maximumWidth)
1787
- }
1788
-
1789
- const readStoredRatio = () => {
1790
- const storedRatio = Number(
1791
- window.localStorage.getItem(STORAGE_KEY) ?? window.localStorage.getItem(LEGACY_STORAGE_KEY),
1792
- )
1793
-
1794
- return Number.isFinite(storedRatio) && storedRatio > 0 && storedRatio < 1
1795
- ? storedRatio
1796
- : DEFAULT_EDIT_RATIO
1797
- }
1798
-
1799
- const setEditWidth = (container: HTMLElement, requestedWidth: number) => {
1800
- const containerWidth = container.getBoundingClientRect().width
1801
- const width = clampEditWidth(requestedWidth, containerWidth)
1802
- const ratio = width / containerWidth
1803
-
1804
- container.style.setProperty('--live-preview-edit-width', `${width}px`)
1805
-
1806
- return ratio
1807
- }
1808
-
1809
- type DragState = {
1810
- containerLeft: number
1811
- pointerId: number
1812
- }
1813
-
1814
- const LivePreviewResizeHandle = ({ container }: { container: HTMLElement }) => {
1815
- const dragState = useRef<DragState | null>(null)
1816
- const latestRatio = useRef(DEFAULT_EDIT_RATIO)
1817
- const [isDragging, setIsDragging] = useState(false)
1818
- const [ratio, setRatio] = useState(DEFAULT_EDIT_RATIO)
1819
-
1820
- useEffect(() => {
1821
- const storedRatio = readStoredRatio()
1822
- const initialRatio = setEditWidth(
1823
- container,
1824
- container.getBoundingClientRect().width * storedRatio,
1825
- )
1826
- latestRatio.current = initialRatio
1827
- setRatio(initialRatio)
1828
-
1829
- const resizeObserver = new ResizeObserver(() => {
1830
- if (!dragState.current) {
1831
- const nextRatio = setEditWidth(
1832
- container,
1833
- container.getBoundingClientRect().width * readStoredRatio(),
1834
- )
1835
- latestRatio.current = nextRatio
1836
- setRatio(nextRatio)
1837
- }
1838
- })
1839
-
1840
- resizeObserver.observe(container)
1841
-
1842
- return () => resizeObserver.disconnect()
1843
- }, [container])
1844
-
1845
- const finishDragging = (event: PointerEvent<HTMLDivElement>) => {
1846
- if (dragState.current?.pointerId !== event.pointerId) return
1847
-
1848
- window.localStorage.setItem(STORAGE_KEY, String(latestRatio.current))
1849
- dragState.current = null
1850
- setIsDragging(false)
1851
- }
1852
-
1853
- const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
1854
- const bounds = container.getBoundingClientRect()
1855
-
1856
- event.preventDefault()
1857
- event.currentTarget.setPointerCapture(event.pointerId)
1858
- dragState.current = {
1859
- containerLeft: bounds.left,
1860
- pointerId: event.pointerId,
1861
- }
1862
- setIsDragging(true)
1863
- }
1864
-
1865
- const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
1866
- const drag = dragState.current
1867
- if (!drag || drag.pointerId !== event.pointerId) return
1868
-
1869
- const nextRatio = setEditWidth(container, event.clientX - drag.containerLeft)
1870
- latestRatio.current = nextRatio
1871
- setRatio(nextRatio)
1872
- }
1873
-
1874
- const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
1875
- if (!['ArrowLeft', 'ArrowRight', 'Home'].includes(event.key)) return
1876
-
1877
- event.preventDefault()
1878
- const containerWidth = container.getBoundingClientRect().width
1879
- const currentWidth = containerWidth * ratio
1880
- const step = event.shiftKey ? 40 : 10
1881
- const requestedWidth =
1882
- event.key === 'Home'
1883
- ? containerWidth * DEFAULT_EDIT_RATIO
1884
- : currentWidth + (event.key === 'ArrowLeft' ? -step : step)
1885
- const nextRatio = setEditWidth(container, requestedWidth)
1886
-
1887
- latestRatio.current = nextRatio
1888
- setRatio(nextRatio)
1889
- window.localStorage.setItem(STORAGE_KEY, String(nextRatio))
1890
- }
1891
-
1892
- const resetWidth = () => {
1893
- const nextRatio = setEditWidth(
1894
- container,
1895
- container.getBoundingClientRect().width * DEFAULT_EDIT_RATIO,
1896
- )
1897
-
1898
- latestRatio.current = nextRatio
1899
- setRatio(nextRatio)
1900
- window.localStorage.setItem(STORAGE_KEY, String(nextRatio))
1901
- }
1902
-
1903
- return (
1904
- <div
1905
- aria-label="Thay đổi độ rộng Live Preview"
1906
- aria-orientation="vertical"
1907
- aria-valuemax={Math.round(
1908
- (1 - MIN_PREVIEW_WIDTH / container.getBoundingClientRect().width) * 100,
1909
- )}
1910
- aria-valuemin={Math.round((MIN_EDIT_WIDTH / container.getBoundingClientRect().width) * 100)}
1911
- aria-valuenow={Math.round(ratio * 100)}
1912
- className={`live-preview-resizer${isDragging ? ' live-preview-resizer--dragging' : ''}`}
1913
- onDoubleClick={resetWidth}
1914
- onKeyDown={handleKeyDown}
1915
- onPointerCancel={finishDragging}
1916
- onPointerDown={handlePointerDown}
1917
- onPointerMove={handlePointerMove}
1918
- onPointerUp={finishDragging}
1919
- role="separator"
1920
- tabIndex={0}
1921
- title="Kéo để đổi độ rộng preview · Nhấp đúp để đặt lại"
1922
- >
1923
- <span aria-hidden="true" className="live-preview-resizer__grip" />
1924
- </div>
1925
- )
1926
- }
1927
-
1928
- const LivePreviewResizer = ({ children }: { children?: React.ReactNode }) => {
1929
- const [targets, setTargets] = useState<EditorTargets | null>(null)
1930
-
1931
- useEffect(() => {
1932
- let transitionTimer = 0
1933
-
1934
- const clearTransitionState = () => {
1935
- document.documentElement.classList.remove('wikex-live-preview-switching')
1936
- document.documentElement.removeAttribute('data-wikex-live-preview-target')
1937
- }
1938
-
1939
- const prepareLivePreviewToggle = (event: MouseEvent) => {
1940
- const eventTarget = event.target
1941
- if (!(eventTarget instanceof Element) || !eventTarget.closest('#live-preview-toggler')) {
1942
- return
1943
- }
1944
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
1945
-
1946
- const editor = document.querySelector<HTMLElement>('.collection-edit')
1947
- if (!editor) return
1948
-
1949
- window.clearTimeout(transitionTimer)
1950
- document.documentElement.dataset.wikexLivePreviewTarget = editor.classList.contains(
1951
- 'collection-edit--is-live-previewing',
1952
- )
1953
- ? 'edit'
1954
- : 'preview'
1955
- document.documentElement.classList.add('wikex-live-preview-switching')
1956
- transitionTimer = window.setTimeout(clearTransitionState, LIVE_PREVIEW_TRANSITION_MS + 80)
1957
- }
1958
-
1959
- document.addEventListener('click', prepareLivePreviewToggle, true)
1960
-
1961
- return () => {
1962
- document.removeEventListener('click', prepareLivePreviewToggle, true)
1963
- window.clearTimeout(transitionTimer)
1964
- clearTransitionState()
1965
- }
1966
- }, [])
1967
-
1968
- useEffect(() => {
1969
- const syncVisualEditorState = () => {
1970
- const pathname = window.location.pathname
1971
- const isVisualEditor = isVisualEditorRoute(pathname)
1972
- const editor = document.querySelector<HTMLElement>('.collection-edit')
1973
- const isActive = Boolean(
1974
- isVisualEditor && editor?.classList.contains('collection-edit--is-live-previewing'),
1975
- )
1976
-
1977
- editor?.toggleAttribute('data-wikex-visual-editor', isActive)
1978
- if (editor && isActive) editor.dataset.wikexVisualEditor = 'true'
1979
- document.documentElement.classList.toggle('wikex-visual-preview-active', isActive)
1980
- }
1981
-
1982
- const findTargets = () => {
1983
- syncVisualEditorState()
1984
- const previewWindow = document.querySelector<HTMLElement>(
1985
- '.collection-edit__main-wrapper > .live-preview-window',
1986
- )
1987
- const pathname = window.location.pathname
1988
- const isVisualEditor = isVisualEditorRoute(pathname)
1989
- const scope = getEditorScope(pathname)
1990
- const container = previewWindow?.parentElement ?? null
1991
- const editor = previewWindow?.closest<HTMLElement>('.collection-edit') ?? null
1992
- const previewWrapper = previewWindow?.querySelector<HTMLElement>(
1993
- '.live-preview-window__wrapper',
1994
- )
1995
- const activeIframe = previewWrapper?.querySelector<HTMLIFrameElement>('#live-preview-iframe')
1996
- const isLivePreviewing = editor?.classList.contains('collection-edit--is-live-previewing')
1997
-
1998
- const nextTargets =
1999
- isVisualEditor && isLivePreviewing && container && editor && previewWrapper && activeIframe
2000
- ? { container, editor, previewWrapper, route: pathname, scope }
2001
- : null
2002
-
2003
- setTargets((currentTargets) => {
2004
- if (!currentTargets || !nextTargets)
2005
- return currentTargets === nextTargets ? currentTargets : nextTargets
2006
-
2007
- const targetsAreUnchanged =
2008
- currentTargets.container === nextTargets.container &&
2009
- currentTargets.editor === nextTargets.editor &&
2010
- currentTargets.previewWrapper === nextTargets.previewWrapper &&
2011
- currentTargets.route === nextTargets.route &&
2012
- currentTargets.scope === nextTargets.scope
2013
-
2014
- return targetsAreUnchanged ? currentTargets : nextTargets
2015
- })
2016
- }
2017
-
2018
- findTargets()
2019
- let scanFrame = 0
2020
- const scheduleFindTargets = () => {
2021
- if (scanFrame) return
2022
-
2023
- scanFrame = window.requestAnimationFrame(() => {
2024
- scanFrame = 0
2025
- findTargets()
2026
- })
2027
- }
2028
- const previewStructureSelector =
2029
- '.collection-edit, .live-preview-window, .live-preview-window__wrapper, #live-preview-iframe'
2030
- const touchesPreviewStructure = (mutation: MutationRecord) => {
2031
- if (
2032
- mutation.type === 'attributes' &&
2033
- mutation.target instanceof HTMLElement &&
2034
- mutation.target.classList.contains('collection-edit')
2035
- ) {
2036
- return true
2037
- }
2038
-
2039
- if (mutation.type !== 'childList') return false
2040
-
2041
- return [...mutation.addedNodes, ...mutation.removedNodes].some(
2042
- (node) =>
2043
- node instanceof HTMLElement &&
2044
- (node.matches(previewStructureSelector) ||
2045
- Boolean(node.querySelector(previewStructureSelector))),
2046
- )
2047
- }
2048
- const observer = new MutationObserver((mutations) => {
2049
- if (mutations.some(touchesPreviewStructure)) {
2050
- window.cancelAnimationFrame(scanFrame)
2051
- scanFrame = 0
2052
- findTargets()
2053
- return
2054
- }
2055
-
2056
- scheduleFindTargets()
2057
- })
2058
- observer.observe(document.body, {
2059
- attributeFilter: ['class'],
2060
- attributes: true,
2061
- childList: true,
2062
- subtree: true,
2063
- })
2064
-
2065
- return () => {
2066
- observer.disconnect()
2067
- window.cancelAnimationFrame(scanFrame)
2068
- document.documentElement.classList.remove('wikex-visual-preview-active')
2069
- }
2070
- }, [])
2071
-
2072
- return (
2073
- <>
2074
- {children}
2075
- {targets && (
2076
- <>
2077
- {createPortal(
2078
- <LivePreviewResizeHandle container={targets.container} />,
2079
- targets.container,
2080
- )}
2081
- <WixEditorChrome key={targets.route} {...targets} />
2082
- </>
2083
- )}
2084
- </>
2085
- )
2086
- }
2087
-
2088
- export default LivePreviewResizer