PluginProbe
WPJAM Basic / trunk
WPJAM Basic vtrunk
wpjam-basic / static / blocks.js

blocks.js in WPJAM Basic trunk, at static/blocks.js

785 lines 19.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function(wp, _, panels) {
2 const el = (tag, ...args)=> {
3 return wp.element.createElement(typeof tag === 'string' && /^[A-Z]/.test(tag) ? wp.components[tag] : tag, ...args);
4 };
5
6 const isEqual = (a, b)=> String(a) === String(b);
7
8 const parseName = (name)=> {
9 const parts = name.includes('[') && !name.startsWith('[') && name.endsWith(']') && name.split(/(\[|\])/).filter(t => t !== '');
10
11 if(!parts || parts.length % 3 !== 1){
12 return [name];
13 }
14
15 const names = [parts[0]];
16
17 for(let i = 1; i < parts.length; i += 3){
18 if(parts[i] !== '[' || !parts[i+1] || parts[i+2] !== ']'){
19 return [name];
20 }
21
22 names.push(parts[i+1]);
23 }
24
25 return names;
26 };
27
28 const getValue = (name, {data, value_callback})=> {
29 const names = parseName(name);
30 const val = names.reduce((acc, n)=> acc?.[n], value_callback ? {[names[0]]: value_callback(names[0])} : data);
31
32 return val !== undefined ? val : null;
33 };
34
35 const setValue = (name, value, args)=> {
36 const names = parseName(name);
37 const data = { [names[0]]: getValue(names[0], args) };
38
39 names.reduce((acc, n, i)=> {
40 if(i === names.length - 1){
41 if(value === null && names.length > 1){
42 delete acc[n];
43 }else{
44 acc[n] = value;
45 }
46 }else{
47 acc[n] = _.isArray(acc[n]) ? [...acc[n]] : (_.isObject(acc[n]) ? {...acc[n]} : {});
48 }
49
50 return acc[n];
51 }, data);
52
53 return data;
54 };
55
56 const sanitizeValue = (schema, value)=> {
57 if(value === ''){
58 return null;
59 }
60
61 const type = schema.type;
62
63 if(type === 'array'){
64 return value.map(v => sanitizeValue(schema.items, v)).filter(v => v !== null);
65 }else if(['integer', 'number'].includes(type)){
66 value = type === 'integer' ? parseInt(value, 10) : parseFloat(value);
67
68 return isNaN(value) ? null : value;
69 }else if(type === 'string'){
70 return value === null ? '' : String(value);
71 }
72
73 return value;
74 };
75
76 const validateValue = (schema, value, prevValue)=> {
77 if(!schema || !_.isArray(value)){
78 return null;
79 }
80
81 if(schema.maxItems && value.length > schema.maxItems && (!prevValue || value.length > prevValue.length)){
82 return '最多支持'+schema.maxItems+'';
83 }
84
85 if(schema.minItems && value.length < schema.minItems && prevValue && value.length < prevValue.length){
86 return '至少需要'+schema.minItems+'';
87 }
88
89 if(schema.uniqueItems && _.uniq(value.map(String)).length !== value.length){
90 return '不�
91 �许重复';
92 }
93
94 return null;
95 };
96
97 const TruncateText = (text)=> el('span', {className: 'truncate-text'}, text);
98
99 const MediaButton = ({field, render})=> {
100 const isId = field.item_type === 'id';
101 const allowed = field.item_type ? { allowedTypes: [isId ? 'image' : field.item_type] } : {};
102
103 return el(wp.blockEditor.MediaUpload, {
104 ...allowed,
105 multiple: !!field.mu,
106 onSelect: (media)=> {
107 const toVal = (m)=> isId ? m.id : m.url;
108
109 field.mu ? field.mu.addItem(...media.map(toVal)) : field.update(toVal(media));
110 },
111 render: render || (({open})=> el('Button', {
112 variant: 'secondary',
113 onClick: open
114 }, field.button_text))
115 });
116 };
117
118 const FileControl = ({field})=> {
119 const {value, update, mu} = field;
120 const [newVal, setNewVal] = wp.element.useState('');
121
122 const length = value.length;
123
124 const textInput = (val, index)=> el('TextControl', {
125 value: val,
126 placeholder: field.placeholder || 'https://...',
127 __next40pxDefaultSize: true,
128 ...(mu ? {
129 onChange: (v)=> {
130 if(index === length){
131 setNewVal(v);
132 }else{
133 mu.updateItem(index, v);
134 }
135 },
136 onBlur: ()=> {
137 if(index === length){
138 if(val){
139 mu.addItem(val);
140 setNewVal('');
141 }
142 }else if(!val){
143 mu.removeItem(index);
144 }
145 }
146 } : {
147 onChange: (v)=> update(v || null)
148 })
149 });
150
151 if(mu){
152 return el('Flex', {
153 direction: 'column',
154 align: 'flex-start',
155 gap: 2
156 }, [...value, newVal].map((val, index) => el('Flex', {
157 ...(index < length ? {...mu.dragProps, 'data-index': index} : {}),
158 className: 'mu-item',
159 key: index,
160 gap: 1
161 }, el('FlexBlock', null, textInput(val, index)), index < length ? el('Button', {
162 variant: 'secondary',
163 isDestructive: true,
164 onClick: ()=> mu.removeItem(index)
165 }, '删除') : null, index < length ? el('Button', {
166 icon: 'menu',
167 size: 'small',
168 className: 'move-item'
169 }) : el(MediaButton, {
170 field
171 }))));
172 }
173
174 return el('Flex', {
175 justify: 'flex-start',
176 gap: 1
177 }, el('FlexBlock', null, textInput(value)), el(MediaButton, {
178 field
179 }));
180 };
181
182 const MediaControl = ({field})=> {
183 const [urlInput, setUrlInput] = wp.element.useState(null);
184
185 const value = field.value;
186 const isId = field.item_type === 'id';
187 const mu = field.mu;
188 let urls = mu ? value : (value ? [value] : []);
189 const ids = isId ? urls : [];
190 const cache = wp.element.useRef({});
191 const data = wp.data.useSelect(select => {
192 const missing = ids.filter(id => !cache.current[id]);
193 return missing.length ? select('core').getEntityRecords('postType', 'attachment', {
194 include: missing,
195 per_page: missing.length
196 }) || [] : [];
197 }, [JSON.stringify(ids)])
198
199 if(isId){
200 data.forEach(m => cache.current[m.id] = m.source_url);
201 urls = ids.map(id => cache.current[id]).filter(Boolean);
202 }
203
204 if(urlInput !== null){
205 return el('Flex', {
206 direction: 'column'
207 }, el('TextControl', {
208 value: urlInput,
209 placeholder: 'https://...',
210 onChange: (val)=> setUrlInput(val)
211 }), el('Flex', {
212 justify: 'flex-start'
213 }, el('Button', {
214 variant: 'primary',
215 onClick: ()=> {
216 urlInput && (mu ? mu.addItem(urlInput) : field.update(urlInput));
217
218 setUrlInput(null);
219 }
220 }, '应用'), el('Button', {
221 variant: 'secondary',
222 onClick: ()=> setUrlInput(null)
223 }, '取消')));
224 }
225
226 if(mu){
227 return el('Flex', {
228 className: 'mu-img',
229 justify: 'flex-start',
230 wrap: true,
231 gap: 2
232 }, urls.map((src, i) => {
233 return el('div', {
234 ...mu.dragProps,
235 'data-index': i,
236 key: i,
237 className: 'mu-item'
238 }, el('img', {
239 src
240 }), el('Button', {
241 icon: 'no-alt',
242 size: 'small',
243 className: 'del-img',
244 onClick: ()=> mu.removeItem(i)
245 }));
246 }), el(MediaButton, {
247 field,
248 render: ({open})=> el('div', {
249 className: 'new-item',
250 onClick: open
251 }, (isId ? '' : el('Button', {
252 icon: 'admin-links',
253 size: 'small',
254 onClick: (e)=> {
255 e.stopPropagation();
256 setUrlInput('');
257 }
258 })))
259 }));
260 }
261
262 if(urls[0]){
263 return el('div', {
264 className: 'wpjam-img'
265 }, el(MediaButton, {
266 field,
267 render: ({open})=> el('img', {
268 src: urls[0],
269 onClick: open
270 })
271 }), el('Button', {
272 icon: 'no-alt',
273 className: 'del-img',
274 onClick: ()=> field.update(null)
275 }));
276 }
277
278 return el('Flex', {
279 justify: 'flex-start'
280 }, el(MediaButton, {
281 field,
282 render: ({open})=> el('Button', {
283 variant: 'secondary',
284 icon: 'camera',
285 text: field.button_text,
286 onClick: open
287 })
288 }), (isId ? '' : el('Button', {
289 variant: 'secondary',
290 text: '输�
291 �外链',
292 onClick: ()=> setUrlInput(value || '')
293 })));
294 };
295
296 const UploaderControl = ({field})=> {
297 const [uploading, setUploading] = wp.element.useState(false);
298
299 return el('Flex', {
300 justify: 'flex-start',
301 gap: 1
302 }, el('FormFileUpload', {
303 accept: field.accept,
304 disabled: uploading,
305 variant: 'secondary',
306 onChange: (e)=> {
307 const file = e.target.files[0];
308
309 if(!file) return;
310
311 setUploading(true);
312
313 const reader = new FileReader();
314
315 reader.onerror = ()=> {
316 setUploading(false);
317 alert('文件读取失败');
318 };
319
320 reader.onload = ()=> {
321 wpjam.post({
322 action: 'wpjam-upload',
323 name: field.name,
324 filename: file.name,
325 bits: reader.result,
326 _ajax_nonce: field.nonce
327 }).then(data => {
328 setUploading(false);
329
330 if(data.errcode === 0){
331 field.update(data.url);
332 }else{
333 alert(data.errmsg || '上传失败');
334 }
335 });
336 };
337
338 reader.readAsDataURL(file);
339 }
340 }, field.button_text), (field.value ? el('FlexBlock', null, el('Flex', {
341 justify: 'flex-start',
342 wrap: false,
343 gap: 0
344 }, el('Button', {
345 icon: 'dismiss',
346 size: 'small',
347 onClick: ()=> field.update(null)
348 }), TruncateText(field.value.split('/').pop()))) : ''));
349 };
350
351 const ColorControl = ({field})=> {
352 let value = field.value || '#000000';
353
354 return el('Dropdown', {
355 renderToggle: ({isOpen, onToggle})=> el('Button', {
356 onClick: onToggle,
357 style: {
358 color: value,
359 border: `1px solid ${value}`
360 }
361 }, el('ColorIndicator', {
362 colorValue: value
363 }), value || field.button_text || wp.i18n.__('Select Color', 'default')),
364 renderContent: ()=> el('ColorPicker', {
365 color: value,
366 enableAlpha: field.alpha || false,
367 onChange: (color)=> field.update(color)
368 })
369 });
370 };
371
372 const MuSelectControl = ({field})=> {
373 return el('Dropdown', {
374 focusOnMount: false,
375 popoverProps: { className: 'components-wpjam-select-popover' },
376 renderToggle: ({isOpen, onToggle})=> el('Button', {
377 icon: 'arrow-down-alt2',
378 iconPosition: 'right',
379 iconSize: 12,
380 className: 'truncate-text',
381 onClick: onToggle,
382 }, field.value.length > 0 ? field.value.map(v => (field.options.find(o => isEqual(o.value, v)) || {}).label || v).join(', ') : (field.placeholder || '请选择')),
383 renderContent: ()=> el(CheckboxControl, {field})
384 });
385 };
386
387 const ImageRadioControl = ({field})=> {
388 return el('Flex', {
389 justify: 'flex-start',
390 wrap: true,
391 gap: 4,
392 }, field.options.map(opt => {
393 return el('label', {
394 key: opt.value,
395 className: 'image-radio'
396 }, el('input', {
397 type: 'radio',
398 name: field.name,
399 value: opt.value,
400 checked: isEqual(field.value, opt.value),
401 onChange: ()=> field.update(opt.value)
402 }), [].concat(opt.image).slice(0, 2).map((src, i) => el('img', {
403 key: i,
404 src,
405 alt: opt.label
406 })), opt.label);
407 }));
408 };
409
410 const CheckboxControl = ({field})=> {
411 return el('Flex', {
412 direction: 'column',
413 gap: 3,
414 }, field.options.map(opt => el('CheckboxControl', {
415 key: opt.value,
416 label: opt.label,
417 checked: field.value.some(v => isEqual(v, opt.value)),
418 onChange: (checked)=> field.update(checked ? [...field.value, opt.value] : field.value.filter(v=> !isEqual(v, opt.value)))
419 })));
420 };
421
422 const ComboboxControl = ({field})=> {
423 const [options, setOptions] = wp.element.useState([]);
424 const [comboValue, setComboValue] = wp.element.useState('');
425
426 const labels = wp.element.useRef({});
427 const ref = wp.element.useRef(null);
428
429 const getLabel = (v)=> labels.current[String(v)] || String(v);
430 const queryItems = ({search, include, exclude})=> {
431 const query_args = field.query_args || {};
432
433 if(search){
434 query_args[field.data_type === 'post_type' ? 's' : 'search'] = search;
435 }
436
437 if(exclude?.length){
438 query_args.exclude = exclude;
439 }
440
441 wpjam.post({
442 action: 'wpjam-query',
443 data_type: field.data_type,
444 query_args: query_args,
445 ...(include ? {include} : {})
446 }).then(data => {
447 if(data.errcode === 0){
448 data.items.forEach(o => labels.current[String(o.value)] = o.label);
449
450 setOptions(data.items);
451 }
452 });
453 };
454
455 let {value, mu, update} = field;
456
457 if(!mu && value !== null){
458 value = String(value);
459 }
460
461 wp.element.useEffect(()=> {
462 if(!value || !ref.current) return;
463
464 const observer = new IntersectionObserver(([e])=> {
465 if(e.isIntersecting){
466 queryItems({include: mu ? value : [value]});
467 observer.disconnect();
468 }
469 }, {threshold: 0.1});
470
471 observer.observe(ref.current);
472
473 return ()=> observer.disconnect();
474 }, [value]);
475
476 return el('Flex', {
477 ref,
478 direction: 'column',
479 className: 'mu-text',
480 gap: 2
481 }, el('ComboboxControl', {
482 options,
483 value: mu ? comboValue : value,
484 placeholder: field.placeholder,
485 __next40pxDefaultSize: true,
486 onChange: (val)=> {
487 if(mu){
488 setComboValue('');
489
490 val && !mu.hasItem(val) && mu.addItem(val);
491
492 queryItems({exclude: [...value, val]});
493 }else{
494 update(val);
495 }
496 },
497 onFilterValueChange: (search)=> queryItems({search: search || '', exclude: mu ? value : null})
498 }), mu && value.length > 0 && value.map((v, i) => el('Flex', {
499 ...mu.dragProps,
500 'data-index': i,
501 key: v,
502 gap: 0,
503 className: 'mu-item',
504 }, TruncateText(getLabel(v)), el('Button', {
505 icon: 'no-alt',
506 size: 'small',
507 onClick: ()=> mu.removeItem(i)
508 }))));
509 };
510
511 const renderFieldset = (field, {index, ...args})=> {
512 const mu = field.mu;
513
514 if(mu && _.isUndefined(index)){
515 return el('Flex', {
516 direction: 'column',
517 gap: 2
518 }, field.value.map((item, index)=> renderFieldset(field, {
519 index,
520 ...args
521 })), el('FlexItem', null, el('Button', {
522 variant: 'secondary',
523 onClick: ()=> mu.addItem({})
524 }, field.button_text)));
525 }
526
527 const names = parseName(field.name);
528 const prefix = names[0]+(mu ? '['+index+']' : '')+names.slice(1).map(n => '['+n+']').join('');
529
530 return el('fieldset', {
531 ...(mu ? {
532 ...mu.dragProps,
533 className: 'mu-item',
534 'data-index': index
535 } : {}),
536 key: index
537 }, el('legend', {
538 className: 'screen-reader-text'
539 }, field.label+(mu ? ' ' + (index + 1) : '')), el('Flex', {
540 direction: 'column',
541 gap: 2
542 }, field.fields.map(sub => el(Field, {
543 key: sub.name,
544 field: {
545 ...sub,
546 name: field.fieldset === 'object' ? prefix + parseName(sub.name).map(n => '['+n+']').join('') : sub.name
547 },
548 args
549 })), mu ? el('Flex', {
550 justify: 'flex-start',
551 gap: 1
552 }, el('Button', {
553 variant: 'secondary',
554 isDestructive: true,
555 onClick: ()=> mu.removeItem(index)
556 }, '删除'), el('Button', {
557 icon: 'menu',
558 size: 'small',
559 className: 'move-item'
560 })) : ''));
561 };
562
563 const Field = ({field, args})=> {
564 const { name, component, multiple, show_if } = field;
565 const shouldHide = show_if && !wpjam.compare(getValue(show_if.key, args), show_if.compare, show_if.value);
566
567 wp.element.useEffect(()=> {
568 if(shouldHide){
569 args.callback(name, null);
570 }
571 }, [shouldHide]);
572
573 if(shouldHide){
574 return null;
575 }
576
577 let value = getValue(name, args);
578
579 if(multiple){
580 value = _.isArray(value) ? value : [];
581
582 field.mu = {
583 addItem: (...items)=> field.update([...field.value, ...items]),
584 updateItem: (index, item)=> field.update(field.value.with(index, item)),
585 removeItem: (index)=> field.update(field.value.filter((_, i)=> i !== index)),
586 hasItem: (item)=> field.value.some(v => isEqual(v, item)),
587 dragProps: {
588 draggable: true,
589 onMouseDown: (e)=> {
590 if(e.currentTarget.querySelector('.move-item')){
591 e.currentTarget._canDrag = !!e.target.closest('.move-item');
592 }
593 },
594 onDragStart: (e)=> {
595 const t = e.currentTarget;
596
597 if(t._canDrag === false){
598 e.preventDefault();
599 }else{
600 t.classList.add('drag-from');
601 e.dataTransfer.effectAllowed = 'move';
602 }
603
604 delete t._canDrag;
605 },
606 onDragOver: (e)=> {
607 e.preventDefault();
608
609 e.currentTarget.parentElement?.querySelectorAll('.drag-to').forEach(el => el.classList.remove('drag-to'));
610
611 e.currentTarget.classList.add('drag-to');
612 },
613 onDragEnd: (e)=> {
614 const t = e.currentTarget;
615 const from = Number(t.dataset.index);
616 const toEl = t.parentElement?.querySelector('.drag-to');
617 const to = toEl ? Number(toEl.dataset.index) : null;
618
619 t.parentElement?.querySelectorAll('.drag-from, .drag-to').forEach(el => el.classList.remove('drag-from', 'drag-to'));
620
621 to != null && from !== to && field.update(field.value.toSpliced(from, 1).toSpliced(to, 0, field.value[from]));
622 }
623 }
624 }
625 }
626
627 const className = `components-wpjam-`+(multiple ? 'mu-' : '')+`${component.toLowerCase()}-control`;
628 field.value = value;
629 field.update = (val)=> {
630 if(val !== null){
631 val = sanitizeValue(field.schema, val);
632
633 const err = validateValue(field.schema, val, field.value);
634
635 if(err){
636 return alert(err);
637 }
638 }
639
640 args.callback(name, val);
641 };
642
643 let options = field.options;
644
645 if(options){
646 options = _.reduce(options, (acc, opt)=> {
647 const { data, ...props } = opt;
648 const show_if = data?.show_if;
649
650 if(!show_if || wpjam.compare(getValue(show_if.key, args), show_if.compare, show_if.value)){
651 acc.push({ ...props, ...(data && data) });
652 }
653
654 return acc;
655 }, []);
656 }
657
658 let Control = '';
659
660 if(component == 'Radio'){
661 if(options?.some(o => o.image)){
662 Control = ImageRadioControl;
663 }
664 }else if(component == 'Select'){
665 if(multiple){
666 Control = MuSelectControl;
667 }
668 }else{
669 Control = {
670 File: FileControl,
671 Media: MediaControl,
672 Color: ColorControl,
673 Checkbox: CheckboxControl,
674 Combobox: ComboboxControl,
675 Uploader: UploaderControl
676 }[component];
677 }
678
679 if(Control || component === 'Fieldset'){
680 return el('BaseControl', {
681 className,
682 ..._.pick(field, ['key', 'label', 'help'])
683 }, component === 'Fieldset' ? renderFieldset(field, args) : el(Control, { field, ...args }));
684 }
685
686 if(multiple){
687 Control = 'FormTokenField';
688
689 field.__experimentalShowHowTo = false
690 }else{
691 Control = (component == 'Timestamp' ? 'Text' : component)+'Control';
692
693 if(component == 'Timestamp'){
694 if(value && /^\d+$/.test(String(value))){
695 const p2 = (n)=> String(n).padStart(2, '0');
696 const date = new Date(Number(value) * 1000);
697
698 value = date.getFullYear()+'-'+p2(date.getMonth() + 1)+'-'+p2(date.getDate())+'T'+p2(date.getHours())+':'+p2(date.getMinutes());
699 }
700
701 field.type = 'datetime-local';
702 }
703 }
704
705 const supportsSize = ['Text', 'Select', 'Textarea', 'Range', 'Timestamp'].includes(component);
706
707 return el(Control, {
708 ...(supportsSize ? { __next40pxDefaultSize: true } : {}),
709 className,
710 ..._.omit(field, ['component', 'show_if', 'schema', 'multiple', 'value', 'update', 'mu']),
711 ...(options ? { options } : {}),
712 ...(component === 'Toggle' ? { checked: !!value } : (component === 'Radio' ? { selected: value } : { value: value ?? '' })),
713 onChange: (val)=> field.update(val)
714 });
715 };
716
717 const Modal = ({panel, data, setOpen})=> {
718 const pick = (data, init = {})=> panel.fields.reduce((acc, field)=> ({...acc, ...setValue(field.name, getValue(field.name, {data}), {data: acc})}), {...init});
719
720 const [draft, setDraft] = wp.element.useState(()=> pick(data));
721 const { editPost: edit, savePost: save } = wp.data.useDispatch('core/editor');
722
723 return el('Modal', {
724 title: panel.title,
725 size: 'medium',
726 onRequestClose: ()=> setOpen(false)
727 }, el('Flex', {
728 direction: 'column',
729 gap: 4,
730 }, panel.fields.map(field => el(Field, {
731 key: field.name,
732 field,
733 args: {
734 data: draft,
735 callback: (name, val)=> setDraft(prev => ({ ...prev, ...setValue(name, val, {data: prev})}))
736 }
737 })), el('Flex', {
738 justify: 'flex-end'
739 }, el('Button', {
740 variant: 'primary',
741 onClick: ()=> {
742 edit({meta: pick(draft, data)});
743 save();
744 }
745 }, '提交'))));
746 };
747
748 const Panel = ({panel})=> {
749 const data = wp.data.useSelect(select => select('core/editor').getEditedPostAttribute('meta') || {});
750 const edit = wp.data.useDispatch('core/editor').editPost;
751 const modal = panel.modal;
752 const title = modal ? el('Flex', {
753 onClick: (e)=> {
754 e.stopPropagation();
755 setOpen(true);
756 }
757 }, panel.title, el('span', { className: 'dashicons dashicons-admin-generic' })) : panel.title;
758
759 const [isOpen, setOpen] = wp.element.useState(false);
760
761 return el(wp.element.Fragment, null, el(wp.editor.PluginDocumentSettingPanel, {
762 name: panel.name,
763 title
764 }, modal ? panel.description : el('Flex', {
765 direction: 'column',
766 gap: 4,
767 }, panel.fields.map(field => el(Field, {
768 key: field.name,
769 field,
770 args: {
771 data,
772 callback : (name, val)=> edit({ meta: setValue(name, val, {data}) })
773 }
774 })))), modal && isOpen && el(Modal, {panel, data, setOpen}));
775 };
776
777 if(panels?.length){
778 wp.plugins.registerPlugin('wpjam-panels', {
779 render: ()=> el(wp.element.Fragment, null, panels.map(panel => el(Panel, {
780 key: panel.name,
781 panel
782 })))
783 });
784 }
785 })(window.wp, window._, window.wpjam_page_setting?.block.panels);