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"""
- ">
-
-
- <%= Icons.svg(:play, class: "h-6") %>
-
-
- <%= Icons.svg(:trash, class: "h-6") %>
-
+
">
+ <%= render_cell_content(assigns) %>
+
+ """
+ end
+
+ def render_cell_content(%{cell: %{type: :markdown}} = assigns) do
+ ~L"""
+
+
+ <%= Icons.svg(:trash, class: "h-6") %>
+
+
+
+
">
+ <%= render_editor(@cell) %>
+
+
+
+ <%= render_markdown_content_placeholder(@cell.source) %>
+
+ """
+ end
+
+ def render_cell_content(%{cell: %{type: :elixir}} = assigns) do
+ ~L"""
+
+
+ <%= Icons.svg(:play, class: "h-6") %>
+
+
+ <%= Icons.svg(:trash, class: "h-6") %>
+
+
+
+ <%= 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 %>
-
+
<%= Icons.svg(:plus, class: "h-6") %>
New section
-
+
@@ -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