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;