import { parseHookProps } from "../lib/attribute"; import Markdown from "../lib/markdown"; import { globalPubsub } from "../lib/pubsub"; import { md5Base64, smoothlyScrollToElement, withStyle } 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 const amplifyButton = this.el.querySelector( `[data-el-amplify-outputs-button]`, ); if (amplifyButton) { 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:focus_changed", ({ focusableId, scroll }) => this.handleElementFocused(focusableId, scroll), ), globalPubsub.subscribe("navigation:insert_mode_changed", ({ enabled }) => this.handleInsertModeChanged(enabled), ), globalPubsub.subscribe("cells:cell_moved", ({ cellId }) => this.handleCellMoved(cellId), ), globalPubsub.subscribe( `cells:${this.props.cellId}:dispatch_queue_evaluation`, ({ dispatch }) => this.handleDispatchQueueEvaluation(dispatch), ), globalPubsub.subscribe( `cells:${this.props.cellId}:jump_to_line`, ({ line, offset = 0 }) => this.handleJumpToLine(line, offset), ), ]; // 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", ]); }, 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(() => { // We defer the check to happen after all focus/click events have // been processed, in case the state changes as a result setTimeout(() => { // 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(); } }, 0); }); liveEditor.onFocus(() => { // We defer the check to happen after all focus/click events have // been processed, in case the state changes as a result setTimeout(() => { // 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(); } else { this.sendCursorHistory(); } }, 0); }); liveEditor.onSelectionChange(() => { if (this.isFocused) { this.sendCursorHistory(); } }); 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; this.el.setAttribute("data-js-insert-mode", ""); if (this.currentEditor()) { this.currentEditor().focus(); this.scrollEditorCursorIntoViewIfNeeded(); } } else if (this.insertMode && !insertMode) { this.insertMode = insertMode; this.el.removeAttribute("data-js-insert-mode"); 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}:sync`, { callback: dispatch, }); } else { dispatch(); } }, handleJumpToLine(line, offset) { if (this.isFocused) { this.currentEditor().moveCursorToLine(line, offset); } }, scrollEditorCursorIntoViewIfNeeded() { const element = this.currentEditor().getElementAtCursor(); // Scroll to the cursor, positioning it near the top of the viewport withStyle(element, { scrollMarginTop: "128px" }, () => { scrollIntoView(element, { scrollMode: "if-needed", behavior: "instant", block: "start", }); }); }, sendCursorHistory() { const cursor = this.currentEditor().getCurrentCursorPosition(); if (cursor === null) return; globalPubsub.broadcast("navigation:cursor_moved", { line: cursor.line, offset: cursor.offset, cellId: this.props.cellId, }); }, }; export default Cell;