@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.
- package/CHANGELOG.md +17 -0
- package/README.md +167 -0
- package/RELEASING.md +59 -0
- package/package.json +55 -0
- package/src/TextStyleRuntime.tsx +90 -0
- package/src/admin/BeforeLogin.tsx +19 -0
- package/src/admin/Brand.tsx +74 -0
- package/src/admin/Header.tsx +87 -0
- package/src/admin/LogoutButton.tsx +41 -0
- package/src/admin/Nav.tsx +35 -0
- package/src/admin/NavClient.tsx +288 -0
- package/src/admin/ThemeToggle.tsx +31 -0
- package/src/admin/ViewSite.tsx +11 -0
- package/src/admin/useAdminBrand.ts +81 -0
- package/src/adminExperience.ts +117 -0
- package/src/index.ts +11 -0
- package/src/live-preview/GlobalInlineInspector.tsx +334 -0
- package/src/live-preview/LivePreviewEditor.tsx +2088 -0
- package/src/live-preview/TextStyleInspector.tsx +289 -0
- package/src/live-preview/index.ts +23 -0
- package/src/live-preview/runtimeConfig.ts +52 -0
- package/src/plugin.ts +70 -0
- package/src/project.ts +82 -0
- package/src/rich-text/client.tsx +202 -0
- package/src/rich-text/index.ts +10 -0
- package/src/starter/client.tsx +22 -0
- package/src/starter.ts +95 -0
- package/src/styles/_wikex-fonts.scss +45 -0
- package/src/styles/admin.scss +3032 -0
- package/src/textStyles.ts +161 -0
|
@@ -0,0 +1,2088 @@
|
|
|
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
|