@quillforms/block-editor
Version:
338 lines (315 loc) • 10.4 kB
JavaScript
import { useSelect, useDispatch } from '@wordpress/data';
import { applyFilters } from '@wordpress/hooks';
import { useState, useMemo, useEffect, useCallback, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* QuillForms Dependencies
*/
import { __experimentalEditor as TextEditor, __unstableHtmlSerialize as serialize, __unstableReactEditor as ReactEditor, __unstableCreateEditor as createEditor, __unstableHtmlDeserialize as deserialize } from '@quillforms/admin-components';
/**
* External Dependencies
*/
import { css } from 'emotion';
import classNames from 'classnames';
/**
* Custom hook for debounced updates
*/
import { jsx as _jsx } from "react/jsx-runtime";
const useDebounce = (callback, delay) => {
const timeoutRef = useRef(null);
const debouncedCallback = useCallback((...args) => {
// Clear the previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set a new timeout
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
}, [callback, delay]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Function to immediately execute pending updates
const flush = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
return {
debouncedCallback,
flush
};
};
/**
* BlockEditor Component
*/
const BlockEditor = ({
type,
childId,
childIndex,
parentId
}) => {
// Selectors and Dispatch
const {
currentBlock,
isAnimating,
currentChildBlockId
} = useSelect(select => ({
currentBlock: select("quillForms/block-editor").getCurrentBlock(),
isAnimating: select("quillForms/renderer-core").isAnimating(),
currentChildBlockId: select("quillForms/block-editor").getCurrentChildBlockId()
}));
const lastFocusedRef = useRef(false);
const [isFocused, setIsFocused] = useState(false);
const {
prevFields,
correctIncorrectQuiz,
blockTypes
} = useSelect(select => {
return {
blockTypes: select('quillForms/blocks').getBlockTypes(),
prevFields: select('quillForms/block-editor').getPreviousEditableFieldsWithOrder(currentBlock?.id),
correctIncorrectQuiz: select('quillForms/quiz-editor').getState()
};
});
const {
setBlockAttributes
} = useDispatch("quillForms/block-editor");
// Destructure current block attributes
let {
attributes,
id
} = currentBlock || {};
let isChildBlock = false;
if (type === "label" && childIndex !== undefined && childIndex > -1 && currentBlock?.innerBlocks?.[childIndex]) {
attributes = currentBlock.innerBlocks[childIndex].attributes;
id = currentBlock.innerBlocks[childIndex].id;
isChildBlock = true;
}
const label = attributes?.label || "";
const description = attributes?.description || "";
// Editor instance
const editor = useMemo(() => createEditor(), []);
// Local editor value for immediate UI updates
const [editorValue, setEditorValue] = useState(() => {
if (type === "label") {
return deserialize(isChildBlock ? attributes?.label || "" : label);
}
if (type === "description") {
return deserialize(description);
}
return [];
});
// Debounced update function
const updateBlockAttributes = useCallback(serializedValue => {
if (type === "label") {
if (isChildBlock) {
setBlockAttributes(childId, {
label: serializedValue
}, parentId);
} else {
setBlockAttributes(id, {
label: serializedValue
});
}
} else if (type === "description") {
setBlockAttributes(id, {
description: serializedValue
});
}
}, [type, isChildBlock, childId, parentId, id, setBlockAttributes]);
// Create debounced version with 300ms delay
const {
debouncedCallback: debouncedUpdate,
flush
} = useDebounce(updateBlockAttributes, 0);
// Handle focus state changes
useEffect(() => {
const editorNode = ReactEditor.toDOMNode(editor, editor);
const handleEditorFocus = () => {
lastFocusedRef.current = true;
};
const handleEditorBlur = () => {
lastFocusedRef.current = false;
// Flush any pending updates when user leaves the editor
flush();
};
editorNode.addEventListener('focusin', handleEditorFocus);
editorNode.addEventListener('focusout', handleEditorBlur);
return () => {
editorNode.removeEventListener('focusin', handleEditorFocus);
editorNode.removeEventListener('focusout', handleEditorBlur);
};
}, [editor, flush]);
// Update Editor Value When `type` or Attributes Change
useEffect(() => {
if (type === "label") {
setEditorValue(deserialize(isChildBlock ? attributes?.label || "" : label));
} else if (type === "description") {
setEditorValue(deserialize(description));
}
}, [attributes, type, childIndex]);
// Auto-focus and scroll logic
useEffect(() => {
let timeoutId;
if (!isAnimating && type === "label") {
if (currentChildBlockId === childId || !isChildBlock && !currentChildBlockId) {
timeoutId = setTimeout(() => {
const editorEl = ReactEditor.toDOMNode(editor, editor);
const rect = editorEl.getBoundingClientRect();
const isInViewport = rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth);
if (!isInViewport) {
editorEl.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
}, 100);
}
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [isAnimating, currentChildBlockId, childId, isChildBlock, type, editor]);
// Optimized editor change handler
const handleEditorChange = useCallback(value => {
const currentSerialized = serialize(editorValue);
const newSerialized = serialize(value);
// Only update if content actually changed
if (newSerialized !== currentSerialized) {
// Update local state immediately for responsive UI
setEditorValue(value);
// Debounce the actual block attribute update
debouncedUpdate(newSerialized);
}
}, [editorValue, debouncedUpdate]);
const editorRef = useRef(null);
const handleFocus = useCallback(() => {
if (!ReactEditor.isFocused(editor)) {
ReactEditor.focus(editor);
}
}, [editor]);
const handleBlur = useCallback(() => {
setIsFocused(false);
// Ensure any pending updates are saved when user blurs
flush();
}, [flush]);
// Memoize merge tags to prevent unnecessary recalculations
const mergeTags = useMemo(() => {
let tags = prevFields.map(field => ({
type: 'field',
label: field?.attributes?.label,
modifier: field.id,
icon: blockTypes[field.name]?.icon,
color: blockTypes[field.name]?.color,
order: field.order
}));
tags = tags.concat(applyFilters('QuillForms.Builder.MergeTags', []));
if (correctIncorrectQuiz?.enabled) {
tags = tags.concat([{
type: 'quiz',
label: 'Correct Answers Count',
modifier: 'correct_answers_count',
icon: 'yes',
color: '#4caf50',
order: undefined
}, {
type: 'quiz',
label: 'Incorrect Answers Count',
modifier: 'incorrect_answers_count',
icon: 'no-alt',
color: '#f44336',
order: undefined
}, {
type: 'quiz',
label: 'Quiz Summary',
modifier: 'summary',
icon: 'editor-table',
color: '#4caf50',
order: undefined
}]);
}
return tags;
}, [prevFields, blockTypes, correctIncorrectQuiz?.enabled]);
// Memoized styles
const editorStyle = useMemo(() => css`
p {
color: inherit ;
font-family: inherit ;
margin: 0;
@media (min-width: 768px) {
font-size: inherit ;
line-height: inherit ;
}
@media (max-width: 767px) {
font-size: inherit ;
line-height: inherit ;
}
}
`, []);
const descriptionStyle = useMemo(() => css`
p {
color: inherit ;
font-family: inherit ;
@media (min-width: 768px) {
font-size: inherit ;
line-height: inherit ;
}
@media (max-width: 767px) {
font-size: inherit ;
line-height: inherit ;
}
}
`, []);
const wrapperStyles = useMemo(() => css`
&.block-editor-block-edit-label__editor:not(.is-focused) {
[data-slate-placeholder="true"] {
color: #757575 ;
opacity: 0.87 ;
}
}
.richtext__editor {
position: relative;
}
[contenteditable] {
position: relative;
z-index: 1;
}
`, []);
// Cleanup on unmount
useEffect(() => {
return () => {
// Ensure any pending updates are saved when component unmounts
flush();
};
}, [flush]);
return /*#__PURE__*/_jsx("div", {
className: classNames("block-editor-block-edit__editor", `block-editor-block-edit-${type}__editor`, {
'is-focused': isFocused
}, wrapperStyles),
onBlur: handleBlur,
onClick: handleFocus,
ref: editorRef,
children: /*#__PURE__*/_jsx(TextEditor, {
editor: editor,
placeholder: type === "label" ? __("Type question here. Recall information with @.", "quillforms") : __("Add a description", "quillforms"),
className: type === "label" ? editorStyle : descriptionStyle,
mergeTags: mergeTags,
value: editorValue,
onFocus: handleFocus,
onChange: handleEditorChange,
allowedFormats: ["bold", "italic", "link", "color"]
})
});
};
export default BlockEditor;
//# sourceMappingURL=editor.js.map