| 1 |
import { useRef, useState, useEffect, useMemo } from "react"; |
| 2 |
import { |
| 3 |
PlainText, |
| 4 |
store as blockEditorStore, |
| 5 |
transformStyles, |
| 6 |
} from "@wordpress/block-editor"; |
| 7 |
import { __ } from "@wordpress/i18n"; |
| 8 |
import { mergeAttrsWithDefaultsAndApplyBindings } from "@tableberg/shared/utils/merge-attrs-with-defaults-and-apply-bindings"; |
| 9 |
|
| 10 |
import { useTableStore } from "../../store"; |
| 11 |
import { useClickOutside } from "../../hooks/useClickOutside"; |
| 12 |
import { ElementRendererProps } from "../index"; |
| 13 |
import { CustomHtmlElementControls } from "./controls"; |
| 14 |
import { BindableAttribute, ElementBindings } from "../../dynamic-data/types"; |
| 15 |
import { useDynamicDataBindings } from "../../dynamic-data/hooks/useDynamicData"; |
| 16 |
import { useSelect } from "@wordpress/data"; |
| 17 |
import { |
| 18 |
elementAlignmentToJustifyContent, |
| 19 |
ElementAlignment, |
| 20 |
} from "../../alignment"; |
| 21 |
|
| 22 |
const DEFAULT_STYLES = ` |
| 23 |
html,body,:root { |
| 24 |
margin: 0 !important; |
| 25 |
padding: 0 !important; |
| 26 |
overflow: visible !important; |
| 27 |
min-height: auto !important; |
| 28 |
} |
| 29 |
`; |
| 30 |
|
| 31 |
export interface CustomHtmlElementAttributes { |
| 32 |
content: string; |
| 33 |
align: ElementAlignment; |
| 34 |
} |
| 35 |
|
| 36 |
export const customHtmlAttrDefaults: CustomHtmlElementAttributes = { |
| 37 |
content: "", |
| 38 |
align: "left", |
| 39 |
}; |
| 40 |
|
| 41 |
export interface CustomHtmlElementType { |
| 42 |
name: "custom-html"; |
| 43 |
attributes: CustomHtmlElementAttributes; |
| 44 |
bindings?: ElementBindings; |
| 45 |
} |
| 46 |
|
| 47 |
export function CustomHtmlElement({ |
| 48 |
attributes, |
| 49 |
bindings, |
| 50 |
cellCoords, |
| 51 |
elementIndex, |
| 52 |
}: ElementRendererProps<CustomHtmlElementAttributes>) { |
| 53 |
const wrapperRef = useRef<HTMLDivElement>(null); |
| 54 |
const iframeRef = useRef<HTMLIFrameElement>(null); |
| 55 |
|
| 56 |
const sortPreviewMode = useTableStore(state => state.sortPreviewMode); |
| 57 |
const isElementSelected = useTableStore(state => state.isElementSelected); |
| 58 |
const setSelectedElement = useTableStore(state => state.setSelectedElement); |
| 59 |
const clearSelectedElement = useTableStore( |
| 60 |
state => state.clearSelectedElement |
| 61 |
); |
| 62 |
const updateCellElement = useTableStore(state => state.updateCellElement); |
| 63 |
|
| 64 |
const isSelected = isElementSelected(cellCoords, elementIndex); |
| 65 |
const [isPreview, setIsPreview] = useState(false); |
| 66 |
|
| 67 |
useClickOutside({ |
| 68 |
ref: wrapperRef, |
| 69 |
onClickOutside: () => { |
| 70 |
if (isSelected) { |
| 71 |
clearSelectedElement(); |
| 72 |
} |
| 73 |
}, |
| 74 |
}); |
| 75 |
|
| 76 |
const { values: previewValues, isLoading: isDynamicLoading } = |
| 77 |
useDynamicDataBindings(bindings); |
| 78 |
|
| 79 |
const mergedAttrs = mergeAttrsWithDefaultsAndApplyBindings( |
| 80 |
attributes, |
| 81 |
customHtmlAttrDefaults, |
| 82 |
bindings, |
| 83 |
previewValues, |
| 84 |
__("(No data)", "tableberg") |
| 85 |
); |
| 86 |
|
| 87 |
const contentIsBound = !!bindings?.content; |
| 88 |
const { content, align } = mergedAttrs; |
| 89 |
|
| 90 |
const wrapperStyle: React.CSSProperties = { |
| 91 |
display: "flex", |
| 92 |
justifyContent: elementAlignmentToJustifyContent(align), |
| 93 |
}; |
| 94 |
|
| 95 |
useEffect(() => { |
| 96 |
if (contentIsBound) { |
| 97 |
setIsPreview(true); |
| 98 |
} |
| 99 |
}, [contentIsBound]); |
| 100 |
|
| 101 |
const settingStyles = useSelect( |
| 102 |
select => (select(blockEditorStore) as any).getSettings()?.styles || [], |
| 103 |
[] |
| 104 |
); |
| 105 |
|
| 106 |
const styles = useMemo( |
| 107 |
() => |
| 108 |
[ |
| 109 |
DEFAULT_STYLES, |
| 110 |
...transformStyles( |
| 111 |
settingStyles.filter((style: any) => style.css) |
| 112 |
), |
| 113 |
].join(""), |
| 114 |
[settingStyles] |
| 115 |
); |
| 116 |
|
| 117 |
const renderIframeContent = () => { |
| 118 |
const iframe = iframeRef.current; |
| 119 |
if (!iframe) { |
| 120 |
return; |
| 121 |
} |
| 122 |
const iframeDocument = iframe.contentWindow?.document; |
| 123 |
if (!iframeDocument) { |
| 124 |
return; |
| 125 |
} |
| 126 |
|
| 127 |
iframeDocument.head.innerHTML = `<style>${styles}</style>`; |
| 128 |
iframeDocument.body.innerHTML = content |
| 129 |
? `<div |
| 130 |
style="width: max-content; overflow: hidden;" |
| 131 |
class="tableberg-html-content editor-styles-wrapper" |
| 132 |
> |
| 133 |
${content} |
| 134 |
</div>` |
| 135 |
: `<div |
| 136 |
style="width: max-content; overflow: hidden; color: grey; padding: 8px;" |
| 137 |
class="tableberg-html-content editor-styles-wrapper" |
| 138 |
> |
| 139 |
${__("Empty custom HTML block", "tableberg")} |
| 140 |
</div>`; |
| 141 |
|
| 142 |
const contentEl = iframeDocument.querySelector( |
| 143 |
".tableberg-html-content" |
| 144 |
); |
| 145 |
if (contentEl) { |
| 146 |
const contentRect = contentEl.getBoundingClientRect(); |
| 147 |
iframe.style.height = `${Math.ceil(contentRect.height) + 1}px`; |
| 148 |
iframe.style.width = `${Math.ceil(contentRect.width) + 1}px`; |
| 149 |
} |
| 150 |
}; |
| 151 |
|
| 152 |
useEffect(() => { |
| 153 |
renderIframeContent(); |
| 154 |
}, [styles, isPreview, content, isSelected]); |
| 155 |
|
| 156 |
const handleClick = () => { |
| 157 |
if (!sortPreviewMode) { |
| 158 |
setSelectedElement(cellCoords, elementIndex); |
| 159 |
} |
| 160 |
}; |
| 161 |
|
| 162 |
const handleContentChange = (newContent: string) => { |
| 163 |
updateCellElement(cellCoords, elementIndex, { |
| 164 |
attributes: { content: newContent }, |
| 165 |
}); |
| 166 |
}; |
| 167 |
|
| 168 |
if (isDynamicLoading) { |
| 169 |
return ( |
| 170 |
<div |
| 171 |
ref={wrapperRef} |
| 172 |
className="tableberg-custom-html" |
| 173 |
style={wrapperStyle} |
| 174 |
onClick={handleClick} |
| 175 |
> |
| 176 |
<p style={{ margin: 0 }}>{__("Loading...", "tableberg")}</p> |
| 177 |
</div> |
| 178 |
); |
| 179 |
} |
| 180 |
|
| 181 |
const shouldShowPreview = sortPreviewMode || contentIsBound || isPreview; |
| 182 |
|
| 183 |
return ( |
| 184 |
<> |
| 185 |
{isSelected && ( |
| 186 |
<CustomHtmlElementControls |
| 187 |
attributes={mergedAttrs} |
| 188 |
bindings={bindings} |
| 189 |
isPreview={isPreview} |
| 190 |
setIsPreview={setIsPreview} |
| 191 |
cellCoords={cellCoords} |
| 192 |
elementIndex={elementIndex} |
| 193 |
/> |
| 194 |
)} |
| 195 |
<div |
| 196 |
ref={wrapperRef} |
| 197 |
className="tableberg-custom-html" |
| 198 |
onClick={handleClick} |
| 199 |
> |
| 200 |
{shouldShowPreview ? ( |
| 201 |
<iframe |
| 202 |
ref={iframeRef} |
| 203 |
title={__("Custom HTML Preview", "tableberg")} |
| 204 |
tabIndex={-1} |
| 205 |
sandbox="allow-same-origin" |
| 206 |
onLoad={renderIframeContent} |
| 207 |
style={{ |
| 208 |
display: "block", |
| 209 |
border: "none", |
| 210 |
minWidth: "100px", |
| 211 |
minHeight: "20px", |
| 212 |
pointerEvents: "none", |
| 213 |
}} |
| 214 |
/> |
| 215 |
) : ( |
| 216 |
<PlainText |
| 217 |
className="tableberg-custom-html-editor" |
| 218 |
value={content} |
| 219 |
onChange={handleContentChange} |
| 220 |
placeholder={__("Write HTML...", "tableberg")} |
| 221 |
aria-label={__("HTML", "tableberg")} |
| 222 |
/> |
| 223 |
)} |
| 224 |
</div> |
| 225 |
</> |
| 226 |
); |
| 227 |
} |
| 228 |
|
| 229 |
export const customHtmlBindableAttributes: BindableAttribute[] = [ |
| 230 |
{ |
| 231 |
path: "content", |
| 232 |
label: __("HTML Content", "tableberg"), |
| 233 |
}, |
| 234 |
]; |
| 235 |
|