2021-03-11 22:28:18 +08:00
|
|
|
import { getAttributeOrThrow } from "../lib/attribute";
|
2021-01-30 07:33:04 +08:00
|
|
|
import LiveEditor from "./live_editor";
|
|
|
|
import Markdown from "./markdown";
|
2021-03-11 22:28:18 +08:00
|
|
|
import { globalPubSub } from "../lib/pub_sub";
|
|
|
|
import { smoothlyScrollToElement } from "../lib/utils";
|
2021-04-05 21:06:14 +08:00
|
|
|
import scrollIntoView from "scroll-into-view-if-needed";
|
2021-01-30 07:33:04 +08:00
|
|
|
|
|
|
|
/**
|
|
|
|
* A hook managing a single cell.
|
|
|
|
*
|
|
|
|
* Mounts and manages the collaborative editor,
|
|
|
|
* takes care of markdown rendering and focusing the editor when applicable.
|
|
|
|
*
|
|
|
|
* Configuration:
|
|
|
|
*
|
|
|
|
* * `data-cell-id` - id of the cell being edited
|
2021-06-08 18:33:50 +08:00
|
|
|
* * `data-type` - type of the cell
|
2021-01-30 07:33:04 +08:00
|
|
|
*/
|
|
|
|
const Cell = {
|
|
|
|
mounted() {
|
|
|
|
this.props = getProps(this);
|
2021-03-11 22:28:18 +08:00
|
|
|
this.state = {
|
|
|
|
isFocused: false,
|
|
|
|
insertMode: false,
|
2021-06-08 18:33:50 +08:00
|
|
|
// For text cells (markdown or elixir)
|
|
|
|
liveEditor: null,
|
2021-03-11 22:28:18 +08:00
|
|
|
};
|
2021-01-30 07:33:04 +08:00
|
|
|
|
2021-06-08 18:33:50 +08:00
|
|
|
if (["markdown", "elixir"].includes(this.props.type)) {
|
|
|
|
this.pushEvent("cell_init", { cell_id: this.props.cellId }, (payload) => {
|
|
|
|
const { source, revision } = payload;
|
2021-01-30 07:33:04 +08:00
|
|
|
|
2021-06-08 18:33:50 +08:00
|
|
|
const editorContainer = this.el.querySelector(
|
|
|
|
`[data-element="editor-container"]`
|
|
|
|
);
|
|
|
|
// Remove the content placeholder.
|
|
|
|
editorContainer.firstElementChild.remove();
|
|
|
|
// Create an empty container for the editor to be mounted in.
|
|
|
|
const editorElement = document.createElement("div");
|
|
|
|
editorContainer.appendChild(editorElement);
|
|
|
|
// Setup the editor instance.
|
|
|
|
this.state.liveEditor = new LiveEditor(
|
|
|
|
this,
|
|
|
|
editorElement,
|
|
|
|
this.props.cellId,
|
|
|
|
this.props.type,
|
|
|
|
source,
|
|
|
|
revision
|
|
|
|
);
|
2021-02-04 23:01:59 +08:00
|
|
|
|
2021-06-08 18:33:50 +08:00
|
|
|
// Setup markdown rendering.
|
|
|
|
if (this.props.type === "markdown") {
|
|
|
|
const markdownContainer = this.el.querySelector(
|
|
|
|
`[data-element="markdown-container"]`
|
|
|
|
);
|
|
|
|
const baseUrl = this.props.sessionPath;
|
|
|
|
const markdown = new Markdown(markdownContainer, source, baseUrl);
|
2021-05-07 22:41:37 +08:00
|
|
|
|
2021-06-08 18:33:50 +08:00
|
|
|
this.state.liveEditor.onChange((newSource) => {
|
|
|
|
markdown.setContent(newSource);
|
|
|
|
});
|
|
|
|
}
|
2021-03-02 01:48:19 +08:00
|
|
|
|
2021-06-08 18:33:50 +08:00
|
|
|
// Once the editor is created, reflect the current state.
|
2021-03-11 22:28:18 +08:00
|
|
|
if (this.state.isFocused && this.state.insertMode) {
|
|
|
|
this.state.liveEditor.focus();
|
2021-06-08 18:33:50 +08:00
|
|
|
// If the element is being scrolled to, focus interrupts it,
|
|
|
|
// so ensure the scrolling continues.
|
|
|
|
smoothlyScrollToElement(this.el);
|
|
|
|
|
|
|
|
broadcastSelection(this);
|
2021-03-02 01:48:19 +08:00
|
|
|
}
|
2021-06-08 18:33:50 +08:00
|
|
|
|
|
|
|
this.state.liveEditor.onBlur(() => {
|
|
|
|
// Prevent from blurring unless the state changes.
|
|
|
|
// For example when we move cell using buttons
|
|
|
|
// the editor should keep focus.
|
|
|
|
if (this.state.isFocused && this.state.insertMode) {
|
|
|
|
this.state.liveEditor.focus();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
this.state.liveEditor.onCursorSelectionChange((selection) => {
|
|
|
|
broadcastSelection(this, selection);
|
|
|
|
});
|
2021-03-02 01:48:19 +08:00
|
|
|
});
|
2021-06-08 18:33:50 +08:00
|
|
|
}
|
2021-05-07 22:41:37 +08:00
|
|
|
|
2021-06-08 18:33:50 +08:00
|
|
|
if (this.props.type === "input") {
|
|
|
|
const input = getInput(this);
|
|
|
|
|
|
|
|
input.addEventListener("blur", (event) => {
|
|
|
|
if (this.state.isFocused && this.state.insertMode) {
|
|
|
|
input.focus();
|
|
|
|
}
|
2021-05-07 22:41:37 +08:00
|
|
|
});
|
2021-06-08 18:33:50 +08:00
|
|
|
}
|
2021-03-11 22:28:18 +08:00
|
|
|
|
2021-05-07 22:41:37 +08:00
|
|
|
this._unsubscribeFromCellsEvents = globalPubSub.subscribe(
|
|
|
|
"cells",
|
|
|
|
(event) => {
|
|
|
|
handleCellsEvent(this, event);
|
|
|
|
}
|
|
|
|
);
|
2021-03-11 22:28:18 +08:00
|
|
|
},
|
|
|
|
|
|
|
|
destroyed() {
|
2021-05-07 22:41:37 +08:00
|
|
|
this._unsubscribeFromCellsEvents();
|
2021-04-21 23:38:48 +08:00
|
|
|
|
|
|
|
if (this.state.liveEditor) {
|
2021-05-07 22:41:37 +08:00
|
|
|
this.state.liveEditor.dispose();
|
2021-04-21 23:38:48 +08:00
|
|
|
}
|
2021-01-30 07:33:04 +08:00
|
|
|
},
|
|
|
|
|
|
|
|
updated() {
|
|
|
|
this.props = getProps(this);
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
function getProps(hook) {
|
|
|
|
return {
|
|
|
|
cellId: getAttributeOrThrow(hook.el, "data-cell-id"),
|
|
|
|
type: getAttributeOrThrow(hook.el, "data-type"),
|
2021-04-05 00:55:51 +08:00
|
|
|
sessionPath: getAttributeOrThrow(hook.el, "data-session-path"),
|
2021-01-30 07:33:04 +08:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-06-08 18:33:50 +08:00
|
|
|
function getInput(hook) {
|
|
|
|
if (hook.props.type === "input") {
|
|
|
|
return hook.el.querySelector(`[data-element="input"]`);
|
|
|
|
} else {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-01-30 07:33:04 +08:00
|
|
|
/**
|
2021-05-07 22:41:37 +08:00
|
|
|
* Handles client-side cells event.
|
2021-01-30 07:33:04 +08:00
|
|
|
*/
|
2021-05-07 22:41:37 +08:00
|
|
|
function handleCellsEvent(hook, event) {
|
2021-03-11 22:28:18 +08:00
|
|
|
if (event.type === "cell_focused") {
|
|
|
|
handleCellFocused(hook, event.cellId);
|
|
|
|
} else if (event.type === "insert_mode_changed") {
|
|
|
|
handleInsertModeChanged(hook, event.enabled);
|
|
|
|
} else if (event.type === "cell_moved") {
|
|
|
|
handleCellMoved(hook, event.cellId);
|
2021-04-05 00:55:51 +08:00
|
|
|
} else if (event.type === "cell_upload") {
|
|
|
|
handleCellUpload(hook, event.cellId, event.url);
|
2021-05-07 22:41:37 +08:00
|
|
|
} else if (event.type === "location_report") {
|
|
|
|
handleLocationReport(hook, event.client, event.report);
|
2021-03-11 22:28:18 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function handleCellFocused(hook, cellId) {
|
|
|
|
if (hook.props.cellId === cellId) {
|
|
|
|
hook.state.isFocused = true;
|
|
|
|
hook.el.setAttribute("data-js-focused", "true");
|
|
|
|
smoothlyScrollToElement(hook.el);
|
|
|
|
} else if (hook.state.isFocused) {
|
|
|
|
hook.state.isFocused = false;
|
|
|
|
hook.el.removeAttribute("data-js-focused");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function handleInsertModeChanged(hook, insertMode) {
|
|
|
|
if (hook.state.isFocused) {
|
|
|
|
hook.state.insertMode = insertMode;
|
|
|
|
|
2021-04-05 21:06:14 +08:00
|
|
|
if (hook.state.liveEditor) {
|
|
|
|
if (hook.state.insertMode) {
|
|
|
|
hook.state.liveEditor.focus();
|
|
|
|
// The insert mode may be enabled as a result of clicking the editor,
|
|
|
|
// in which case we want to wait until editor handles the click
|
|
|
|
// and sets new cursor position.
|
|
|
|
// To achieve this, we simply put this task at the end of event loop,
|
|
|
|
// ensuring all click handlers are executed first.
|
|
|
|
setTimeout(() => {
|
|
|
|
scrollIntoView(document.activeElement, {
|
|
|
|
scrollMode: "if-needed",
|
|
|
|
behavior: "smooth",
|
|
|
|
block: "center",
|
|
|
|
});
|
|
|
|
}, 0);
|
2021-05-07 22:41:37 +08:00
|
|
|
|
|
|
|
broadcastSelection(hook);
|
2021-04-05 21:06:14 +08:00
|
|
|
} else {
|
|
|
|
hook.state.liveEditor.blur();
|
|
|
|
}
|
2021-03-11 22:28:18 +08:00
|
|
|
}
|
2021-06-08 18:33:50 +08:00
|
|
|
|
|
|
|
const input = getInput(hook);
|
|
|
|
|
|
|
|
if (input) {
|
|
|
|
if (hook.state.insertMode) {
|
|
|
|
input.focus();
|
|
|
|
input.selectionStart = input.selectionEnd = input.value.length;
|
|
|
|
} else {
|
|
|
|
input.blur();
|
|
|
|
}
|
|
|
|
}
|
2021-03-11 22:28:18 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function handleCellMoved(hook, cellId) {
|
|
|
|
if (hook.state.isFocused && cellId === hook.props.cellId) {
|
|
|
|
smoothlyScrollToElement(hook.el);
|
|
|
|
}
|
2021-01-30 07:33:04 +08:00
|
|
|
}
|
|
|
|
|
2021-04-05 00:55:51 +08:00
|
|
|
function handleCellUpload(hook, cellId, url) {
|
2021-06-08 18:33:50 +08:00
|
|
|
if (!hook.state.liveEditor) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-04-05 00:55:51 +08:00
|
|
|
if (hook.props.cellId === cellId) {
|
|
|
|
const markdown = `![](${url})`;
|
|
|
|
hook.state.liveEditor.insert(markdown);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-07 22:41:37 +08:00
|
|
|
function handleLocationReport(hook, client, report) {
|
2021-06-08 18:33:50 +08:00
|
|
|
if (!hook.state.liveEditor) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-05-07 22:41:37 +08:00
|
|
|
if (hook.props.cellId === report.cellId && report.selection) {
|
|
|
|
hook.state.liveEditor.updateUserSelection(client, report.selection);
|
|
|
|
} else {
|
|
|
|
hook.state.liveEditor.removeUserSelection(client);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function broadcastSelection(hook, selection = null) {
|
|
|
|
selection = selection || hook.state.liveEditor.editor.getSelection();
|
|
|
|
|
|
|
|
// Report new selection only if this cell is in insert mode
|
|
|
|
if (hook.state.isFocused && hook.state.insertMode) {
|
|
|
|
globalPubSub.broadcast("session", {
|
|
|
|
type: "cursor_selection_changed",
|
|
|
|
cellId: hook.props.cellId,
|
|
|
|
selection,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-01-30 07:33:04 +08:00
|
|
|
export default Cell;
|