diff --git a/assets/css/app.css b/assets/css/app.css index 50e984e73..f52d1fcd9 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -15,3 +15,109 @@ button:focus { iframe[hidden] { display: none; } + +/* Markdown rendered content */ + +.markdown { + @apply text-gray-700; +} + +.markdown h1 { + @apply text-gray-900 font-semibold text-3xl my-4; +} + +.markdown h2 { + @apply text-gray-900 font-semibold text-2xl my-4; +} + +.markdown h3 { + @apply text-gray-900 font-semibold text-xl my-4; +} + +.markdown p { + @apply my-4; +} + +.markdown ul { + @apply list-disc list-inside my-4; +} + +.markdown ol { + @apply list-decimal list-inside my-4; +} + +.markdown ul > li, +.markdown ol > li { + @apply my-1; +} + +.markdown ul > li ul, +.markdown ol > li ol { + @apply ml-6; +} + +.markdown blockquote { + @apply border-l-4 border-gray-200 pl-4 py-2 my-4 text-gray-500; +} + +.markdown a { + @apply font-medium underline text-gray-900 hover:no-underline; +} + +.markdown table { + @apply w-full my-4; +} + +.markdown table thead tr { + @apply border-b border-gray-200; +} + +.markdown table tbody tr:not(:last-child) { + @apply border-b border-gray-200; +} + +.markdown table th { + @apply p-2 font-bold; +} + +.markdown table td { + @apply p-2; +} + +.markdown table th:first-child, +.markdown table td:first-child { + @apply pl-0; +} + +.markdown table th:last-child, +.markdown table td:last-child { + @apply pr-0; +} + +.markdown code { + @apply py-1 px-2 rounded text-sm align-middle; + /* Match the editor colors */ + background-color: #282c34; + color: #abb2bf; +} + +.markdown pre > code { + @apply block p-4 rounded text-sm align-middle; + /* Match the editor colors */ + background-color: #282c34; + color: #abb2bf; +} + +.markdown :first-child { + @apply mt-0; +} + +.markdown :last-child { + @apply mb-0; +} + +/* Other */ + +.bg-editor { + background-color: #282c34; +} diff --git a/assets/js/app.js b/assets/js/app.js index 8608712d0..66b2e495b 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -5,11 +5,13 @@ import { Socket } from "phoenix"; import NProgress from "nprogress"; import { LiveSocket } from "phoenix_live_view"; import ContentEditable from "./content_editable"; -import Editor from "./editor"; +import Cell from "./cell"; +import Session from "./session"; const Hooks = { ContentEditable, - Editor, + Cell, + Session, }; const csrfToken = document diff --git a/assets/js/cell/index.js b/assets/js/cell/index.js new file mode 100644 index 000000000..84855342b --- /dev/null +++ b/assets/js/cell/index.js @@ -0,0 +1,93 @@ +import { + getAttributeOrThrow, + parseBoolean, + parseInteger, +} from "../lib/attribute"; +import LiveEditor from "./live_editor"; +import Markdown from "./markdown"; + +/** + * 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` - editor type (i.e. language), either "markdown" or "elixir" is expected + * * `data-focused` - whether the cell is currently focused + * * `data-expanded` - whether the cell is currently expanded (relevant for markdown cells) + */ +const Cell = { + mounted() { + this.props = getProps(this); + + this.pushEvent("cell_init", { cell_id: this.props.cellId }, (payload) => { + const { source, revision } = payload; + + const editorContainer = this.el.querySelector("[data-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.liveEditor = new LiveEditor( + this, + editorElement, + this.props.cellId, + this.props.type, + source, + revision + ); + + // Setup markdown rendering. + if (this.props.type === "markdown") { + const markdownContainer = this.el.querySelector( + "[data-markdown-container]" + ); + const markdown = new Markdown(markdownContainer, source); + + this.liveEditor.onChange((newSource) => { + markdown.setContent(newSource); + }); + } + }); + }, + + updated() { + const prevProps = this.props; + this.props = getProps(this); + + if (!isActive(prevProps) && isActive(this.props)) { + this.liveEditor.focus(); + } + + if (isActive(prevProps) && !isActive(this.props)) { + this.liveEditor.blur(); + } + }, +}; + +function getProps(hook) { + return { + cellId: getAttributeOrThrow(hook.el, "data-cell-id"), + type: getAttributeOrThrow(hook.el, "data-type"), + isFocused: getAttributeOrThrow(hook.el, "data-focused", parseBoolean), + isExpanded: getAttributeOrThrow(hook.el, "data-expanded", parseBoolean), + }; +} + +/** + * Checks if the cell editor is active and should have focus. + */ +function isActive(props) { + if (props.type === "markdown") { + return props.isFocused && props.isExpanded; + } else { + return props.isFocused; + } +} + +export default Cell; diff --git a/assets/js/cell/live_editor.js b/assets/js/cell/live_editor.js new file mode 100644 index 000000000..d24869d66 --- /dev/null +++ b/assets/js/cell/live_editor.js @@ -0,0 +1,103 @@ +import monaco from "./live_editor/monaco"; +import EditorClient from "./live_editor/editor_client"; +import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter"; +import HookServerAdapter from "./live_editor/hook_server_adapter"; + +/** + * Mounts cell source editor with real-time collaboration mechanism. + */ +class LiveEditor { + constructor(hook, container, cellId, type, source, revision) { + this.container = container; + this.cellId = cellId; + this.type = type; + this.source = source; + this._onChange = null; + + this.__mountEditor(); + + const serverAdapter = new HookServerAdapter(hook, cellId); + const editorAdapter = new MonacoEditorAdapter(this.editor); + this.editorClient = new EditorClient( + serverAdapter, + editorAdapter, + revision + ); + + this.editorClient.onDelta((delta) => { + this.source = delta.applyToString(this.source); + this._onChange && this._onChange(this.source); + }); + } + + /** + * Registers a callback called with a new cell content whenever it changes. + */ + onChange(callback) { + this._onChange = callback; + } + + focus() { + this.editor.focus(); + } + + blur() { + if (this.editor.hasTextFocus()) { + document.activeElement.blur(); + } + } + + __mountEditor() { + this.editor = monaco.editor.create(this.container, { + language: this.type, + value: this.source, + scrollbar: { + vertical: "hidden", + handleMouseWheel: false, + }, + minimap: { + enabled: false, + }, + overviewRulerLanes: 0, + scrollBeyondLastLine: false, + quickSuggestions: false, + renderIndentGuides: false, + occurrencesHighlight: false, + renderLineHighlight: "none", + theme: "custom", + }); + + this.editor.getModel().updateOptions({ + tabSize: 2, + }); + + this.editor.updateOptions({ + autoIndent: true, + tabSize: 2, + formatOnType: true, + }); + + // Automatically adjust the editor size to fit the container. + const resizeObserver = new ResizeObserver((entries) => { + entries.forEach((entry) => { + // Ignore hidden container. + if (this.container.offsetHeight > 0) { + this.editor.layout(); + } + }); + }); + + resizeObserver.observe(this.container); + + // Whenever editor content size changes (new line is added/removed) + // update the container height. Thanks to the above observer + // the editor is resized to fill the container. + // Related: https://github.com/microsoft/monaco-editor/issues/794#issuecomment-688959283 + this.editor.onDidContentSizeChange(() => { + const contentHeight = this.editor.getContentHeight(); + this.container.style.height = `${contentHeight}px`; + }); + } +} + +export default LiveEditor; diff --git a/assets/js/editor/editor_client.js b/assets/js/cell/live_editor/editor_client.js similarity index 87% rename from assets/js/editor/editor_client.js rename to assets/js/cell/live_editor/editor_client.js index c8ff13191..c0f67986e 100644 --- a/assets/js/editor/editor_client.js +++ b/assets/js/cell/live_editor/editor_client.js @@ -26,9 +26,12 @@ export default class EditorClient { this.editorAdapter = editorAdapter; this.revision = revision; this.state = new Synchronized(this); + this._onDelta = null; this.editorAdapter.onDelta((delta) => { this.__handleClientDelta(delta); + // This delta comes from the editor, so it has already been applied. + this.__emitDelta(delta); }); this.serverAdapter.onDelta((delta) => { @@ -40,6 +43,20 @@ export default class EditorClient { }); } + /** + * Registers a callback called with a every delta applied to the editor. + * + * These deltas are already transformed such that applying them + * one by one should eventually lead to the same state as on the server. + */ + onDelta(callback) { + this._onDelta = callback; + } + + __emitDelta(delta) { + this._onDelta && this._onDelta(delta); + } + __handleClientDelta(delta) { this.state = this.state.onClientDelta(delta); } @@ -50,16 +67,18 @@ export default class EditorClient { } __handleServerAcknowledgement() { + this.revision++; this.state = this.state.onServerAcknowledgement(); } applyDelta(delta) { this.editorAdapter.applyDelta(delta); + // This delta comes from the server and we have just applied it to the editor. + this.__emitDelta(delta); } sendDelta(delta) { - this.revision++; - this.serverAdapter.sendDelta(delta, this.revision); + this.serverAdapter.sendDelta(delta, this.revision + 1); } } diff --git a/assets/js/editor/elixir/language_configuration.js b/assets/js/cell/live_editor/elixir/language_configuration.js similarity index 100% rename from assets/js/editor/elixir/language_configuration.js rename to assets/js/cell/live_editor/elixir/language_configuration.js diff --git a/assets/js/editor/elixir/monarch_language.js b/assets/js/cell/live_editor/elixir/monarch_language.js similarity index 97% rename from assets/js/editor/elixir/monarch_language.js rename to assets/js/cell/live_editor/elixir/monarch_language.js index e9ce04a17..56f32ae27 100644 --- a/assets/js/editor/elixir/monarch_language.js +++ b/assets/js/cell/live_editor/elixir/monarch_language.js @@ -192,27 +192,34 @@ const ElixirMonarchLanguage = { [ // In-scope call - an identifier followed by ( or .( /(@variableName)(?=\s*\.?\s*\()/, - ['function.call'] + ["function.call"], ], [ // Referencing function in a module /(@moduleName)(\s*)(\.)(\s*)(@variableName)/, - ['type.identifier', 'white', 'operator', 'white', 'function.call'] + ["type.identifier", "white", "operator", "white", "function.call"], ], [ // Referencing function in an Erlang module /(:)(@atomName)(\s*)(\.)(\s*)(@variableName)/, - ["constant.punctuation", "constant", 'white', 'operator', 'white', 'function.call'] + [ + "constant.punctuation", + "constant", + "white", + "operator", + "white", + "function.call", + ], ], [ // Piping into a function (tokenized separately as it may not have parentheses) /(\|>)(\s*)(@variableName)/, - ['operator', 'white', 'function.call'] + ["operator", "white", "function.call"], ], [ // Function reference passed to another function /(&)(\s*)(@variableName)/, - ['operator', 'white', 'function.call'] + ["operator", "white", "function.call"], ], // Language keywords, builtins, constants and variables [ diff --git a/assets/js/editor/elixir/on_type_formatting_edit_provider.js b/assets/js/cell/live_editor/elixir/on_type_formatting_edit_provider.js similarity index 100% rename from assets/js/editor/elixir/on_type_formatting_edit_provider.js rename to assets/js/cell/live_editor/elixir/on_type_formatting_edit_provider.js diff --git a/assets/js/editor/hook_server_adapter.js b/assets/js/cell/live_editor/hook_server_adapter.js similarity index 93% rename from assets/js/editor/hook_server_adapter.js rename to assets/js/cell/live_editor/hook_server_adapter.js index c1695f878..ef8269e67 100644 --- a/assets/js/editor/hook_server_adapter.js +++ b/assets/js/cell/live_editor/hook_server_adapter.js @@ -1,4 +1,4 @@ -import Delta from "../lib/delta"; +import Delta from "../../lib/delta"; /** * Encapsulates logic related to sending/receiving messages from the server. @@ -39,7 +39,7 @@ export default class HookServerAdapter { * Sends the given delta to the server. */ sendDelta(delta, revision) { - this.hook.pushEvent("cell_delta", { + this.hook.pushEvent("apply_cell_delta", { cell_id: this.cellId, delta: delta.toCompressed(), revision, diff --git a/assets/js/cell/live_editor/monaco.js b/assets/js/cell/live_editor/monaco.js new file mode 100644 index 000000000..8f3d3a1ac --- /dev/null +++ b/assets/js/cell/live_editor/monaco.js @@ -0,0 +1,67 @@ +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import ElixirLanguageConfiguration from "./elixir/language_configuration"; +import ElixirMonarchLanguage from "./elixir/monarch_language"; +import ElixirOnTypeFormattingEditProvider from "./elixir/on_type_formatting_edit_provider"; + +// Register the Elixir language and add relevant configuration +monaco.languages.register({ id: "elixir" }); + +monaco.languages.setLanguageConfiguration( + "elixir", + ElixirLanguageConfiguration +); + +monaco.languages.registerOnTypeFormattingEditProvider( + "elixir", + ElixirOnTypeFormattingEditProvider +); + +monaco.languages.setMonarchTokensProvider("elixir", ElixirMonarchLanguage); + +// Define custom theme + +monaco.editor.defineTheme("custom", { + base: "vs-dark", + inherit: false, + rules: [ + { token: "", foreground: "#abb2bf" }, + { token: "variable", foreground: "#abb2bf" }, + { token: "constant", foreground: "#61afef" }, + { token: "constant.character.escape", foreground: "#61afef" }, + { token: "comment", foreground: "#5c6370" }, + { token: "number", foreground: "#61afef" }, + { token: "regexp", foreground: "#e06c75" }, + { token: "type", foreground: "#e06c75" }, + { token: "string", foreground: "#98c379" }, + { token: "keyword", foreground: "#c678dd" }, + { token: "operator", foreground: "#d19a66" }, + { token: "delimiter.bracket.embed", foreground: "#be5046" }, + { token: "sigil", foreground: "#56b6c2" }, + { token: "function", foreground: "#61afef" }, + { token: "function.call", foreground: "#abb2bf" }, + + // Markdown specific + { token: "emphasis", fontStyle: "italic" }, + { token: "strong", fontStyle: "bold" }, + { token: "keyword.md", foreground: "#e06c75" }, + { token: "keyword.table", foreground: "#e06c75" }, + { token: "string.link.md", foreground: "#61afef" }, + { token: "variable.md", foreground: "#56b6c2" }, + ], + + colors: { + "editor.background": "#282c34", + "editor.foreground": "#abb2bf", + "editorLineNumber.foreground": "#636d83", + "editorCursor.foreground": "#636d83", + "editor.selectionBackground": "#3e4451", + "editor.findMatchHighlightBackground": "#528bff3D", + "editorSuggestWidget.background": "#21252b", + "editorSuggestWidget.border": "#181a1f", + "editorSuggestWidget.selectedBackground": "#2c313a", + "input.background": "#1b1d23", + "input.border": "#181a1f", + }, +}); + +export default monaco; diff --git a/assets/js/editor/monaco_editor_adapter.js b/assets/js/cell/live_editor/monaco_editor_adapter.js similarity index 97% rename from assets/js/editor/monaco_editor_adapter.js rename to assets/js/cell/live_editor/monaco_editor_adapter.js index fa37f211c..b5aa6a299 100644 --- a/assets/js/editor/monaco_editor_adapter.js +++ b/assets/js/cell/live_editor/monaco_editor_adapter.js @@ -1,5 +1,5 @@ import monaco from "./monaco"; -import Delta, { isDelete, isInsert, isRetain } from "../lib/delta"; +import Delta, { isDelete, isInsert, isRetain } from "../../lib/delta"; /** * Encapsulates logic related to getting/applying changes to the editor. diff --git a/assets/js/cell/markdown.js b/assets/js/cell/markdown.js new file mode 100644 index 000000000..265c53fe2 --- /dev/null +++ b/assets/js/cell/markdown.js @@ -0,0 +1,44 @@ +import marked from "marked"; +import morphdom from "morphdom"; + +/** + * Renders markdown content in the given container. + */ +class Markdown { + constructor(container, content) { + this.container = container; + this.content = content; + + this.__render(); + } + + setContent(content) { + this.content = content; + this.__render(); + } + + __render() { + const html = this.__getHtml(); + // Wrap the HTML in another element, so that we + // can use morphdom's childrenOnly option. + const wrappedHtml = `
${html}
`; + + morphdom(this.container, wrappedHtml, { childrenOnly: true }); + } + + __getHtml() { + const html = marked(this.content); + + if (html) { + return html; + } else { + return ` +
+ Empty markdown cell +
+ `; + } + } +} + +export default Markdown; diff --git a/assets/js/content_editable.js b/assets/js/content_editable/index.js similarity index 76% rename from assets/js/content_editable.js rename to assets/js/content_editable/index.js index ad91c39b2..f8cbe552f 100644 --- a/assets/js/content_editable.js +++ b/assets/js/content_editable/index.js @@ -1,3 +1,5 @@ +import { getAttributeOrThrow } from "../lib/attribute"; + /** * A hook used on [contenteditable] elements to update the specified * attribute with the element text. @@ -8,7 +10,7 @@ */ const ContentEditable = { mounted() { - this.attribute = this.el.dataset.updateAttribute; + this.props = getProps(this); this.__updateAttribute(); @@ -26,14 +28,22 @@ const ContentEditable = { }, updated() { + this.props = getProps(this); + // The element has been re-rendered so we have to add the attribute back this.__updateAttribute(); }, __updateAttribute() { const value = this.el.innerText.trim(); - this.el.setAttribute(this.attribute, value); + this.el.setAttribute(this.props.attribute, value); }, }; +function getProps(hook) { + return { + attribute: getAttributeOrThrow(hook.el, "data-update-attribute"), + }; +} + export default ContentEditable; diff --git a/assets/js/editor/index.js b/assets/js/editor/index.js index ece6b9905..32ae2d87f 100644 --- a/assets/js/editor/index.js +++ b/assets/js/editor/index.js @@ -1,7 +1,12 @@ -import monaco from "./monaco"; -import EditorClient from "./editor_client"; -import MonacoEditorAdapter from "./monaco_editor_adapter"; +import monaco from "../cell/live_editor/monaco"; +import EditorClient from "../cell/live_editor/editor_client"; +import MonacoEditorAdapter from "../cell/live_editor/monaco_editor_adapter"; import HookServerAdapter from "./hook_server_adapter"; +import { + getAttributeOrThrow, + parseBoolean, + parseInteger, +} from "../lib/attribute"; /** * A hook managing an editable cell. @@ -14,38 +19,67 @@ import HookServerAdapter from "./hook_server_adapter"; * * * `data-cell-id` - id of the cell being edited * * `data-type` - editor type (i.e. language), either "markdown" or "elixir" is expected + * * `data-hidden` - whether this editor is currently hidden + * * `data-active` - whether this editor is currently the active one * * Additionally the root element should have a direct `div` child * with `data-source` and `data-revision` providing the initial values. */ const Editor = { mounted() { - this.cellId = this.el.dataset.cellId; - this.type = this.el.dataset.type; + this.props = getProps(this); - const editorContainer = this.el.querySelector("div"); + this.editorContainer = this.el.querySelector("div"); - if (!editorContainer) { + if (!this.editorContainer) { throw new Error("Editor Hook root element should have a div child"); } - const source = editorContainer.dataset.source; - const revision = +editorContainer.dataset.revision; + // Remove the content placeholder + this.editorContainer.firstElementChild.remove(); - this.editor = this.__mountEditor(editorContainer); + this.__mountEditor(); + + const source = getAttributeOrThrow(this.editorContainer, "data-source"); + const revision = getAttributeOrThrow( + this.editorContainer, + "data-revision", + parseInteger + ); this.editor.getModel().setValue(source); new EditorClient( - new HookServerAdapter(this, this.cellId), + new HookServerAdapter(this, this.props.cellId), new MonacoEditorAdapter(this.editor), revision ); }, - __mountEditor(editorContainer) { - const editor = monaco.editor.create(editorContainer, { - language: this.type, + updated() { + const prevProps = this.props; + this.props = getProps(this); + + if (prevProps.isHidden && !this.props.isHidden) { + // If the editor was created as hidden it didn't get the chance + // to properly adjust to the available space, so trigger it now. + this.__adjustEditorLayout(); + } + + if (!prevProps.isActive && this.props.isActive) { + this.editor.focus(); + } + + if (prevProps.isActive && !this.props.isActive) { + if (this.editor.hasTextFocus()) { + document.activeElement.blur(); + } + } + }, + + __mountEditor() { + this.editor = monaco.editor.create(this.editorContainer, { + language: this.props.type, value: "", scrollbar: { vertical: "hidden", @@ -63,32 +97,39 @@ const Editor = { theme: "custom", }); - editor.getModel().updateOptions({ + this.editor.getModel().updateOptions({ tabSize: 2, }); - editor.updateOptions({ + this.editor.updateOptions({ autoIndent: true, tabSize: 2, formatOnType: true, }); - // Dynamically adjust editor height to the content, see https://github.com/microsoft/monaco-editor/issues/794 - function adjustEditorLayout() { - const contentHeight = editor.getContentHeight(); - editorContainer.style.height = `${contentHeight}px`; - editor.layout(); - } - - editor.onDidContentSizeChange(adjustEditorLayout); - adjustEditorLayout(); + this.editor.onDidContentSizeChange(() => this.__adjustEditorLayout()); + this.__adjustEditorLayout(); window.addEventListener("resize", (event) => { - editor.layout(); + this.editor.layout(); }); + }, - return editor; + __adjustEditorLayout() { + // Dynamically adjust editor height to the content, see https://github.com/microsoft/monaco-editor/issues/794 + const contentHeight = this.editor.getContentHeight(); + this.editorContainer.style.height = `${contentHeight}px`; + this.editor.layout(); }, }; +function getProps(hook) { + return { + cellId: getAttributeOrThrow(hook.el, "data-cell-id"), + type: getAttributeOrThrow(hook.el, "data-type"), + isHidden: getAttributeOrThrow(hook.el, "data-hidden", parseBoolean), + isActive: getAttributeOrThrow(hook.el, "data-active", parseBoolean), + }; +} + export default Editor; diff --git a/assets/js/editor/monaco.js b/assets/js/editor/monaco.js deleted file mode 100644 index 4678cdb7c..000000000 --- a/assets/js/editor/monaco.js +++ /dev/null @@ -1,59 +0,0 @@ -import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; -import ElixirLanguageConfiguration from "./elixir/language_configuration"; -import ElixirMonarchLanguage from "./elixir/monarch_language"; -import ElixirOnTypeFormattingEditProvider from "./elixir/on_type_formatting_edit_provider"; - -// Register the Elixir language and add relevant configuration -monaco.languages.register({ id: "elixir" }); - -monaco.languages.setLanguageConfiguration( - "elixir", - ElixirLanguageConfiguration -); - -monaco.languages.registerOnTypeFormattingEditProvider( - "elixir", - ElixirOnTypeFormattingEditProvider -); - -monaco.languages.setMonarchTokensProvider("elixir", ElixirMonarchLanguage); - -// Define custom theme - -monaco.editor.defineTheme("custom", { - base: "vs", - inherit: false, - rules: [ - { token: "", foreground: "#444444" }, - { token: "variable", foreground: "#ca4956" }, - { token: "constant", foreground: "#3c91cf" }, - { token: "constant.character.escape", foreground: "#3c91cf" }, - { token: "comment", foreground: "#9e9e9e" }, - { token: "number", foreground: "#bf8b56" }, - { token: "regexp", foreground: "#ca4956" }, - { token: "type", foreground: "#ca4956" }, - { token: "string", foreground: "#50a14f" }, - { token: "keyword", foreground: "#9c00b0" }, - { token: "operator", foreground: "#cc5c52" }, - { token: "delimiter.bracket.embed", foreground: "#204a87" }, - { token: "sigil", foreground: "#bf8b56" }, - { token: "function", foreground: "#3c91cf" }, - { token: "function.call", foreground: "#444444" }, - - // Markdown specific - { token: "emphasis", fontStyle: "italic" }, - { token: "strong", fontStyle: "bold" }, - { token: "keyword.md", foreground: "#ca4956" }, - { token: "keyword.table", foreground: "#ca4956" }, - { token: "string.link.md", foreground: "#3c91cf" }, - { token: "variable.md", foreground: "#204a87" }, - ], - colors: { - "editor.background": "#fafafa", - "editorLineNumber.foreground": "#cfd8dc", - "editorCursor.foreground": "#666666", - "editor.selectionBackground": "#eeeeee", - }, -}); - -export default monaco; diff --git a/assets/js/lib/attribute.js b/assets/js/lib/attribute.js new file mode 100644 index 000000000..ffb1f54aa --- /dev/null +++ b/assets/js/lib/attribute.js @@ -0,0 +1,35 @@ +export function getAttributeOrThrow(element, attr, transform = null) { + if (!element.hasAttribute(attr)) { + throw new Error( + `Missing attribute '${attr}' on element <${element.tagName}:${element.id}>` + ); + } + + const value = element.getAttribute(attr); + + return transform ? transform(value) : value; +} + +export function parseBoolean(value) { + if (value === "true") { + return true; + } + + if (value === "false") { + return false; + } + + throw new Error( + `Invalid boolean attribute ${value}, should be either "true" or "false"` + ); +} + +export function parseInteger(value) { + const number = parseInt(value, 10); + + if (Number.isNaN(number)) { + throw new Error(`Invalid integer value ${value}`); + } + + return number; +} diff --git a/assets/js/lib/delta.js b/assets/js/lib/delta.js index ff4221574..7cfbc68f5 100644 --- a/assets/js/lib/delta.js +++ b/assets/js/lib/delta.js @@ -173,7 +173,7 @@ export default class Delta { } /** - * Converts the given delta to a compact representation, suitable for sending over the network. + * Converts the delta to a compact representation, suitable for sending over the network. */ toCompressed() { return this.ops.map((op) => { @@ -205,6 +205,33 @@ export default class Delta { throw new Error(`Invalid compressed operation ${compressedOp}`); }, new this()); } + + /** + * Returns the result of applying the delta to the given string. + */ + applyToString(string) { + let newString = ""; + let index = 0; + + this.ops.forEach((op) => { + if (isRetain(op)) { + newString += string.slice(index, index + op.retain); + index += op.retain; + } + + if (isInsert(op)) { + newString += op.insert; + } + + if (isDelete(op)) { + index += op.delete; + } + }); + + newString += string.slice(index); + + return newString; + } } /** diff --git a/assets/js/session/index.js b/assets/js/session/index.js new file mode 100644 index 000000000..e6d948df4 --- /dev/null +++ b/assets/js/session/index.js @@ -0,0 +1,69 @@ +import { getAttributeOrThrow } from "../lib/attribute"; + +/** + * A hook managing the whole session. + * + * Registers event listeners to handle keybindings and focus events. + * + * Configuration: + * + * * `data-focused-cell-id` - id of the cell currently being focused + */ +const Session = { + mounted() { + this.props = getProps(this); + + // Keybindings + document.addEventListener("keydown", (event) => { + if (event.shiftKey && event.key === "Enter" && !event.repeat) { + if (this.props.focusedCellId !== null) { + // If the editor is focused we don't want it to receive the input + event.preventDefault(); + this.pushEvent("toggle_cell_expanded", {}); + } + } else if (event.altKey && event.key === "j") { + event.preventDefault(); + this.pushEvent("move_cell_focus", { offset: 1 }); + } else if (event.altKey && event.key === "k") { + event.preventDefault(); + this.pushEvent("move_cell_focus", { offset: -1 }); + } + }); + + // Focus/unfocus a cell when the user clicks somewhere + document.addEventListener("click", (event) => { + // Find the parent with cell id info, if there is one + const cell = event.target.closest("[data-cell-id]"); + const cellId = cell ? cell.dataset.cellId : null; + if (cellId !== this.props.focusedCellId) { + this.pushEvent("focus_cell", { cell_id: cellId }); + } + }); + }, + + updated() { + const prevProps = this.props; + this.props = getProps(this); + + // When a new cell gets focus, center it nicely on the page + if ( + this.props.focusedCellId && + this.props.focusedCellId !== prevProps.focusedCellId + ) { + const cell = this.el.querySelector(`#cell-${this.props.focusedCellId}`); + cell.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, +}; + +function getProps(hook) { + return { + focusedCellId: getAttributeOrThrow( + hook.el, + "data-focused-cell-id", + (value) => value || null + ), + }; +} + +export default Session; diff --git a/assets/package-lock.json b/assets/package-lock.json index 428f29684..00068c3cf 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -7954,6 +7954,11 @@ "object-visit": "^1.0.0" } }, + "marked": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/marked/-/marked-1.2.8.tgz", + "integrity": "sha512-lzmFjGnzWHkmbk85q/ILZjFoHHJIQGF+SxGEfIdGk/XhiTPhqGs37gbru6Kkd48diJnEyYwnG67nru0Z2gQtuQ==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -8224,6 +8229,11 @@ } } }, + "morphdom": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.6.1.tgz", + "integrity": "sha512-Y8YRbAEP3eKykroIBWrjcfMw7mmwJfjhqdpSvoqinu8Y702nAwikpXcNFDiIkyvfCLxLM9Wu95RZqo4a9jFBaA==" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/assets/package.json b/assets/package.json index f225a66f7..c24d8d44e 100644 --- a/assets/package.json +++ b/assets/package.json @@ -9,7 +9,9 @@ "test": "jest --watch" }, "dependencies": { + "marked": "^1.2.8", "monaco-editor": "^0.21.2", + "morphdom": "^2.6.1", "nprogress": "^0.2.0", "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", diff --git a/assets/test/lib/delta.test.js b/assets/test/lib/delta.test.js index 4b418ec24..da4c70d84 100644 --- a/assets/test/lib/delta.test.js +++ b/assets/test/lib/delta.test.js @@ -252,4 +252,34 @@ describe("Delta", () => { expect(b).toEqual(new Delta().insert("B")); }); }); + + describe("applyToString", () => { + test("prepend", () => { + const string = "cats"; + const delta = new Delta().insert("fat "); + const result = delta.applyToString(string); + expect(result).toEqual("fat cats"); + }); + + test("insert in the middle", () => { + const string = "cats"; + const delta = new Delta().retain(3).insert("'"); + const result = delta.applyToString(string); + expect(result).toEqual("cat's"); + }); + + test("delete", () => { + const string = "cats"; + const delta = new Delta().retain(1).delete(2); + const result = delta.applyToString(string); + expect(result).toEqual("cs"); + }); + + test("replace", () => { + const string = "cats"; + const delta = new Delta().retain(1).delete(2).insert("ar"); + const result = delta.applyToString(string); + expect(result).toEqual("cars"); + }); + }); }); diff --git a/lib/live_book/notebook.ex b/lib/live_book/notebook.ex index 406bed998..2b4d7341b 100644 --- a/lib/live_book/notebook.ex +++ b/lib/live_book/notebook.ex @@ -50,7 +50,7 @@ defmodule LiveBook.Notebook do @doc """ Finds notebook cell by `id` and the corresponding section. """ - @spec fetch_cell_and_section(t(), Cell.section_id()) :: {:ok, Cell.t(), Section.t()} | :error + @spec fetch_cell_and_section(t(), Cell.id()) :: {:ok, Cell.t(), Section.t()} | :error def fetch_cell_and_section(notebook, cell_id) do for( section <- notebook.sections, @@ -64,6 +64,23 @@ defmodule LiveBook.Notebook do end end + @doc """ + Finds a cell being `offset` from the given cell within the same section. + """ + @spec fetch_cell_sibling(t(), Cell.id(), integer()) :: {:ok, Cell.t()} | :error + def fetch_cell_sibling(notebook, cell_id, offset) do + with {:ok, cell, section} <- fetch_cell_and_section(notebook, cell_id) do + idx = Enum.find_index(section.cells, &(&1 == cell)) + sibling_idx = idx + offset + + if sibling_idx >= 0 and sibling_idx < length(section.cells) do + {:ok, Enum.at(section.cells, sibling_idx)} + else + :error + end + end + end + @doc """ Inserts `section` at the given `index`. """ diff --git a/lib/live_book_web/live/cell.ex b/lib/live_book_web/live/cell.ex index dd49cbf57..52e765ee0 100644 --- a/lib/live_book_web/live/cell.ex +++ b/lib/live_book_web/live/cell.ex @@ -3,26 +3,97 @@ defmodule LiveBookWeb.Cell do def render(assigns) do ~L""" -
"> -
- - +
"> + <%= render_cell_content(assigns) %> +
+ """ + end + + def render_cell_content(%{cell: %{type: :markdown}} = assigns) do + ~L""" +
+ +
+ +
"> + <%= render_editor(@cell) %> +
+ +
+ <%= render_markdown_content_placeholder(@cell.source) %> +
+ """ + end + + def render_cell_content(%{cell: %{type: :elixir}} = assigns) do + ~L""" +
+ + +
+ + <%= render_editor(@cell) %> + """ + end + + defp render_editor(cell) do + ~E""" +
+ <%= render_editor_content_placeholder(cell.source) %> +
+ """ + end + + # The whole page has to load and then hooks are mounded. + # There may be a tiny delay before the markdown is rendered + # or and editors are mounted, so show neat placeholders immediately. + + defp render_markdown_content_placeholder("" = _content) do + ~E""" +
+ """ + end + + defp render_markdown_content_placeholder(_content) do + ~E""" +
+
+
+
+
-
-
-
+
+ """ + end + + defp render_editor_content_placeholder("" = _content) do + ~E""" +
+ """ + end + + defp render_editor_content_placeholder(_content) do + ~E""" +
+
+
+
+
""" diff --git a/lib/live_book_web/live/section.ex b/lib/live_book_web/live/section.ex index b8eac8590..ae17a72f3 100644 --- a/lib/live_book_web/live/section.ex +++ b/lib/live_book_web/live/section.ex @@ -23,7 +23,7 @@ defmodule LiveBookWeb.Section do
-
+
<%= live_component @socket, LiveBookWeb.InsertCellActions, section_id: @section.id, index: 0 %> @@ -31,7 +31,8 @@ defmodule LiveBookWeb.Section do <%= live_component @socket, LiveBookWeb.Cell, cell: cell, cell_info: @cell_infos[cell.id], - focused: cell.id == @focused_cell_id %> + focused: @selected and cell.id == @focused_cell_id, + expanded: @selected and cell.id == @focused_cell_id and @focused_cell_expanded %> <%= live_component @socket, LiveBookWeb.InsertCellActions, section_id: @section.id, index: index + 1 %> diff --git a/lib/live_book_web/live/session_live.ex b/lib/live_book_web/live/session_live.ex index 6253bcdc2..5d0118331 100644 --- a/lib/live_book_web/live/session_live.ex +++ b/lib/live_book_web/live/session_live.ex @@ -1,7 +1,7 @@ defmodule LiveBookWeb.SessionLive do use LiveBookWeb, :live_view - alias LiveBook.{SessionSupervisor, Session, Delta} + alias LiveBook.{SessionSupervisor, Session, Delta, Notebook} @impl true def mount(%{"id" => session_id}, _session, socket) do @@ -33,14 +33,18 @@ defmodule LiveBookWeb.SessionLive do session_id: session_id, data: data, selected_section_id: first_section_id, - focused_cell_id: nil + focused_cell_id: nil, + focused_cell_expanded: false } end @impl true def render(assigns) do ~L""" -
+

<% end %> -
+
+
@@ -74,7 +78,8 @@ defmodule LiveBookWeb.SessionLive do section: section, selected: section.id == @selected_section_id, cell_infos: @data.cell_infos, - focused_cell_id: @focused_cell_id %> + focused_cell_id: @focused_cell_id, + focused_cell_expanded: @focused_cell_expanded %> <% end %>
@@ -83,6 +88,23 @@ defmodule LiveBookWeb.SessionLive do end @impl true + def handle_event("cell_init", %{"cell_id" => cell_id}, socket) do + data = socket.assigns.data + + case Notebook.fetch_cell_and_section(data.notebook, cell_id) do + {:ok, cell, _section} -> + payload = %{ + source: cell.source, + revision: data.cell_infos[cell.id].revision + } + + {:reply, payload, socket} + + :error -> + {:noreply, socket} + end + end + def handle_event("add_section", _params, socket) do end_index = length(socket.assigns.data.notebook.sections) Session.insert_section(socket.assigns.session_id, end_index) @@ -118,10 +140,6 @@ defmodule LiveBookWeb.SessionLive do {:noreply, socket} end - def handle_event("focus_cell", %{"cell_id" => cell_id}, socket) do - {:noreply, assign(socket, focused_cell_id: cell_id)} - end - def handle_event("set_notebook_name", %{"name" => name}, socket) do name = normalize_name(name) Session.set_notebook_name(socket.assigns.session_id, name) @@ -137,7 +155,7 @@ defmodule LiveBookWeb.SessionLive do end def handle_event( - "cell_delta", + "apply_cell_delta", %{"cell_id" => cell_id, "delta" => delta, "revision" => revision}, socket ) do @@ -147,13 +165,25 @@ defmodule LiveBookWeb.SessionLive do {:noreply, socket} end - defp normalize_name(name) do - name - |> String.trim() - |> String.replace(~r/\s+/, " ") - |> case do - "" -> "Untitled" - name -> name + def handle_event("focus_cell", %{"cell_id" => cell_id}, socket) do + {:noreply, assign(socket, focused_cell_id: cell_id, focused_cell_expanded: false)} + end + + def handle_event("move_cell_focus", %{"offset" => offset}, socket) do + case new_focused_cell_from_offset(socket.assigns, offset) do + {:ok, cell} -> + {:noreply, assign(socket, focused_cell_id: cell.id, focused_cell_expanded: false)} + + :error -> + {:noreply, socket} + end + end + + def handle_event("toggle_cell_expanded", %{}, socket) do + if socket.assigns.focused_cell_id do + {:noreply, assign(socket, focused_cell_expanded: !socket.assigns.focused_cell_expanded)} + else + {:noreply, socket} end end @@ -182,4 +212,32 @@ defmodule LiveBookWeb.SessionLive do end defp handle_action(socket, _action), do: socket + + defp normalize_name(name) do + name + |> String.trim() + |> String.replace(~r/\s+/, " ") + |> case do + "" -> "Untitled" + name -> name + end + end + + defp new_focused_cell_from_offset(assigns, offset) do + cond do + assigns.focused_cell_id -> + # If a cell is focused, look up the appropriate sibling + Notebook.fetch_cell_sibling(assigns.data.notebook, assigns.focused_cell_id, offset) + + assigns.selected_section_id -> + # If no cell is focused, focus the first one for easier keyboard navigation. + {:ok, section} = + Notebook.fetch_section(assigns.data.notebook, assigns.selected_section_id) + + Enum.fetch(section.cells, 0) + + true -> + :error + end + end end diff --git a/lib/live_book_web/templates/layout/root.html.leex b/lib/live_book_web/templates/layout/root.html.leex index 8fcbd75dd..78e3ce51a 100644 --- a/lib/live_book_web/templates/layout/root.html.leex +++ b/lib/live_book_web/templates/layout/root.html.leex @@ -9,7 +9,7 @@ "/> - + <%= @inner_content %> diff --git a/test/live_book/evaluator_test.exs b/test/live_book/evaluator_test.exs index 2122c02a4..c7bbb9c31 100644 --- a/test/live_book/evaluator_test.exs +++ b/test/live_book/evaluator_test.exs @@ -99,7 +99,9 @@ defmodule LiveBook.EvaluatorTest do ] # Note: evaluating module definitions is relatively slow, so we use a higher wait timeout. - assert_receive {:evaluator_response, :code_1, {:error, _kind, _error, ^expected_stacktrace}}, 1000 + assert_receive {:evaluator_response, :code_1, + {:error, _kind, _error, ^expected_stacktrace}}, + 1000 end end diff --git a/test/live_book/notebook_test.exs b/test/live_book/notebook_test.exs new file mode 100644 index 000000000..30701b8f9 --- /dev/null +++ b/test/live_book/notebook_test.exs @@ -0,0 +1,48 @@ +defmodule LiveBook.NotebookTest do + use ExUnit.Case, async: true + + alias LiveBook.Notebook + alias LiveBook.Notebook.{Section, Cell} + + describe "fetch_cell_sibling/3" do + test "returns error given invalid cell id" do + notebook = Notebook.new() + + assert :error == Notebook.fetch_cell_sibling(notebook, "1", 0) + end + + test "returns sibling cell if there is one at the given offset" do + cell1 = %{Cell.new(:markdown) | id: "1"} + cell2 = %{Cell.new(:markdown) | id: "2"} + cell3 = %{Cell.new(:markdown) | id: "3"} + cell4 = %{Cell.new(:markdown) | id: "4"} + + notebook = %{ + Notebook.new() + | sections: [ + %{Section.new() | cells: [cell1, cell2, cell3, cell4]} + ] + } + + assert {:ok, cell1} == Notebook.fetch_cell_sibling(notebook, cell2.id, -1) + assert {:ok, cell3} == Notebook.fetch_cell_sibling(notebook, cell2.id, 1) + assert {:ok, cell4} == Notebook.fetch_cell_sibling(notebook, cell2.id, 2) + end + + test "returns error if the offset is out of range" do + cell1 = %{Cell.new(:markdown) | id: "1"} + cell2 = %{Cell.new(:markdown) | id: "2"} + + notebook = %{ + Notebook.new() + | sections: [ + %{Section.new() | cells: [cell1, cell2]} + ] + } + + assert :error == Notebook.fetch_cell_sibling(notebook, cell2.id, -2) + assert :error == Notebook.fetch_cell_sibling(notebook, cell2.id, 1) + assert :error == Notebook.fetch_cell_sibling(notebook, cell2.id, 2) + end + end +end diff --git a/test/live_book_web/live/session_live_test.exs b/test/live_book_web/live/session_live_test.exs new file mode 100644 index 000000000..6d3f62c80 --- /dev/null +++ b/test/live_book_web/live/session_live_test.exs @@ -0,0 +1,108 @@ +defmodule LiveBookWeb.SessionLiveTest do + use LiveBookWeb.ConnCase + + import Phoenix.LiveViewTest + + alias LiveBook.{SessionSupervisor, Session} + + setup do + {:ok, session_id} = SessionSupervisor.create_session() + %{session_id: session_id} + end + + test "disconnected and connected render", %{conn: conn, session_id: session_id} do + {:ok, view, disconnected_html} = live(conn, "/sessions/#{session_id}") + assert disconnected_html =~ "Untitled notebook" + assert render(view) =~ "Untitled notebook" + end + + describe "asynchronous updates" do + test "renders an updated notebook name", %{conn: conn, session_id: session_id} do + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + Session.set_notebook_name(session_id, "My notebook") + wait_for_session_update(session_id) + + assert render(view) =~ "My notebook" + end + + test "renders a newly inserted section", %{conn: conn, session_id: session_id} do + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + Session.insert_section(session_id, 0) + %{notebook: %{sections: [section]}} = Session.get_data(session_id) + + assert render(view) =~ section.id + end + + test "renders an updated section name", %{conn: conn, session_id: session_id} do + Session.insert_section(session_id, 0) + %{notebook: %{sections: [section]}} = Session.get_data(session_id) + + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + Session.set_section_name(session_id, section.id, "My section") + wait_for_session_update(session_id) + + assert render(view) =~ "My section" + end + + test "renders a newly inserted cell", %{conn: conn, session_id: session_id} do + Session.insert_section(session_id, 0) + %{notebook: %{sections: [section]}} = Session.get_data(session_id) + + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + Session.insert_cell(session_id, section.id, 0, :markdown) + %{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_id) + + assert render(view) =~ cell.id + end + + test "un-renders a deleted cell", %{conn: conn, session_id: session_id} do + Session.insert_section(session_id, 0) + %{notebook: %{sections: [section]}} = Session.get_data(session_id) + Session.insert_cell(session_id, section.id, 0, :markdown) + %{notebook: %{sections: [%{cells: [cell]}]}} = Session.get_data(session_id) + + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + Session.delete_cell(session_id, cell.id) + wait_for_session_update(session_id) + + refute render(view) =~ cell.id + end + end + + describe "UI-triggered updates" do + test "adding a new session updates the shared state", %{conn: conn, session_id: session_id} do + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + view + |> element("button", "New section") + |> render_click() + + assert %{notebook: %{sections: [_section]}} = Session.get_data(session_id) + end + + test "adding a new cell updates the shared state", %{conn: conn, session_id: session_id} do + Session.insert_section(session_id, 0) + + {:ok, view, _} = live(conn, "/sessions/#{session_id}") + + view + |> element("button", "+ Markdown") + |> render_click() + + assert %{notebook: %{sections: [%{cells: [%{type: :markdown}]}]}} = + Session.get_data(session_id) + end + end + + defp wait_for_session_update(session_id) do + # This call is synchronous, so it gives the session time + # for handling the previously sent change messages. + Session.get_data(session_id) + :ok + end +end