mirror of
				https://github.com/livebook-dev/livebook.git
				synced 2025-10-31 07:46:18 +08:00 
			
		
		
		
	* Implement checkbox input * Adjustments Co-authored-by: Jonatan Kłosko <jonatanklosko@gmail.com>
		
			
				
	
	
		
			290 lines
		
	
	
	
		
			8.3 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			290 lines
		
	
	
	
		
			8.3 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| import { getAttributeOrThrow } from "../lib/attribute";
 | |
| import LiveEditor from "./live_editor";
 | |
| import Markdown from "./markdown";
 | |
| import { globalPubSub } from "../lib/pub_sub";
 | |
| import { md5Base64, smoothlyScrollToElement } from "../lib/utils";
 | |
| import scrollIntoView from "scroll-into-view-if-needed";
 | |
| 
 | |
| /**
 | |
|  * 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
 | |
|  *   * `data-type` - type of the cell
 | |
|  */
 | |
| const Cell = {
 | |
|   mounted() {
 | |
|     this.props = getProps(this);
 | |
|     this.state = {
 | |
|       isFocused: false,
 | |
|       insertMode: false,
 | |
|       // For text cells (markdown or elixir)
 | |
|       liveEditor: null,
 | |
|       evaluationDigest: null,
 | |
|     };
 | |
| 
 | |
|     if (["markdown", "elixir"].includes(this.props.type)) {
 | |
|       this.pushEvent("cell_init", { cell_id: this.props.cellId }, (payload) => {
 | |
|         const { source, revision, evaluation_digest } = payload;
 | |
| 
 | |
|         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
 | |
|         );
 | |
| 
 | |
|         // Setup change indicator
 | |
|         if (this.props.type === "elixir") {
 | |
|           this.state.evaluationDigest = evaluation_digest;
 | |
| 
 | |
|           const updateChangeIndicator = () => {
 | |
|             const indicator = this.el.querySelector(
 | |
|               `[data-element="change-indicator"]`
 | |
|             );
 | |
| 
 | |
|             if (indicator) {
 | |
|               const source = this.state.liveEditor.getSource();
 | |
|               const digest = md5Base64(source);
 | |
|               const changed = this.state.evaluationDigest !== digest;
 | |
|               indicator.toggleAttribute("data-js-shown", changed);
 | |
|             }
 | |
|           };
 | |
| 
 | |
|           updateChangeIndicator();
 | |
| 
 | |
|           this.handleEvent(
 | |
|             `evaluation_started:${this.props.cellId}`,
 | |
|             ({ evaluation_digest }) => {
 | |
|               this.state.evaluationDigest = evaluation_digest;
 | |
|               updateChangeIndicator();
 | |
|             }
 | |
|           );
 | |
| 
 | |
|           this.state.liveEditor.onChange((newSource) => {
 | |
|             updateChangeIndicator();
 | |
|           });
 | |
|         }
 | |
| 
 | |
|         // 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,
 | |
|             emptyText: "Empty markdown cell",
 | |
|           });
 | |
| 
 | |
|           this.state.liveEditor.onChange((newSource) => {
 | |
|             markdown.setContent(newSource);
 | |
|           });
 | |
|         }
 | |
| 
 | |
|         // Once the editor is created, reflect the current state.
 | |
|         if (this.state.isFocused && this.state.insertMode) {
 | |
|           this.state.liveEditor.focus();
 | |
|           // If the element is being scrolled to, focus interrupts it,
 | |
|           // so ensure the scrolling continues.
 | |
|           smoothlyScrollToElement(this.el);
 | |
| 
 | |
|           broadcastSelection(this);
 | |
|         }
 | |
| 
 | |
|         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);
 | |
|         });
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     if (this.props.type === "input") {
 | |
|       const input = getInput(this);
 | |
| 
 | |
|       input.addEventListener("blur", (event) => {
 | |
|         if (this.state.isFocused && this.state.insertMode) {
 | |
|           // We are still in the insert mode, so focus the input
 | |
|           // back once other handlers complete
 | |
|           setTimeout(() => {
 | |
|             input.focus();
 | |
|           }, 0);
 | |
|         }
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     this._unsubscribeFromCellsEvents = globalPubSub.subscribe(
 | |
|       "cells",
 | |
|       (event) => {
 | |
|         handleCellsEvent(this, event);
 | |
|       }
 | |
|     );
 | |
|   },
 | |
| 
 | |
|   destroyed() {
 | |
|     this._unsubscribeFromCellsEvents();
 | |
| 
 | |
|     if (this.state.liveEditor) {
 | |
|       this.state.liveEditor.dispose();
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   updated() {
 | |
|     this.props = getProps(this);
 | |
|   },
 | |
| };
 | |
| 
 | |
| function getProps(hook) {
 | |
|   return {
 | |
|     cellId: getAttributeOrThrow(hook.el, "data-cell-id"),
 | |
|     type: getAttributeOrThrow(hook.el, "data-type"),
 | |
|     sessionPath: getAttributeOrThrow(hook.el, "data-session-path"),
 | |
|   };
 | |
| }
 | |
| 
 | |
| function getInput(hook) {
 | |
|   if (hook.props.type === "input") {
 | |
|     return hook.el.querySelector(`[data-element="input"]`);
 | |
|   } else {
 | |
|     return null;
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Handles client-side cells event.
 | |
|  */
 | |
| function handleCellsEvent(hook, event) {
 | |
|   if (event.type === "cell_focused") {
 | |
|     handleCellFocused(hook, event.cellId, event.scroll);
 | |
|   } else if (event.type === "insert_mode_changed") {
 | |
|     handleInsertModeChanged(hook, event.enabled);
 | |
|   } else if (event.type === "cell_moved") {
 | |
|     handleCellMoved(hook, event.cellId);
 | |
|   } else if (event.type === "cell_upload") {
 | |
|     handleCellUpload(hook, event.cellId, event.url);
 | |
|   } else if (event.type === "location_report") {
 | |
|     handleLocationReport(hook, event.client, event.report);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function handleCellFocused(hook, cellId, scroll) {
 | |
|   if (hook.props.cellId === 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) {
 | |
|   if (hook.state.isFocused) {
 | |
|     hook.state.insertMode = insertMode;
 | |
| 
 | |
|     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);
 | |
| 
 | |
|         broadcastSelection(hook);
 | |
|       } else {
 | |
|         hook.state.liveEditor.blur();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     const input = getInput(hook);
 | |
| 
 | |
|     if (input) {
 | |
|       if (hook.state.insertMode) {
 | |
|         input.focus();
 | |
|         // selectionStart is only supported on text based input
 | |
|         if (input.selectionStart !== null) {
 | |
|           input.selectionStart = input.selectionEnd = input.value.length;
 | |
|         }
 | |
|       } else {
 | |
|         input.blur();
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function handleCellMoved(hook, cellId) {
 | |
|   if (hook.state.isFocused && cellId === hook.props.cellId) {
 | |
|     smoothlyScrollToElement(hook.el);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function handleCellUpload(hook, cellId, url) {
 | |
|   if (!hook.state.liveEditor) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (hook.props.cellId === cellId) {
 | |
|     const markdown = ``;
 | |
|     hook.state.liveEditor.insert(markdown);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function handleLocationReport(hook, client, report) {
 | |
|   if (!hook.state.liveEditor) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   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,
 | |
|     });
 | |
|   }
 | |
| }
 | |
| 
 | |
| export default Cell;
 |