mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-02-23 06:19:38 +08:00
360 lines
9.5 KiB
JavaScript
360 lines
9.5 KiB
JavaScript
import { parseHookProps } from "../lib/attribute";
|
|
import Markdown from "../lib/markdown";
|
|
import { globalPubsub } from "../lib/pubsub";
|
|
import {
|
|
md5Base64,
|
|
smoothlyScrollToElement,
|
|
waitUntilInViewport,
|
|
} from "../lib/utils";
|
|
import scrollIntoView from "scroll-into-view-if-needed";
|
|
import { isEvaluable } from "../lib/notebook";
|
|
|
|
/**
|
|
* A hook managing a single cell.
|
|
*
|
|
* Manages the collaborative editor, takes care of markdown rendering
|
|
* and focusing the editor when applicable.
|
|
*
|
|
* ## Props
|
|
*
|
|
* * `cell-id` - id of the cell being edited
|
|
*
|
|
* * `type` - type of the cell
|
|
*
|
|
* * `session-path` - root path to the current session
|
|
*
|
|
* * `evaluation-digest` - digest of the last evaluated cell source
|
|
*
|
|
* * `smart-cell-js-view-ref` - ref for the JS View, applicable
|
|
* only to Smart cells
|
|
*
|
|
* * `allowed-uri-schemes` - a list of additional URI schemes that
|
|
* should be kept during sanitization. Applicable only to Markdown
|
|
* cells
|
|
*
|
|
*/
|
|
const Cell = {
|
|
mounted() {
|
|
this.props = this.getProps();
|
|
|
|
this.isFocused = false;
|
|
this.insertMode = false;
|
|
this.liveEditors = {};
|
|
|
|
this.updateInsertModeAvailability();
|
|
|
|
// Setup action handlers
|
|
|
|
if (["code", "smart"].includes(this.props.type)) {
|
|
const amplifyButton = this.el.querySelector(
|
|
`[data-el-amplify-outputs-button]`
|
|
);
|
|
amplifyButton.addEventListener("click", (event) => {
|
|
this.el.toggleAttribute("data-js-amplified");
|
|
});
|
|
}
|
|
|
|
if (this.props.type === "smart") {
|
|
const toggleSourceButton = this.el.querySelector(
|
|
`[data-el-toggle-source-button]`
|
|
);
|
|
toggleSourceButton.addEventListener("click", (event) => {
|
|
this.el.toggleAttribute("data-js-source-visible");
|
|
this.updateInsertModeAvailability();
|
|
this.maybeFocusCurrentEditor();
|
|
});
|
|
}
|
|
|
|
// Setup listeners
|
|
|
|
this.el.addEventListener("lb:cell:editor_created", (event) => {
|
|
const { tag, liveEditor } = event.detail;
|
|
this.handleCellEditorCreated(tag, liveEditor);
|
|
});
|
|
|
|
this.el.addEventListener("lb:cell:editor_removed", (event) => {
|
|
const { tag } = event.detail;
|
|
this.handleCellEditorRemoved(tag);
|
|
});
|
|
|
|
// We manually track hover to correctly handle absolute iframe
|
|
|
|
this.el.addEventListener("mouseenter", (event) => {
|
|
this.el.setAttribute("data-js-hover", "");
|
|
});
|
|
|
|
this.el.addEventListener("mouseleave", (event) => {
|
|
this.el.removeAttribute("data-js-hover");
|
|
});
|
|
|
|
this.subscriptions = [
|
|
globalPubsub.subscribe(
|
|
"navigation",
|
|
this.handleNavigationEvent.bind(this)
|
|
),
|
|
globalPubsub.subscribe("cells", this.handleCellsEvent.bind(this)),
|
|
globalPubsub.subscribe(
|
|
`cells:${this.props.cellId}`,
|
|
this.handleCellEvent.bind(this)
|
|
),
|
|
];
|
|
|
|
// DOM events
|
|
|
|
this._handleViewportResize = this.handleViewportResize.bind(this);
|
|
window.visualViewport.addEventListener(
|
|
"resize",
|
|
this._handleViewportResize
|
|
);
|
|
},
|
|
|
|
disconnected() {
|
|
// Reinitialize on reconnection
|
|
this.el.removeAttribute("id");
|
|
},
|
|
|
|
destroyed() {
|
|
this.subscriptions.forEach((subscription) => subscription.destroy());
|
|
|
|
window.visualViewport.removeEventListener(
|
|
"resize",
|
|
this._handleViewportResize
|
|
);
|
|
},
|
|
|
|
updated() {
|
|
const prevProps = this.props;
|
|
this.props = this.getProps();
|
|
|
|
if (this.props.evaluationDigest !== prevProps.evaluationDigest) {
|
|
this.updateChangeIndicator();
|
|
}
|
|
},
|
|
|
|
getProps() {
|
|
return parseHookProps(this.el, [
|
|
"cell-id",
|
|
"type",
|
|
"session-path",
|
|
"evaluation-digest",
|
|
"smart-cell-js-view-ref",
|
|
"allowed-uri-schemes",
|
|
]);
|
|
},
|
|
|
|
handleNavigationEvent(event) {
|
|
if (event.type === "element_focused") {
|
|
this.handleElementFocused(event.focusableId, event.scroll);
|
|
} else if (event.type === "insert_mode_changed") {
|
|
this.handleInsertModeChanged(event.enabled);
|
|
}
|
|
},
|
|
|
|
handleCellsEvent(event) {
|
|
if (event.type === "cell_moved") {
|
|
this.handleCellMoved(event.cellId);
|
|
}
|
|
},
|
|
|
|
handleCellEvent(event) {
|
|
if (event.type === "dispatch_queue_evaluation") {
|
|
this.handleDispatchQueueEvaluation(event.dispatch);
|
|
}
|
|
},
|
|
|
|
handleElementFocused(focusableId, scroll) {
|
|
if (this.props.cellId === focusableId) {
|
|
this.isFocused = true;
|
|
this.el.setAttribute("data-js-focused", "");
|
|
if (scroll) {
|
|
smoothlyScrollToElement(this.el);
|
|
}
|
|
} else if (this.isFocused) {
|
|
this.isFocused = false;
|
|
this.el.removeAttribute("data-js-focused");
|
|
}
|
|
},
|
|
|
|
handleCellEditorCreated(tag, liveEditor) {
|
|
this.liveEditors[tag] = liveEditor;
|
|
|
|
this.updateInsertModeAvailability();
|
|
|
|
if (liveEditor === this.currentEditor()) {
|
|
// Once the editor is created, reflect the current insert mode state
|
|
this.maybeFocusCurrentEditor();
|
|
}
|
|
|
|
liveEditor.onBlur(() => {
|
|
// Prevent from blurring unless the state changes. For example
|
|
// when we move cell using buttons the editor should keep focus
|
|
if (this.isFocused && this.insertMode) {
|
|
this.currentEditor().focus();
|
|
}
|
|
});
|
|
|
|
liveEditor.onFocus(() => {
|
|
// Prevent from focusing unless the state changes. The editor
|
|
// uses a contenteditable element, and it may accidentally get
|
|
// focus. In Chrome clicking on the right-side of the editor
|
|
// gives it focus
|
|
if (!this.isFocused || !this.insertMode) {
|
|
this.currentEditor().blur();
|
|
}
|
|
});
|
|
|
|
if (tag === "primary") {
|
|
const source = liveEditor.getSource();
|
|
|
|
this.el.toggleAttribute("data-js-empty", source === "");
|
|
|
|
liveEditor.onChange((newSource) => {
|
|
this.el.toggleAttribute("data-js-empty", newSource === "");
|
|
});
|
|
|
|
// Setup markdown rendering
|
|
if (this.props.type === "markdown") {
|
|
const markdownContainer = this.el.querySelector(
|
|
`[data-el-markdown-container]`
|
|
);
|
|
const markdown = new Markdown(markdownContainer, source, {
|
|
baseUrl: this.props.sessionPath,
|
|
emptyText: "Empty markdown cell",
|
|
allowedUriSchemes: this.props.allowedUriSchemes,
|
|
});
|
|
|
|
liveEditor.onChange((newSource) => {
|
|
markdown.setContent(newSource);
|
|
});
|
|
}
|
|
|
|
// Setup change indicator
|
|
if (isEvaluable(this.props.type)) {
|
|
this.updateChangeIndicator();
|
|
|
|
liveEditor.onChange((newSource) => {
|
|
this.updateChangeIndicator();
|
|
});
|
|
|
|
this.handleEvent(
|
|
`evaluation_finished:${this.props.cellId}`,
|
|
({ code_markers }) => {
|
|
liveEditor.setCodeMarkers(code_markers);
|
|
}
|
|
);
|
|
|
|
this.handleEvent(`start_evaluation:${this.props.cellId}`, () => {
|
|
liveEditor.clearDoctests();
|
|
});
|
|
|
|
this.handleEvent(
|
|
`doctest_report:${this.props.cellId}`,
|
|
(doctestReport) => {
|
|
liveEditor.updateDoctests([doctestReport]);
|
|
}
|
|
);
|
|
|
|
this.handleEvent(`erase_outputs`, () => {
|
|
liveEditor.setCodeMarkers([]);
|
|
liveEditor.clearDoctests();
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
handleCellEditorRemoved(tag) {
|
|
delete this.liveEditors[tag];
|
|
},
|
|
|
|
handleViewportResize() {
|
|
if (this.isFocused) {
|
|
this.scrollEditorCursorIntoViewIfNeeded();
|
|
}
|
|
},
|
|
|
|
currentEditor() {
|
|
return this.liveEditors[this.currentEditorTag()];
|
|
},
|
|
|
|
currentEditorTag() {
|
|
if (this.props.type === "smart") {
|
|
const isSourceTab = this.el.hasAttribute("data-js-source-visible");
|
|
return isSourceTab ? "primary" : "secondary";
|
|
}
|
|
|
|
return "primary";
|
|
},
|
|
|
|
updateInsertModeAvailability() {
|
|
this.el.toggleAttribute(
|
|
"data-js-insert-mode-disabled",
|
|
!this.currentEditor()
|
|
);
|
|
},
|
|
|
|
maybeFocusCurrentEditor() {
|
|
if (this.isFocused && this.insertMode) {
|
|
this.currentEditor().focus();
|
|
}
|
|
},
|
|
|
|
updateChangeIndicator() {
|
|
const cellStatus = this.el.querySelector(`[data-el-cell-status]`);
|
|
const indicator =
|
|
cellStatus && cellStatus.querySelector(`[data-el-change-indicator]`);
|
|
|
|
if (indicator && this.props.evaluationDigest) {
|
|
const source = this.liveEditors.primary.getSource();
|
|
const digest = md5Base64(source);
|
|
const changed = this.props.evaluationDigest !== digest;
|
|
this.el.toggleAttribute("data-js-changed", changed);
|
|
}
|
|
},
|
|
|
|
handleInsertModeChanged(insertMode) {
|
|
if (this.isFocused && !this.insertMode && insertMode) {
|
|
this.insertMode = insertMode;
|
|
|
|
if (this.currentEditor()) {
|
|
this.currentEditor().focus();
|
|
this.scrollEditorCursorIntoViewIfNeeded();
|
|
}
|
|
} else if (this.insertMode && !insertMode) {
|
|
this.insertMode = insertMode;
|
|
|
|
if (this.currentEditor()) {
|
|
this.currentEditor().blur();
|
|
}
|
|
}
|
|
},
|
|
|
|
handleCellMoved(cellId) {
|
|
if (this.isFocused && cellId === this.props.cellId) {
|
|
smoothlyScrollToElement(this.el);
|
|
}
|
|
},
|
|
|
|
handleDispatchQueueEvaluation(dispatch) {
|
|
if (this.props.type === "smart" && this.props.smartCellJsViewRef) {
|
|
// Ensure the smart cell UI is reflected on the server, before the evaluation
|
|
globalPubsub.broadcast(`js_views:${this.props.smartCellJsViewRef}`, {
|
|
type: "sync",
|
|
callback: dispatch,
|
|
});
|
|
} else {
|
|
dispatch();
|
|
}
|
|
},
|
|
|
|
scrollEditorCursorIntoViewIfNeeded() {
|
|
const element = this.currentEditor().getElementAtCursor();
|
|
|
|
scrollIntoView(element, {
|
|
scrollMode: "if-needed",
|
|
behavior: "smooth",
|
|
block: "center",
|
|
});
|
|
},
|
|
};
|
|
|
|
export default Cell;
|