import { getAttributeOrThrow } from "../lib/attribute"; import { globalPubSub } from "../lib/pub_sub"; import { smoothlyScrollToElement } from "../lib/utils"; /** * A hook managing notebook/section headline. * * Similarly to cells the headline is focus/insert enabled. * * Configuration: * * * `data-focusable-id` - an identifier for the focus/insert navigation * * `data-on-value-change` - name of the event pushed when the user edits heading value * * `data-metadata` - additional value to send with the change event */ const Headline = { mounted() { this.props = getProps(this); this.state = { isFocused: false, insertMode: false, }; const heading = getHeading(this); // Make sure only plain text is pasted heading.addEventListener("paste", (event) => { event.preventDefault(); const text = event.clipboardData.getData("text/plain").replace("\n", " "); document.execCommand("insertText", false, text); }); // Ignore enter heading.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); } }); heading.addEventListener("blur", (event) => { // Wait for other handlers to complete and if still in insert // force focus setTimeout(() => { if (this.state.isFocused && this.state.insertMode) { heading.focus(); moveSelectionToEnd(heading); } }, 0); }); this._unsubscribeFromNavigationEvents = globalPubSub.subscribe( "navigation", (event) => { handleNavigationEvent(this, event); } ); }, updated() { this.props = getProps(this); }, destroyed() { this._unsubscribeFromNavigationEvents(); }, }; function getProps(hook) { return { focusableId: getAttributeOrThrow(hook.el, "data-focusable-id"), onValueChange: getAttributeOrThrow(hook.el, "data-on-value-change"), metadata: getAttributeOrThrow(hook.el, "data-metadata"), }; } function handleNavigationEvent(hook, event) { if (event.type === "element_focused") { handleElementFocused(hook, event.focusableId, event.scroll); } else if (event.type === "insert_mode_changed") { handleInsertModeChanged(hook, event.enabled); } } function handleElementFocused(hook, cellId, scroll) { if (hook.props.focusableId === cellId) { hook.state.isFocused = true; hook.el.setAttribute("data-js-focused", "true"); if (scroll) { smoothlyScrollToElement(hook.el); } } else if (hook.state.isFocused) { hook.state.isFocused = false; hook.el.removeAttribute("data-js-focused"); } } function handleInsertModeChanged(hook, insertMode) { const heading = getHeading(hook); if (hook.state.isFocused && !hook.state.insertMode && insertMode) { hook.state.insertMode = insertMode; // While in insert mode, ignore the incoming changes hook.el.setAttribute("phx-update", "ignore"); heading.setAttribute("contenteditable", "true"); heading.focus(); moveSelectionToEnd(heading); } else if (hook.state.insertMode && !insertMode) { hook.state.insertMode = insertMode; heading.removeAttribute("contenteditable"); hook.el.removeAttribute("phx-update"); hook.pushEvent(hook.props.onValueChange, { value: headingValue(heading), metadata: hook.props.metadata, }); } } function getHeading(hook) { return hook.el.querySelector(`[data-element="heading"]`); } function headingValue(heading) { return heading.textContent.trim(); } function moveSelectionToEnd(heading) { const range = document.createRange(); range.selectNodeContents(heading); range.collapse(false); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } export default Headline;