2021-11-17 04:57:10 +08:00
|
|
|
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) {
|
2021-11-19 00:34:04 +08:00
|
|
|
const heading = getHeading(hook);
|
|
|
|
|
|
|
|
if (hook.state.isFocused && !hook.state.insertMode && insertMode) {
|
2021-11-17 04:57:10 +08:00
|
|
|
hook.state.insertMode = insertMode;
|
|
|
|
|
2021-11-19 00:34:04 +08:00
|
|
|
// 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,
|
|
|
|
});
|
2021-11-17 04:57:10 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|