mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-08 14:04:31 +08:00
Set up markdown rendering (#16)
* Set up markdown rendering, update theme. * Improve focus and handle expanding for markdown cells * Add keybindings for expanding/navigating cells * Improve editor autofocus when navigating with shortcuts * Add tests * Render markdown on the client * Don't render cell initial data and make a request instead
This commit is contained in:
parent
ca36e22af0
commit
936d0af5fb
29 changed files with 1054 additions and 143 deletions
|
@ -15,3 +15,109 @@ button:focus {
|
||||||
iframe[hidden] {
|
iframe[hidden] {
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -5,11 +5,13 @@ import { Socket } from "phoenix";
|
||||||
import NProgress from "nprogress";
|
import NProgress from "nprogress";
|
||||||
import { LiveSocket } from "phoenix_live_view";
|
import { LiveSocket } from "phoenix_live_view";
|
||||||
import ContentEditable from "./content_editable";
|
import ContentEditable from "./content_editable";
|
||||||
import Editor from "./editor";
|
import Cell from "./cell";
|
||||||
|
import Session from "./session";
|
||||||
|
|
||||||
const Hooks = {
|
const Hooks = {
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
Editor,
|
Cell,
|
||||||
|
Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
const csrfToken = document
|
const csrfToken = document
|
||||||
|
|
93
assets/js/cell/index.js
Normal file
93
assets/js/cell/index.js
Normal file
|
@ -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;
|
103
assets/js/cell/live_editor.js
Normal file
103
assets/js/cell/live_editor.js
Normal file
|
@ -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;
|
|
@ -26,9 +26,12 @@ export default class EditorClient {
|
||||||
this.editorAdapter = editorAdapter;
|
this.editorAdapter = editorAdapter;
|
||||||
this.revision = revision;
|
this.revision = revision;
|
||||||
this.state = new Synchronized(this);
|
this.state = new Synchronized(this);
|
||||||
|
this._onDelta = null;
|
||||||
|
|
||||||
this.editorAdapter.onDelta((delta) => {
|
this.editorAdapter.onDelta((delta) => {
|
||||||
this.__handleClientDelta(delta);
|
this.__handleClientDelta(delta);
|
||||||
|
// This delta comes from the editor, so it has already been applied.
|
||||||
|
this.__emitDelta(delta);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.serverAdapter.onDelta((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) {
|
__handleClientDelta(delta) {
|
||||||
this.state = this.state.onClientDelta(delta);
|
this.state = this.state.onClientDelta(delta);
|
||||||
}
|
}
|
||||||
|
@ -50,16 +67,18 @@ export default class EditorClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
__handleServerAcknowledgement() {
|
__handleServerAcknowledgement() {
|
||||||
|
this.revision++;
|
||||||
this.state = this.state.onServerAcknowledgement();
|
this.state = this.state.onServerAcknowledgement();
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDelta(delta) {
|
applyDelta(delta) {
|
||||||
this.editorAdapter.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) {
|
sendDelta(delta) {
|
||||||
this.revision++;
|
this.serverAdapter.sendDelta(delta, this.revision + 1);
|
||||||
this.serverAdapter.sendDelta(delta, this.revision);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -192,27 +192,34 @@ const ElixirMonarchLanguage = {
|
||||||
[
|
[
|
||||||
// In-scope call - an identifier followed by ( or .(
|
// In-scope call - an identifier followed by ( or .(
|
||||||
/(@variableName)(?=\s*\.?\s*\()/,
|
/(@variableName)(?=\s*\.?\s*\()/,
|
||||||
['function.call']
|
["function.call"],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
// Referencing function in a module
|
// Referencing function in a module
|
||||||
/(@moduleName)(\s*)(\.)(\s*)(@variableName)/,
|
/(@moduleName)(\s*)(\.)(\s*)(@variableName)/,
|
||||||
['type.identifier', 'white', 'operator', 'white', 'function.call']
|
["type.identifier", "white", "operator", "white", "function.call"],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
// Referencing function in an Erlang module
|
// Referencing function in an Erlang module
|
||||||
/(:)(@atomName)(\s*)(\.)(\s*)(@variableName)/,
|
/(:)(@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)
|
// Piping into a function (tokenized separately as it may not have parentheses)
|
||||||
/(\|>)(\s*)(@variableName)/,
|
/(\|>)(\s*)(@variableName)/,
|
||||||
['operator', 'white', 'function.call']
|
["operator", "white", "function.call"],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
// Function reference passed to another function
|
// Function reference passed to another function
|
||||||
/(&)(\s*)(@variableName)/,
|
/(&)(\s*)(@variableName)/,
|
||||||
['operator', 'white', 'function.call']
|
["operator", "white", "function.call"],
|
||||||
],
|
],
|
||||||
// Language keywords, builtins, constants and variables
|
// Language keywords, builtins, constants and variables
|
||||||
[
|
[
|
|
@ -1,4 +1,4 @@
|
||||||
import Delta from "../lib/delta";
|
import Delta from "../../lib/delta";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates logic related to sending/receiving messages from the server.
|
* 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.
|
* Sends the given delta to the server.
|
||||||
*/
|
*/
|
||||||
sendDelta(delta, revision) {
|
sendDelta(delta, revision) {
|
||||||
this.hook.pushEvent("cell_delta", {
|
this.hook.pushEvent("apply_cell_delta", {
|
||||||
cell_id: this.cellId,
|
cell_id: this.cellId,
|
||||||
delta: delta.toCompressed(),
|
delta: delta.toCompressed(),
|
||||||
revision,
|
revision,
|
67
assets/js/cell/live_editor/monaco.js
Normal file
67
assets/js/cell/live_editor/monaco.js
Normal file
|
@ -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;
|
|
@ -1,5 +1,5 @@
|
||||||
import monaco from "./monaco";
|
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.
|
* Encapsulates logic related to getting/applying changes to the editor.
|
44
assets/js/cell/markdown.js
Normal file
44
assets/js/cell/markdown.js
Normal file
|
@ -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 = `<div>${html}</div>`;
|
||||||
|
|
||||||
|
morphdom(this.container, wrappedHtml, { childrenOnly: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
__getHtml() {
|
||||||
|
const html = marked(this.content);
|
||||||
|
|
||||||
|
if (html) {
|
||||||
|
return html;
|
||||||
|
} else {
|
||||||
|
return `
|
||||||
|
<div class="text-gray-300">
|
||||||
|
Empty markdown cell
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Markdown;
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { getAttributeOrThrow } from "../lib/attribute";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A hook used on [contenteditable] elements to update the specified
|
* A hook used on [contenteditable] elements to update the specified
|
||||||
* attribute with the element text.
|
* attribute with the element text.
|
||||||
|
@ -8,7 +10,7 @@
|
||||||
*/
|
*/
|
||||||
const ContentEditable = {
|
const ContentEditable = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.attribute = this.el.dataset.updateAttribute;
|
this.props = getProps(this);
|
||||||
|
|
||||||
this.__updateAttribute();
|
this.__updateAttribute();
|
||||||
|
|
||||||
|
@ -26,14 +28,22 @@ const ContentEditable = {
|
||||||
},
|
},
|
||||||
|
|
||||||
updated() {
|
updated() {
|
||||||
|
this.props = getProps(this);
|
||||||
|
|
||||||
// The element has been re-rendered so we have to add the attribute back
|
// The element has been re-rendered so we have to add the attribute back
|
||||||
this.__updateAttribute();
|
this.__updateAttribute();
|
||||||
},
|
},
|
||||||
|
|
||||||
__updateAttribute() {
|
__updateAttribute() {
|
||||||
const value = this.el.innerText.trim();
|
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;
|
export default ContentEditable;
|
|
@ -1,7 +1,12 @@
|
||||||
import monaco from "./monaco";
|
import monaco from "../cell/live_editor/monaco";
|
||||||
import EditorClient from "./editor_client";
|
import EditorClient from "../cell/live_editor/editor_client";
|
||||||
import MonacoEditorAdapter from "./monaco_editor_adapter";
|
import MonacoEditorAdapter from "../cell/live_editor/monaco_editor_adapter";
|
||||||
import HookServerAdapter from "./hook_server_adapter";
|
import HookServerAdapter from "./hook_server_adapter";
|
||||||
|
import {
|
||||||
|
getAttributeOrThrow,
|
||||||
|
parseBoolean,
|
||||||
|
parseInteger,
|
||||||
|
} from "../lib/attribute";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A hook managing an editable cell.
|
* 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-cell-id` - id of the cell being edited
|
||||||
* * `data-type` - editor type (i.e. language), either "markdown" or "elixir" is expected
|
* * `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
|
* Additionally the root element should have a direct `div` child
|
||||||
* with `data-source` and `data-revision` providing the initial values.
|
* with `data-source` and `data-revision` providing the initial values.
|
||||||
*/
|
*/
|
||||||
const Editor = {
|
const Editor = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.cellId = this.el.dataset.cellId;
|
this.props = getProps(this);
|
||||||
this.type = this.el.dataset.type;
|
|
||||||
|
|
||||||
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");
|
throw new Error("Editor Hook root element should have a div child");
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = editorContainer.dataset.source;
|
// Remove the content placeholder
|
||||||
const revision = +editorContainer.dataset.revision;
|
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);
|
this.editor.getModel().setValue(source);
|
||||||
|
|
||||||
new EditorClient(
|
new EditorClient(
|
||||||
new HookServerAdapter(this, this.cellId),
|
new HookServerAdapter(this, this.props.cellId),
|
||||||
new MonacoEditorAdapter(this.editor),
|
new MonacoEditorAdapter(this.editor),
|
||||||
revision
|
revision
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
__mountEditor(editorContainer) {
|
updated() {
|
||||||
const editor = monaco.editor.create(editorContainer, {
|
const prevProps = this.props;
|
||||||
language: this.type,
|
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: "",
|
value: "",
|
||||||
scrollbar: {
|
scrollbar: {
|
||||||
vertical: "hidden",
|
vertical: "hidden",
|
||||||
|
@ -63,32 +97,39 @@ const Editor = {
|
||||||
theme: "custom",
|
theme: "custom",
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.getModel().updateOptions({
|
this.editor.getModel().updateOptions({
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.updateOptions({
|
this.editor.updateOptions({
|
||||||
autoIndent: true,
|
autoIndent: true,
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
formatOnType: true,
|
formatOnType: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dynamically adjust editor height to the content, see https://github.com/microsoft/monaco-editor/issues/794
|
this.editor.onDidContentSizeChange(() => this.__adjustEditorLayout());
|
||||||
function adjustEditorLayout() {
|
this.__adjustEditorLayout();
|
||||||
const contentHeight = editor.getContentHeight();
|
|
||||||
editorContainer.style.height = `${contentHeight}px`;
|
|
||||||
editor.layout();
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.onDidContentSizeChange(adjustEditorLayout);
|
|
||||||
adjustEditorLayout();
|
|
||||||
|
|
||||||
window.addEventListener("resize", (event) => {
|
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;
|
export default Editor;
|
||||||
|
|
|
@ -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;
|
|
35
assets/js/lib/attribute.js
Normal file
35
assets/js/lib/attribute.js
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -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() {
|
toCompressed() {
|
||||||
return this.ops.map((op) => {
|
return this.ops.map((op) => {
|
||||||
|
@ -205,6 +205,33 @@ export default class Delta {
|
||||||
throw new Error(`Invalid compressed operation ${compressedOp}`);
|
throw new Error(`Invalid compressed operation ${compressedOp}`);
|
||||||
}, new this());
|
}, 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
69
assets/js/session/index.js
Normal file
69
assets/js/session/index.js
Normal file
|
@ -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;
|
10
assets/package-lock.json
generated
10
assets/package-lock.json
generated
|
@ -7954,6 +7954,11 @@
|
||||||
"object-visit": "^1.0.0"
|
"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": {
|
"md5.js": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
"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": {
|
"move-concurrently": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
|
||||||
|
|
|
@ -9,7 +9,9 @@
|
||||||
"test": "jest --watch"
|
"test": "jest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"marked": "^1.2.8",
|
||||||
"monaco-editor": "^0.21.2",
|
"monaco-editor": "^0.21.2",
|
||||||
|
"morphdom": "^2.6.1",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"phoenix": "file:../deps/phoenix",
|
"phoenix": "file:../deps/phoenix",
|
||||||
"phoenix_html": "file:../deps/phoenix_html",
|
"phoenix_html": "file:../deps/phoenix_html",
|
||||||
|
|
|
@ -252,4 +252,34 @@ describe("Delta", () => {
|
||||||
expect(b).toEqual(new Delta().insert("B"));
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -50,7 +50,7 @@ defmodule LiveBook.Notebook do
|
||||||
@doc """
|
@doc """
|
||||||
Finds notebook cell by `id` and the corresponding section.
|
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
|
def fetch_cell_and_section(notebook, cell_id) do
|
||||||
for(
|
for(
|
||||||
section <- notebook.sections,
|
section <- notebook.sections,
|
||||||
|
@ -64,6 +64,23 @@ defmodule LiveBook.Notebook do
|
||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Inserts `section` at the given `index`.
|
Inserts `section` at the given `index`.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -3,9 +3,38 @@ defmodule LiveBookWeb.Cell do
|
||||||
|
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~L"""
|
~L"""
|
||||||
<div phx-click="focus_cell"
|
<div id="cell-<%= @cell.id %>"
|
||||||
phx-value-cell_id="<%= @cell.id %>"
|
phx-hook="Cell"
|
||||||
class="flex flex-col relative mr-10 border-2 border-gray-200 rounded border-opacity-0 <%= if @focused, do: "border-opacity-100"%>">
|
data-cell-id="<%= @cell.id %>"
|
||||||
|
data-type="<%= @cell.type %>"
|
||||||
|
data-focused="<%= @focused %>"
|
||||||
|
data-expanded="<%= @expanded %>"
|
||||||
|
class="flex flex-col relative mr-10 border-l-4 pl-4 -ml-4 border-blue-100 border-opacity-0 hover:border-opacity-100 <%= if @focused, do: "border-blue-300 border-opacity-100"%>">
|
||||||
|
<%= render_cell_content(assigns) %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_cell_content(%{cell: %{type: :markdown}} = assigns) do
|
||||||
|
~L"""
|
||||||
|
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
|
||||||
|
<button phx-click="delete_cell" phx-value-cell_id="<%= @cell.id %>" class="text-gray-500 hover:text-current">
|
||||||
|
<%= Icons.svg(:trash, class: "h-6") %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="<%= if @expanded, do: "mb-4", else: "hidden" %>">
|
||||||
|
<%= render_editor(@cell) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="markdown" data-markdown-container id="markdown-container-<%= @cell.id %>" phx-update="ignore">
|
||||||
|
<%= render_markdown_content_placeholder(@cell.source) %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_cell_content(%{cell: %{type: :elixir}} = assigns) do
|
||||||
|
~L"""
|
||||||
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
|
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
|
||||||
<button class="text-gray-500 hover:text-current">
|
<button class="text-gray-500 hover:text-current">
|
||||||
<%= Icons.svg(:play, class: "h-6") %>
|
<%= Icons.svg(:play, class: "h-6") %>
|
||||||
|
@ -14,15 +43,57 @@ defmodule LiveBookWeb.Cell do
|
||||||
<%= Icons.svg(:trash, class: "h-6") %>
|
<%= Icons.svg(:trash, class: "h-6") %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
id="cell-<%= @cell.id %>-editor"
|
<%= render_editor(@cell) %>
|
||||||
phx-hook="Editor"
|
"""
|
||||||
phx-update="ignore"
|
end
|
||||||
data-cell-id="<%= @cell.id %>"
|
|
||||||
data-type="<%= @cell.type %>">
|
defp render_editor(cell) do
|
||||||
<div data-source="<%= @cell.source %>"
|
~E"""
|
||||||
data-revision="<%= @cell_info.revision %>">
|
<div class="py-3 rounded-md overflow-hidden bg-editor"
|
||||||
|
data-editor-container
|
||||||
|
id="editor-container-<%= cell.id %>"
|
||||||
|
phx-update="ignore">
|
||||||
|
<%= render_editor_content_placeholder(cell.source) %>
|
||||||
</div>
|
</div>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div class="h-4"></div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_markdown_content_placeholder(_content) do
|
||||||
|
~E"""
|
||||||
|
<div class="max-w-2xl w-full animate-pulse">
|
||||||
|
<div class="flex-1 space-y-4">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_editor_content_placeholder("" = _content) do
|
||||||
|
~E"""
|
||||||
|
<div class="h-4"></div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_editor_content_placeholder(_content) do
|
||||||
|
~E"""
|
||||||
|
<div class="px-8 max-w-2xl w-full animate-pulse">
|
||||||
|
<div class="flex-1 space-y-4 py-1">
|
||||||
|
<div class="h-4 bg-gray-500 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-500 rounded"></div>
|
||||||
|
<div class="h-4 bg-gray-500 rounded w-5/6"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -23,7 +23,7 @@ defmodule LiveBookWeb.Section do
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<div class="flex flex-col space-y-2">
|
<div class="flex flex-col space-y-2 pb-80">
|
||||||
<%= live_component @socket, LiveBookWeb.InsertCellActions,
|
<%= live_component @socket, LiveBookWeb.InsertCellActions,
|
||||||
section_id: @section.id,
|
section_id: @section.id,
|
||||||
index: 0 %>
|
index: 0 %>
|
||||||
|
@ -31,7 +31,8 @@ defmodule LiveBookWeb.Section do
|
||||||
<%= live_component @socket, LiveBookWeb.Cell,
|
<%= live_component @socket, LiveBookWeb.Cell,
|
||||||
cell: cell,
|
cell: cell,
|
||||||
cell_info: @cell_infos[cell.id],
|
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,
|
<%= live_component @socket, LiveBookWeb.InsertCellActions,
|
||||||
section_id: @section.id,
|
section_id: @section.id,
|
||||||
index: index + 1 %>
|
index: index + 1 %>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule LiveBookWeb.SessionLive do
|
defmodule LiveBookWeb.SessionLive do
|
||||||
use LiveBookWeb, :live_view
|
use LiveBookWeb, :live_view
|
||||||
|
|
||||||
alias LiveBook.{SessionSupervisor, Session, Delta}
|
alias LiveBook.{SessionSupervisor, Session, Delta, Notebook}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => session_id}, _session, socket) do
|
def mount(%{"id" => session_id}, _session, socket) do
|
||||||
|
@ -33,14 +33,18 @@ defmodule LiveBookWeb.SessionLive do
|
||||||
session_id: session_id,
|
session_id: session_id,
|
||||||
data: data,
|
data: data,
|
||||||
selected_section_id: first_section_id,
|
selected_section_id: first_section_id,
|
||||||
focused_cell_id: nil
|
focused_cell_id: nil,
|
||||||
|
focused_cell_expanded: false
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~L"""
|
~L"""
|
||||||
<div class="flex flex-grow max-h-full">
|
<div class="flex flex-grow max-h-full"
|
||||||
|
id="session"
|
||||||
|
phx-hook="Session"
|
||||||
|
data-focused-cell-id="<%= @focused_cell_id %>">
|
||||||
<div class="w-1/5 bg-gray-100 border-r-2 border-gray-200">
|
<div class="w-1/5 bg-gray-100 border-r-2 border-gray-200">
|
||||||
<h1 id="notebook-name"
|
<h1 id="notebook-name"
|
||||||
contenteditable
|
contenteditable
|
||||||
|
@ -59,12 +63,12 @@ defmodule LiveBookWeb.SessionLive do
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div phx-click="add_section" class="py-2 px-4 rounded-l-md cursor-pointer text-gray-300 hover:text-gray-400">
|
<button phx-click="add_section" class="py-2 px-4 rounded-l-md cursor-pointer text-gray-300 hover:text-gray-400">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<%= Icons.svg(:plus, class: "h-6") %>
|
<%= Icons.svg(:plus, class: "h-6") %>
|
||||||
<span>New section</span>
|
<span>New section</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-6 py-8 flex overflow-y-auto">
|
<div class="flex-grow px-6 py-8 flex overflow-y-auto">
|
||||||
|
@ -74,7 +78,8 @@ defmodule LiveBookWeb.SessionLive do
|
||||||
section: section,
|
section: section,
|
||||||
selected: section.id == @selected_section_id,
|
selected: section.id == @selected_section_id,
|
||||||
cell_infos: @data.cell_infos,
|
cell_infos: @data.cell_infos,
|
||||||
focused_cell_id: @focused_cell_id %>
|
focused_cell_id: @focused_cell_id,
|
||||||
|
focused_cell_expanded: @focused_cell_expanded %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -83,6 +88,23 @@ defmodule LiveBookWeb.SessionLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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
|
def handle_event("add_section", _params, socket) do
|
||||||
end_index = length(socket.assigns.data.notebook.sections)
|
end_index = length(socket.assigns.data.notebook.sections)
|
||||||
Session.insert_section(socket.assigns.session_id, end_index)
|
Session.insert_section(socket.assigns.session_id, end_index)
|
||||||
|
@ -118,10 +140,6 @@ defmodule LiveBookWeb.SessionLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
def handle_event("set_notebook_name", %{"name" => name}, socket) do
|
||||||
name = normalize_name(name)
|
name = normalize_name(name)
|
||||||
Session.set_notebook_name(socket.assigns.session_id, name)
|
Session.set_notebook_name(socket.assigns.session_id, name)
|
||||||
|
@ -137,7 +155,7 @@ defmodule LiveBookWeb.SessionLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(
|
def handle_event(
|
||||||
"cell_delta",
|
"apply_cell_delta",
|
||||||
%{"cell_id" => cell_id, "delta" => delta, "revision" => revision},
|
%{"cell_id" => cell_id, "delta" => delta, "revision" => revision},
|
||||||
socket
|
socket
|
||||||
) do
|
) do
|
||||||
|
@ -147,13 +165,25 @@ defmodule LiveBookWeb.SessionLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_name(name) do
|
def handle_event("focus_cell", %{"cell_id" => cell_id}, socket) do
|
||||||
name
|
{:noreply, assign(socket, focused_cell_id: cell_id, focused_cell_expanded: false)}
|
||||||
|> String.trim()
|
end
|
||||||
|> String.replace(~r/\s+/, " ")
|
|
||||||
|> case do
|
def handle_event("move_cell_focus", %{"offset" => offset}, socket) do
|
||||||
"" -> "Untitled"
|
case new_focused_cell_from_offset(socket.assigns, offset) do
|
||||||
name -> name
|
{: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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -182,4 +212,32 @@ defmodule LiveBookWeb.SessionLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_action(socket, _action), do: socket
|
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
|
end
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
||||||
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50">
|
<body>
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -99,7 +99,9 @@ defmodule LiveBook.EvaluatorTest do
|
||||||
]
|
]
|
||||||
|
|
||||||
# Note: evaluating module definitions is relatively slow, so we use a higher wait timeout.
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
48
test/live_book/notebook_test.exs
Normal file
48
test/live_book/notebook_test.exs
Normal file
|
@ -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
|
108
test/live_book_web/live/session_live_test.exs
Normal file
108
test/live_book_web/live/session_live_test.exs
Normal file
|
@ -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
|
Loading…
Add table
Reference in a new issue