feat: add monaco editor and align markdown toggle in tabs
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI Project Editor MVP</title>
|
<title>AI Project Editor MVP</title>
|
||||||
<link rel="stylesheet" href="./styles.css" />
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/loader.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="layout" id="layout-root">
|
<main class="layout" id="layout-root">
|
||||||
@@ -31,10 +32,11 @@
|
|||||||
<div class="row-controls tabs-row">
|
<div class="row-controls tabs-row">
|
||||||
<div id="file-tabs" class="tabs"></div>
|
<div id="file-tabs" class="tabs"></div>
|
||||||
<button id="new-text-tab" type="button" class="new-tab-btn" title="Новая вкладка .md">+MD</button>
|
<button id="new-text-tab" type="button" class="new-tab-btn" title="Новая вкладка .md">+MD</button>
|
||||||
|
<button id="md-toggle-mode" type="button" class="md-toggle hidden" title="Переключить режим markdown">👁</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-workspace">
|
<div class="editor-workspace">
|
||||||
<button id="md-toggle-mode" type="button" class="md-toggle hidden" title="Переключить режим markdown">👁</button>
|
|
||||||
<textarea id="file-editor" class="file-editor large" spellcheck="false"></textarea>
|
<textarea id="file-editor" class="file-editor large" spellcheck="false"></textarea>
|
||||||
|
<div id="file-editor-monaco" class="file-editor monaco-host large hidden"></div>
|
||||||
<div id="md-preview" class="md-preview large hidden"></div>
|
<div id="md-preview" class="md-preview large hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-footer">
|
<div class="editor-footer">
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ class AppController {
|
|||||||
#renderCurrentFile() {
|
#renderCurrentFile() {
|
||||||
const path = this.projectStore.selectedFilePath;
|
const path = this.projectStore.selectedFilePath;
|
||||||
if (!path) {
|
if (!path) {
|
||||||
|
this.view.setEditorLanguage("");
|
||||||
this.view.renderFile("");
|
this.view.renderFile("");
|
||||||
this.view.renderMarkdown("");
|
this.view.renderMarkdown("");
|
||||||
this.view.setMarkdownToggleVisible(false);
|
this.view.setMarkdownToggleVisible(false);
|
||||||
@@ -277,6 +278,7 @@ class AppController {
|
|||||||
const file = this.projectStore.getSelectedFile();
|
const file = this.projectStore.getSelectedFile();
|
||||||
const content = this.draftByPath.get(path) ?? file?.content ?? "";
|
const content = this.draftByPath.get(path) ?? file?.content ?? "";
|
||||||
const isMarkdown = this.#isMarkdownPath(path);
|
const isMarkdown = this.#isMarkdownPath(path);
|
||||||
|
this.view.setEditorLanguage(path);
|
||||||
|
|
||||||
if (isMarkdown) {
|
if (isMarkdown) {
|
||||||
const mode = this.markdownModeByPath.get(path) || "preview";
|
const mode = this.markdownModeByPath.get(path) || "preview";
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { MonacoEditorAdapter } from "./MonacoEditorAdapter.js";
|
||||||
|
|
||||||
export class AppView {
|
export class AppView {
|
||||||
constructor(reviewStore) {
|
constructor(reviewStore) {
|
||||||
this.reviewStore = reviewStore;
|
this.reviewStore = reviewStore;
|
||||||
|
this.editorInputHandler = null;
|
||||||
|
this.monacoAdapter = null;
|
||||||
this.el = {
|
this.el = {
|
||||||
layout: document.getElementById("layout-root"),
|
layout: document.getElementById("layout-root"),
|
||||||
splitterLeft: document.getElementById("splitter-left"),
|
splitterLeft: document.getElementById("splitter-left"),
|
||||||
@@ -14,6 +18,7 @@ export class AppView {
|
|||||||
newTextTabBtn: document.getElementById("new-text-tab"),
|
newTextTabBtn: document.getElementById("new-text-tab"),
|
||||||
mdToggleBtn: document.getElementById("md-toggle-mode"),
|
mdToggleBtn: document.getElementById("md-toggle-mode"),
|
||||||
fileEditor: document.getElementById("file-editor"),
|
fileEditor: document.getElementById("file-editor"),
|
||||||
|
fileEditorMonaco: document.getElementById("file-editor-monaco"),
|
||||||
mdPreview: document.getElementById("md-preview"),
|
mdPreview: document.getElementById("md-preview"),
|
||||||
editorInfo: document.getElementById("editor-info"),
|
editorInfo: document.getElementById("editor-info"),
|
||||||
saveFileBtn: document.getElementById("save-file"),
|
saveFileBtn: document.getElementById("save-file"),
|
||||||
@@ -27,6 +32,7 @@ export class AppView {
|
|||||||
chatForm: document.getElementById("chat-form"),
|
chatForm: document.getElementById("chat-form"),
|
||||||
chatInput: document.getElementById("chat-input")
|
chatInput: document.getElementById("chat-input")
|
||||||
};
|
};
|
||||||
|
void this.#initMonacoEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
setIndexStatus(text) {
|
setIndexStatus(text) {
|
||||||
@@ -48,10 +54,13 @@ export class AppView {
|
|||||||
|
|
||||||
setEditorEnabled(enabled) {
|
setEditorEnabled(enabled) {
|
||||||
this.el.fileEditor.readOnly = !enabled;
|
this.el.fileEditor.readOnly = !enabled;
|
||||||
|
if (this.monacoAdapter) this.monacoAdapter.setReadOnly(!enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
bindEditorInput(onInput) {
|
bindEditorInput(onInput) {
|
||||||
|
this.editorInputHandler = onInput;
|
||||||
this.el.fileEditor.oninput = () => onInput(this.el.fileEditor.value);
|
this.el.fileEditor.oninput = () => onInput(this.el.fileEditor.value);
|
||||||
|
if (this.monacoAdapter) this.monacoAdapter.onChange(onInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
bindMarkdownToggle(onToggle) {
|
bindMarkdownToggle(onToggle) {
|
||||||
@@ -85,7 +94,7 @@ export class AppView {
|
|||||||
this.el.mdToggleBtn.classList.toggle("hidden", !visible);
|
this.el.mdToggleBtn.classList.toggle("hidden", !visible);
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
this.el.mdPreview.classList.add("hidden");
|
this.el.mdPreview.classList.add("hidden");
|
||||||
this.el.fileEditor.classList.remove("hidden");
|
this.#setEditorVisible(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +104,11 @@ export class AppView {
|
|||||||
this.el.mdToggleBtn.textContent = isPreview ? "✏️" : "👁";
|
this.el.mdToggleBtn.textContent = isPreview ? "✏️" : "👁";
|
||||||
this.el.mdToggleBtn.title = isPreview ? "Перейти в редактирование" : "Перейти в просмотр";
|
this.el.mdToggleBtn.title = isPreview ? "Перейти в редактирование" : "Перейти в просмотр";
|
||||||
this.el.mdPreview.classList.toggle("hidden", !isPreview);
|
this.el.mdPreview.classList.toggle("hidden", !isPreview);
|
||||||
this.el.fileEditor.classList.toggle("hidden", isPreview);
|
this.#setEditorVisible(!isPreview);
|
||||||
|
if (!isPreview && this.monacoAdapter) {
|
||||||
|
this.monacoAdapter.layout();
|
||||||
|
this.monacoAdapter.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMarkdown(html) {
|
renderMarkdown(html) {
|
||||||
@@ -175,6 +188,11 @@ export class AppView {
|
|||||||
|
|
||||||
renderFile(content) {
|
renderFile(content) {
|
||||||
this.el.fileEditor.value = content || "";
|
this.el.fileEditor.value = content || "";
|
||||||
|
if (this.monacoAdapter) this.monacoAdapter.setValue(content || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorLanguage(path) {
|
||||||
|
if (this.monacoAdapter) this.monacoAdapter.setLanguageByPath(path || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
renderChanges(changes, activePath, onPick) {
|
renderChanges(changes, activePath, onPick) {
|
||||||
@@ -224,4 +242,31 @@ export class AppView {
|
|||||||
this.el.diffView.appendChild(row);
|
this.el.diffView.appendChild(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #initMonacoEditor() {
|
||||||
|
if (!this.el.fileEditorMonaco) return;
|
||||||
|
const adapter = new MonacoEditorAdapter(this.el.fileEditorMonaco);
|
||||||
|
const ready = await adapter.init();
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
|
this.monacoAdapter = adapter;
|
||||||
|
const editorVisible = !this.el.fileEditor.classList.contains("hidden");
|
||||||
|
this.el.fileEditorMonaco.classList.toggle("hidden", !editorVisible);
|
||||||
|
this.el.fileEditor.classList.add("hidden");
|
||||||
|
this.monacoAdapter.setReadOnly(this.el.fileEditor.readOnly);
|
||||||
|
this.monacoAdapter.setValue(this.el.fileEditor.value || "");
|
||||||
|
if (this.editorInputHandler) {
|
||||||
|
this.monacoAdapter.onChange(this.editorInputHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#setEditorVisible(visible) {
|
||||||
|
if (this.monacoAdapter) {
|
||||||
|
this.el.fileEditorMonaco.classList.toggle("hidden", !visible);
|
||||||
|
this.el.fileEditor.classList.add("hidden");
|
||||||
|
if (visible) this.monacoAdapter.layout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.el.fileEditor.classList.toggle("hidden", !visible);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/ui/MonacoEditorAdapter.js
Normal file
146
src/ui/MonacoEditorAdapter.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
export class MonacoEditorAdapter {
|
||||||
|
constructor(hostElement) {
|
||||||
|
this.hostElement = hostElement;
|
||||||
|
this.editor = null;
|
||||||
|
this.model = null;
|
||||||
|
this.changeHandler = null;
|
||||||
|
this.suppressChange = false;
|
||||||
|
this.readOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const monaco = await this.#loadMonaco();
|
||||||
|
if (!monaco || !this.hostElement) return false;
|
||||||
|
|
||||||
|
this.#defineTheme(monaco);
|
||||||
|
this.model = monaco.editor.createModel("", "plaintext");
|
||||||
|
this.editor = monaco.editor.create(this.hostElement, {
|
||||||
|
model: this.model,
|
||||||
|
theme: "app-dark-blue",
|
||||||
|
automaticLayout: true,
|
||||||
|
readOnly: this.readOnly,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "IBM Plex Mono, Consolas, monospace",
|
||||||
|
tabSize: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
this.model.onDidChangeContent(() => {
|
||||||
|
if (this.suppressChange || !this.changeHandler) return;
|
||||||
|
this.changeHandler(this.model.getValue());
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(handler) {
|
||||||
|
this.changeHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReadOnly(readOnly) {
|
||||||
|
this.readOnly = Boolean(readOnly);
|
||||||
|
if (!this.editor) return;
|
||||||
|
this.editor.updateOptions({ readOnly: this.readOnly });
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(text) {
|
||||||
|
if (!this.model) return;
|
||||||
|
const next = text || "";
|
||||||
|
if (this.model.getValue() === next) return;
|
||||||
|
this.suppressChange = true;
|
||||||
|
this.model.pushEditOperations(
|
||||||
|
[],
|
||||||
|
[{ range: this.model.getFullModelRange(), text: next }],
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
this.model.pushStackElement();
|
||||||
|
this.suppressChange = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLanguageByPath(path) {
|
||||||
|
if (!this.model || !window.monaco?.editor) return;
|
||||||
|
window.monaco.editor.setModelLanguage(this.model, this.#detectLanguage(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
if (this.editor) this.editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
layout() {
|
||||||
|
if (this.editor) this.editor.layout();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadMonaco() {
|
||||||
|
if (window.monaco?.editor) return window.monaco;
|
||||||
|
if (typeof window.require !== "function") return null;
|
||||||
|
if (window.__monacoPromise) return window.__monacoPromise;
|
||||||
|
|
||||||
|
window.__monacoPromise = new Promise((resolve, reject) => {
|
||||||
|
window.require.config({
|
||||||
|
paths: {
|
||||||
|
vs: "https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.require(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
return window.__monacoPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
#defineTheme(monaco) {
|
||||||
|
monaco.editor.defineTheme("app-dark-blue", {
|
||||||
|
base: "vs-dark",
|
||||||
|
inherit: true,
|
||||||
|
rules: [
|
||||||
|
{ token: "comment", foreground: "7f9bbf" },
|
||||||
|
{ token: "keyword", foreground: "8cc8ff" },
|
||||||
|
{ token: "string", foreground: "a7d98f" }
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
"editor.background": "#0b1830",
|
||||||
|
"editor.foreground": "#dce9ff",
|
||||||
|
"editorLineNumber.foreground": "#5e79a2",
|
||||||
|
"editorLineNumber.activeForeground": "#a7c3eb",
|
||||||
|
"editor.selectionBackground": "#274f8677",
|
||||||
|
"editor.inactiveSelectionBackground": "#274f8644",
|
||||||
|
"editorCursor.foreground": "#4fa0ff"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#detectLanguage(path) {
|
||||||
|
const ext = (path || "").split(".").pop()?.toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case "js":
|
||||||
|
case "mjs":
|
||||||
|
case "cjs":
|
||||||
|
return "javascript";
|
||||||
|
case "ts":
|
||||||
|
return "typescript";
|
||||||
|
case "json":
|
||||||
|
return "json";
|
||||||
|
case "md":
|
||||||
|
case "markdown":
|
||||||
|
return "markdown";
|
||||||
|
case "yml":
|
||||||
|
case "yaml":
|
||||||
|
return "yaml";
|
||||||
|
case "xml":
|
||||||
|
return "xml";
|
||||||
|
case "html":
|
||||||
|
return "html";
|
||||||
|
case "css":
|
||||||
|
return "css";
|
||||||
|
case "sh":
|
||||||
|
case "bash":
|
||||||
|
return "shell";
|
||||||
|
case "py":
|
||||||
|
return "python";
|
||||||
|
case "toml":
|
||||||
|
return "ini";
|
||||||
|
default:
|
||||||
|
return "plaintext";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
styles.css
19
styles.css
@@ -316,17 +316,17 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-toggle {
|
.md-toggle {
|
||||||
position: absolute;
|
position: static;
|
||||||
top: 8px;
|
z-index: 0;
|
||||||
right: 8px;
|
width: var(--top-control-btn-h);
|
||||||
z-index: 2;
|
min-width: var(--top-control-btn-h);
|
||||||
width: 30px;
|
height: var(--top-control-btn-h);
|
||||||
height: 30px;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
background: #163057;
|
background: #163057;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-toggle.active {
|
.md-toggle.active {
|
||||||
@@ -355,6 +355,11 @@ textarea {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-editor.monaco-host {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.md-preview {
|
.md-preview {
|
||||||
background: #0b1830;
|
background: #0b1830;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
Reference in New Issue
Block a user