- Clean editor experience without document list sidebars - Theme-aware using --sentient-* CSS variables - Modern toolbar design like Microsoft 365 - Sheet: Formula bar, spreadsheet grid, sheet tabs, zoom controls - Docs: A4 page centered with shadow, formatting toolbar - Slides: Thumbnails on left, canvas in center, properties panel - AI chat panel (collapsible) for all editors - Responsive design with mobile support - Print styles for all editors - Dark/light mode support via theme system
1043 lines
31 KiB
JavaScript
1043 lines
31 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const CONFIG = {
|
|
AUTOSAVE_DELAY: 3000,
|
|
MAX_HISTORY: 50,
|
|
WS_RECONNECT_DELAY: 5000,
|
|
};
|
|
|
|
const state = {
|
|
docId: null,
|
|
docTitle: "Untitled Document",
|
|
content: "",
|
|
history: [],
|
|
historyIndex: -1,
|
|
isDirty: false,
|
|
autoSaveTimer: null,
|
|
ws: null,
|
|
collaborators: [],
|
|
chatPanelOpen: true,
|
|
driveSource: null,
|
|
zoom: 100,
|
|
};
|
|
|
|
const elements = {};
|
|
|
|
function init() {
|
|
cacheElements();
|
|
bindEvents();
|
|
loadFromUrlParams();
|
|
setupToolbar();
|
|
setupKeyboardShortcuts();
|
|
updateWordCount();
|
|
connectWebSocket();
|
|
}
|
|
|
|
function cacheElements() {
|
|
elements.app = document.getElementById("docs-app");
|
|
elements.docName = document.getElementById("docName");
|
|
elements.editorContent = document.getElementById("editorContent");
|
|
elements.editorPage = document.getElementById("editorPage");
|
|
elements.collaborators = document.getElementById("collaborators");
|
|
elements.pageInfo = document.getElementById("pageInfo");
|
|
elements.wordCount = document.getElementById("wordCount");
|
|
elements.charCount = document.getElementById("charCount");
|
|
elements.saveStatus = document.getElementById("saveStatus");
|
|
elements.zoomLevel = document.getElementById("zoomLevel");
|
|
elements.chatPanel = document.getElementById("chatPanel");
|
|
elements.chatMessages = document.getElementById("chatMessages");
|
|
elements.chatInput = document.getElementById("chatInput");
|
|
elements.chatForm = document.getElementById("chatForm");
|
|
elements.shareModal = document.getElementById("shareModal");
|
|
elements.linkModal = document.getElementById("linkModal");
|
|
elements.imageModal = document.getElementById("imageModal");
|
|
elements.tableModal = document.getElementById("tableModal");
|
|
elements.exportModal = document.getElementById("exportModal");
|
|
}
|
|
|
|
function bindEvents() {
|
|
if (elements.editorContent) {
|
|
elements.editorContent.addEventListener("input", handleEditorInput);
|
|
elements.editorContent.addEventListener("keydown", handleEditorKeydown);
|
|
elements.editorContent.addEventListener("paste", handlePaste);
|
|
}
|
|
|
|
if (elements.docName) {
|
|
elements.docName.addEventListener("change", handleDocNameChange);
|
|
elements.docName.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
elements.editorContent?.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById("undoBtn")?.addEventListener("click", undo);
|
|
document.getElementById("redoBtn")?.addEventListener("click", redo);
|
|
document
|
|
.getElementById("boldBtn")
|
|
?.addEventListener("click", () => execCommand("bold"));
|
|
document
|
|
.getElementById("italicBtn")
|
|
?.addEventListener("click", () => execCommand("italic"));
|
|
document
|
|
.getElementById("underlineBtn")
|
|
?.addEventListener("click", () => execCommand("underline"));
|
|
document
|
|
.getElementById("strikeBtn")
|
|
?.addEventListener("click", () => execCommand("strikeThrough"));
|
|
|
|
document
|
|
.getElementById("alignLeftBtn")
|
|
?.addEventListener("click", () => execCommand("justifyLeft"));
|
|
document
|
|
.getElementById("alignCenterBtn")
|
|
?.addEventListener("click", () => execCommand("justifyCenter"));
|
|
document
|
|
.getElementById("alignRightBtn")
|
|
?.addEventListener("click", () => execCommand("justifyRight"));
|
|
document
|
|
.getElementById("alignJustifyBtn")
|
|
?.addEventListener("click", () => execCommand("justifyFull"));
|
|
|
|
document
|
|
.getElementById("bulletListBtn")
|
|
?.addEventListener("click", () => execCommand("insertUnorderedList"));
|
|
document
|
|
.getElementById("numberListBtn")
|
|
?.addEventListener("click", () => execCommand("insertOrderedList"));
|
|
document
|
|
.getElementById("indentBtn")
|
|
?.addEventListener("click", () => execCommand("indent"));
|
|
document
|
|
.getElementById("outdentBtn")
|
|
?.addEventListener("click", () => execCommand("outdent"));
|
|
|
|
document
|
|
.getElementById("linkBtn")
|
|
?.addEventListener("click", () => showModal("linkModal"));
|
|
document
|
|
.getElementById("imageBtn")
|
|
?.addEventListener("click", () => showModal("imageModal"));
|
|
document
|
|
.getElementById("tableBtn")
|
|
?.addEventListener("click", () => showModal("tableModal"));
|
|
|
|
document
|
|
.getElementById("shareBtn")
|
|
?.addEventListener("click", () => showModal("shareModal"));
|
|
|
|
document
|
|
.getElementById("headingSelect")
|
|
?.addEventListener("change", handleHeadingChange);
|
|
document
|
|
.getElementById("fontFamily")
|
|
?.addEventListener("change", handleFontFamilyChange);
|
|
document
|
|
.getElementById("fontSize")
|
|
?.addEventListener("change", handleFontSizeChange);
|
|
|
|
document.getElementById("textColorBtn")?.addEventListener("click", () => {
|
|
document.getElementById("textColorPicker")?.click();
|
|
});
|
|
document
|
|
.getElementById("textColorPicker")
|
|
?.addEventListener("input", handleTextColorChange);
|
|
document.getElementById("highlightBtn")?.addEventListener("click", () => {
|
|
document.getElementById("highlightPicker")?.click();
|
|
});
|
|
document
|
|
.getElementById("highlightPicker")
|
|
?.addEventListener("input", handleHighlightChange);
|
|
|
|
document.getElementById("zoomInBtn")?.addEventListener("click", zoomIn);
|
|
document.getElementById("zoomOutBtn")?.addEventListener("click", zoomOut);
|
|
|
|
document
|
|
.getElementById("chatToggle")
|
|
?.addEventListener("click", toggleChatPanel);
|
|
document
|
|
.getElementById("chatClose")
|
|
?.addEventListener("click", toggleChatPanel);
|
|
elements.chatForm?.addEventListener("submit", handleChatSubmit);
|
|
|
|
document.querySelectorAll(".suggestion-btn").forEach((btn) => {
|
|
btn.addEventListener("click", () =>
|
|
handleSuggestionClick(btn.dataset.action),
|
|
);
|
|
});
|
|
|
|
document.querySelectorAll(".btn-close, .modal").forEach((el) => {
|
|
el.addEventListener("click", (e) => {
|
|
if (e.target === el) closeModals();
|
|
});
|
|
});
|
|
|
|
document
|
|
.getElementById("closeShareModal")
|
|
?.addEventListener("click", () => hideModal("shareModal"));
|
|
document
|
|
.getElementById("closeLinkModal")
|
|
?.addEventListener("click", () => hideModal("linkModal"));
|
|
document
|
|
.getElementById("closeImageModal")
|
|
?.addEventListener("click", () => hideModal("imageModal"));
|
|
document
|
|
.getElementById("closeTableModal")
|
|
?.addEventListener("click", () => hideModal("tableModal"));
|
|
document
|
|
.getElementById("closeExportModal")
|
|
?.addEventListener("click", () => hideModal("exportModal"));
|
|
|
|
document
|
|
.getElementById("insertLinkBtn")
|
|
?.addEventListener("click", insertLink);
|
|
document
|
|
.getElementById("insertImageBtn")
|
|
?.addEventListener("click", insertImage);
|
|
document
|
|
.getElementById("insertTableBtn")
|
|
?.addEventListener("click", insertTable);
|
|
document
|
|
.getElementById("copyLinkBtn")
|
|
?.addEventListener("click", copyShareLink);
|
|
|
|
document.querySelectorAll(".export-option").forEach((btn) => {
|
|
btn.addEventListener("click", () => exportDocument(btn.dataset.format));
|
|
});
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
}
|
|
|
|
function handleEditorInput() {
|
|
saveToHistory();
|
|
state.isDirty = true;
|
|
updateWordCount();
|
|
scheduleAutoSave();
|
|
broadcastChange();
|
|
}
|
|
|
|
function handleDocNameChange() {
|
|
state.docTitle = elements.docName.value || "Untitled Document";
|
|
state.isDirty = true;
|
|
scheduleAutoSave();
|
|
}
|
|
|
|
function handleEditorKeydown(e) {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
switch (e.key.toLowerCase()) {
|
|
case "b":
|
|
e.preventDefault();
|
|
execCommand("bold");
|
|
break;
|
|
case "i":
|
|
e.preventDefault();
|
|
execCommand("italic");
|
|
break;
|
|
case "u":
|
|
e.preventDefault();
|
|
execCommand("underline");
|
|
break;
|
|
case "z":
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
redo();
|
|
} else {
|
|
undo();
|
|
}
|
|
break;
|
|
case "y":
|
|
e.preventDefault();
|
|
redo();
|
|
break;
|
|
case "s":
|
|
e.preventDefault();
|
|
saveDocument();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handlePaste(e) {
|
|
e.preventDefault();
|
|
const text = e.clipboardData.getData("text/plain");
|
|
document.execCommand("insertText", false, text);
|
|
}
|
|
|
|
function handleBeforeUnload(e) {
|
|
if (state.isDirty) {
|
|
e.preventDefault();
|
|
e.returnValue = "";
|
|
}
|
|
}
|
|
|
|
function setupToolbar() {
|
|
updateToolbarState();
|
|
if (elements.editorContent) {
|
|
elements.editorContent.addEventListener("mouseup", updateToolbarState);
|
|
elements.editorContent.addEventListener("keyup", updateToolbarState);
|
|
}
|
|
}
|
|
|
|
function updateToolbarState() {
|
|
document
|
|
.getElementById("boldBtn")
|
|
?.classList.toggle("active", document.queryCommandState("bold"));
|
|
document
|
|
.getElementById("italicBtn")
|
|
?.classList.toggle("active", document.queryCommandState("italic"));
|
|
document
|
|
.getElementById("underlineBtn")
|
|
?.classList.toggle("active", document.queryCommandState("underline"));
|
|
document
|
|
.getElementById("strikeBtn")
|
|
?.classList.toggle("active", document.queryCommandState("strikeThrough"));
|
|
}
|
|
|
|
function setupKeyboardShortcuts() {
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.target.closest(".chat-input, .modal input")) return;
|
|
|
|
if (e.key === "Escape") {
|
|
closeModals();
|
|
}
|
|
});
|
|
}
|
|
|
|
function execCommand(command, value = null) {
|
|
elements.editorContent?.focus();
|
|
document.execCommand(command, false, value);
|
|
saveToHistory();
|
|
state.isDirty = true;
|
|
scheduleAutoSave();
|
|
updateToolbarState();
|
|
}
|
|
|
|
function handleHeadingChange(e) {
|
|
const value = e.target.value;
|
|
execCommand("formatBlock", value);
|
|
}
|
|
|
|
function handleFontFamilyChange(e) {
|
|
execCommand("fontName", e.target.value);
|
|
}
|
|
|
|
function handleFontSizeChange(e) {
|
|
execCommand("fontSize", e.target.value);
|
|
}
|
|
|
|
function handleTextColorChange(e) {
|
|
execCommand("foreColor", e.target.value);
|
|
const indicator = document.querySelector("#textColorBtn .color-indicator");
|
|
if (indicator) indicator.style.background = e.target.value;
|
|
}
|
|
|
|
function handleHighlightChange(e) {
|
|
execCommand("hiliteColor", e.target.value);
|
|
const indicator = document.querySelector("#highlightBtn .color-indicator");
|
|
if (indicator) indicator.style.background = e.target.value;
|
|
}
|
|
|
|
function saveToHistory() {
|
|
if (!elements.editorContent) return;
|
|
const content = elements.editorContent.innerHTML;
|
|
if (state.history[state.historyIndex] === content) return;
|
|
|
|
state.history = state.history.slice(0, state.historyIndex + 1);
|
|
state.history.push(content);
|
|
if (state.history.length > CONFIG.MAX_HISTORY) {
|
|
state.history.shift();
|
|
} else {
|
|
state.historyIndex++;
|
|
}
|
|
}
|
|
|
|
function undo() {
|
|
if (state.historyIndex > 0) {
|
|
state.historyIndex--;
|
|
if (elements.editorContent) {
|
|
elements.editorContent.innerHTML = state.history[state.historyIndex];
|
|
}
|
|
state.isDirty = true;
|
|
updateWordCount();
|
|
}
|
|
}
|
|
|
|
function redo() {
|
|
if (state.historyIndex < state.history.length - 1) {
|
|
state.historyIndex++;
|
|
if (elements.editorContent) {
|
|
elements.editorContent.innerHTML = state.history[state.historyIndex];
|
|
}
|
|
state.isDirty = true;
|
|
updateWordCount();
|
|
}
|
|
}
|
|
|
|
function updateWordCount() {
|
|
if (!elements.editorContent) return;
|
|
const text = elements.editorContent.innerText || "";
|
|
const words = text
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter((w) => w.length > 0);
|
|
const chars = text.length;
|
|
|
|
if (elements.wordCount) {
|
|
elements.wordCount.textContent = `${words.length} word${words.length !== 1 ? "s" : ""}`;
|
|
}
|
|
if (elements.charCount) {
|
|
elements.charCount.textContent = `${chars} character${chars !== 1 ? "s" : ""}`;
|
|
}
|
|
|
|
const pageHeight = 1056;
|
|
const contentHeight = elements.editorContent.scrollHeight || pageHeight;
|
|
const pages = Math.max(1, Math.ceil(contentHeight / pageHeight));
|
|
if (elements.pageInfo) {
|
|
elements.pageInfo.textContent = `Page 1 of ${pages}`;
|
|
}
|
|
}
|
|
|
|
function zoomIn() {
|
|
if (state.zoom < 200) {
|
|
state.zoom += 10;
|
|
applyZoom();
|
|
}
|
|
}
|
|
|
|
function zoomOut() {
|
|
if (state.zoom > 50) {
|
|
state.zoom -= 10;
|
|
applyZoom();
|
|
}
|
|
}
|
|
|
|
function applyZoom() {
|
|
if (elements.editorPage) {
|
|
elements.editorPage.style.transform = `scale(${state.zoom / 100})`;
|
|
elements.editorPage.style.transformOrigin = "top center";
|
|
}
|
|
if (elements.zoomLevel) {
|
|
elements.zoomLevel.textContent = `${state.zoom}%`;
|
|
}
|
|
}
|
|
|
|
function scheduleAutoSave() {
|
|
if (state.autoSaveTimer) {
|
|
clearTimeout(state.autoSaveTimer);
|
|
}
|
|
state.autoSaveTimer = setTimeout(saveDocument, CONFIG.AUTOSAVE_DELAY);
|
|
if (elements.saveStatus) {
|
|
elements.saveStatus.textContent = "Saving...";
|
|
}
|
|
}
|
|
|
|
async function saveDocument() {
|
|
if (!state.isDirty) return;
|
|
|
|
const content = elements.editorContent?.innerHTML || "";
|
|
const title = state.docTitle;
|
|
|
|
try {
|
|
const response = await fetch("/api/docs/save", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
id: state.docId,
|
|
title,
|
|
content,
|
|
driveSource: state.driveSource,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
if (result.id) {
|
|
state.docId = result.id;
|
|
window.history.replaceState({}, "", `#id=${state.docId}`);
|
|
}
|
|
state.isDirty = false;
|
|
if (elements.saveStatus) {
|
|
elements.saveStatus.textContent = "Saved";
|
|
}
|
|
} else {
|
|
if (elements.saveStatus) {
|
|
elements.saveStatus.textContent = "Save failed";
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Save error:", e);
|
|
if (elements.saveStatus) {
|
|
elements.saveStatus.textContent = "Save failed";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadFromUrlParams() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const hash = window.location.hash;
|
|
let docId = urlParams.get("id");
|
|
let bucket = urlParams.get("bucket");
|
|
let path = urlParams.get("path");
|
|
|
|
if (hash) {
|
|
const hashQueryIndex = hash.indexOf("?");
|
|
if (hashQueryIndex > -1) {
|
|
const hashParams = new URLSearchParams(hash.slice(hashQueryIndex + 1));
|
|
docId = docId || hashParams.get("id");
|
|
bucket = bucket || hashParams.get("bucket");
|
|
path = path || hashParams.get("path");
|
|
} else if (hash.startsWith("#id=")) {
|
|
docId = hash.slice(4);
|
|
}
|
|
}
|
|
|
|
if (bucket && path) {
|
|
state.driveSource = { bucket, path };
|
|
await loadFromDrive(bucket, path);
|
|
} else if (docId) {
|
|
try {
|
|
const response = await fetch(`/api/docs/${docId}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
state.docId = docId;
|
|
state.docTitle = data.title || "Untitled Document";
|
|
if (elements.docName) elements.docName.value = state.docTitle;
|
|
if (elements.editorContent)
|
|
elements.editorContent.innerHTML = data.content || "";
|
|
saveToHistory();
|
|
updateWordCount();
|
|
}
|
|
} catch (e) {
|
|
console.error("Load failed:", e);
|
|
}
|
|
} else {
|
|
saveToHistory();
|
|
}
|
|
}
|
|
|
|
async function loadFromDrive(bucket, path) {
|
|
const fileName = path.split("/").pop() || "Document";
|
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
|
|
|
try {
|
|
const response = await fetch("/api/drive/content", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ bucket, path }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const content = data.content || "";
|
|
|
|
state.docTitle = fileName.replace(/\.[^.]+$/, "");
|
|
if (elements.docName) elements.docName.value = state.docTitle;
|
|
|
|
if (ext === "md") {
|
|
if (elements.editorContent) {
|
|
elements.editorContent.innerHTML = markdownToHtml(content);
|
|
}
|
|
} else if (ext === "txt") {
|
|
if (elements.editorContent) {
|
|
elements.editorContent.innerHTML = `<p>${escapeHtml(content).replace(/\n/g, "</p><p>")}</p>`;
|
|
}
|
|
} else {
|
|
if (elements.editorContent) {
|
|
elements.editorContent.innerHTML = content;
|
|
}
|
|
}
|
|
|
|
saveToHistory();
|
|
updateWordCount();
|
|
}
|
|
} catch (e) {
|
|
console.error("Drive load failed:", e);
|
|
}
|
|
}
|
|
|
|
function markdownToHtml(md) {
|
|
return md
|
|
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
|
|
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
|
|
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
|
|
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
.replace(/`(.+?)`/g, "<code>$1</code>")
|
|
.replace(/\n/g, "<br>");
|
|
}
|
|
|
|
function showModal(modalId) {
|
|
const modal = document.getElementById(modalId);
|
|
if (modal) modal.classList.remove("hidden");
|
|
}
|
|
|
|
function hideModal(modalId) {
|
|
const modal = document.getElementById(modalId);
|
|
if (modal) modal.classList.add("hidden");
|
|
}
|
|
|
|
function closeModals() {
|
|
document
|
|
.querySelectorAll(".modal")
|
|
.forEach((m) => m.classList.add("hidden"));
|
|
}
|
|
|
|
function insertLink() {
|
|
const url = document.getElementById("linkUrl")?.value;
|
|
const text = document.getElementById("linkText")?.value || url;
|
|
if (url) {
|
|
elements.editorContent?.focus();
|
|
document.execCommand(
|
|
"insertHTML",
|
|
false,
|
|
`<a href="${escapeHtml(url)}" target="_blank">${escapeHtml(text)}</a>`,
|
|
);
|
|
hideModal("linkModal");
|
|
saveToHistory();
|
|
state.isDirty = true;
|
|
}
|
|
}
|
|
|
|
function insertImage() {
|
|
const url = document.getElementById("imageUrl")?.value;
|
|
const alt = document.getElementById("imageAlt")?.value || "Image";
|
|
if (url) {
|
|
elements.editorContent?.focus();
|
|
document.execCommand(
|
|
"insertHTML",
|
|
false,
|
|
`<img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}" style="max-width:100%">`,
|
|
);
|
|
hideModal("imageModal");
|
|
saveToHistory();
|
|
state.isDirty = true;
|
|
}
|
|
}
|
|
|
|
function insertTable() {
|
|
const rows = parseInt(document.getElementById("tableRows")?.value, 10) || 3;
|
|
const cols = parseInt(document.getElementById("tableCols")?.value, 10) || 3;
|
|
|
|
let html = '<table style="border-collapse:collapse;width:100%">';
|
|
for (let r = 0; r < rows; r++) {
|
|
html += "<tr>";
|
|
for (let c = 0; c < cols; c++) {
|
|
const cell = r === 0 ? "th" : "td";
|
|
html += `<${cell} style="border:1px solid var(--sentient-border,#e0e0e0);padding:8px">${r === 0 ? "Header" : ""}</${cell}>`;
|
|
}
|
|
html += "</tr>";
|
|
}
|
|
html += "</table><p></p>";
|
|
|
|
elements.editorContent?.focus();
|
|
document.execCommand("insertHTML", false, html);
|
|
hideModal("tableModal");
|
|
saveToHistory();
|
|
state.isDirty = true;
|
|
}
|
|
|
|
function copyShareLink() {
|
|
const linkInput = document.getElementById("shareLink");
|
|
if (linkInput) {
|
|
const shareUrl = `${window.location.origin}${window.location.pathname}#id=${state.docId || "new"}`;
|
|
linkInput.value = shareUrl;
|
|
linkInput.select();
|
|
navigator.clipboard.writeText(shareUrl);
|
|
}
|
|
}
|
|
|
|
function exportDocument(format) {
|
|
const title = state.docTitle || "document";
|
|
const content = elements.editorContent?.innerHTML || "";
|
|
|
|
switch (format) {
|
|
case "pdf":
|
|
exportAsPDF(title, content);
|
|
break;
|
|
case "docx":
|
|
exportAsDocx(title, content);
|
|
break;
|
|
case "html":
|
|
exportAsHTML(title, content);
|
|
break;
|
|
case "txt":
|
|
exportAsTxt(title);
|
|
break;
|
|
case "md":
|
|
exportAsMarkdown(title);
|
|
break;
|
|
}
|
|
hideModal("exportModal");
|
|
}
|
|
|
|
function exportAsPDF(title, content) {
|
|
const printWindow = window.open("", "_blank");
|
|
if (printWindow) {
|
|
printWindow.document.write(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>${escapeHtml(title)}</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; padding: 40px; max-width: 800px; margin: 0 auto; }
|
|
h1, h2, h3 { margin-top: 1em; }
|
|
p { line-height: 1.6; }
|
|
table { border-collapse: collapse; width: 100%; }
|
|
th, td { border: 1px solid #ccc; padding: 8px; }
|
|
</style>
|
|
</head>
|
|
<body>${content}</body>
|
|
</html>
|
|
`);
|
|
printWindow.document.close();
|
|
printWindow.print();
|
|
}
|
|
}
|
|
|
|
function exportAsDocx(title, content) {
|
|
addChatMessage(
|
|
"assistant",
|
|
"DOCX export requires server-side processing. Feature coming soon!",
|
|
);
|
|
}
|
|
|
|
function exportAsHTML(title, content) {
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>${escapeHtml(title)}</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; padding: 40px; max-width: 800px; margin: 0 auto; }
|
|
h1, h2, h3 { margin-top: 1em; }
|
|
p { line-height: 1.6; }
|
|
table { border-collapse: collapse; width: 100%; }
|
|
th, td { border: 1px solid #ccc; padding: 8px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${content}
|
|
</body>
|
|
</html>`;
|
|
downloadFile(html, `${title}.html`, "text/html");
|
|
}
|
|
|
|
function exportAsTxt(title) {
|
|
const text = elements.editorContent?.innerText || "";
|
|
downloadFile(text, `${title}.txt`, "text/plain");
|
|
}
|
|
|
|
function exportAsMarkdown(title) {
|
|
const text = elements.editorContent?.innerText || "";
|
|
const md = `# ${title}\n\n${text}`;
|
|
downloadFile(md, `${title}.md`, "text/markdown");
|
|
}
|
|
|
|
function downloadFile(content, filename, mimeType) {
|
|
const blob = new Blob([content], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function connectWebSocket() {
|
|
if (!state.docId) return;
|
|
|
|
try {
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsUrl = `${protocol}//${window.location.host}/api/docs/ws/${state.docId}`;
|
|
state.ws = new WebSocket(wsUrl);
|
|
|
|
state.ws.onopen = () => {
|
|
state.ws.send(
|
|
JSON.stringify({
|
|
type: "join",
|
|
userId: getUserId(),
|
|
userName: getUserName(),
|
|
}),
|
|
);
|
|
};
|
|
|
|
state.ws.onmessage = (e) => {
|
|
try {
|
|
const msg = JSON.parse(e.data);
|
|
handleWebSocketMessage(msg);
|
|
} catch (err) {
|
|
console.error("WS message error:", err);
|
|
}
|
|
};
|
|
|
|
state.ws.onclose = () => {
|
|
setTimeout(connectWebSocket, CONFIG.WS_RECONNECT_DELAY);
|
|
};
|
|
} catch (e) {
|
|
console.error("WebSocket failed:", e);
|
|
}
|
|
}
|
|
|
|
function handleWebSocketMessage(msg) {
|
|
switch (msg.type) {
|
|
case "user_joined":
|
|
addCollaborator(msg.user);
|
|
break;
|
|
case "user_left":
|
|
removeCollaborator(msg.userId);
|
|
break;
|
|
case "content_update":
|
|
if (msg.userId !== getUserId() && elements.editorContent) {
|
|
const selection = window.getSelection();
|
|
const range =
|
|
selection?.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
|
elements.editorContent.innerHTML = msg.content;
|
|
if (range) {
|
|
try {
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
} catch (e) {
|
|
// Ignore selection restoration errors
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function broadcastChange() {
|
|
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
|
state.ws.send(
|
|
JSON.stringify({
|
|
type: "content_update",
|
|
userId: getUserId(),
|
|
content: elements.editorContent?.innerHTML || "",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function addCollaborator(user) {
|
|
if (!state.collaborators.find((u) => u.id === user.id)) {
|
|
state.collaborators.push(user);
|
|
renderCollaborators();
|
|
}
|
|
}
|
|
|
|
function removeCollaborator(userId) {
|
|
state.collaborators = state.collaborators.filter((u) => u.id !== userId);
|
|
renderCollaborators();
|
|
}
|
|
|
|
function renderCollaborators() {
|
|
if (!elements.collaborators) return;
|
|
elements.collaborators.innerHTML = state.collaborators
|
|
.slice(0, 4)
|
|
.map(
|
|
(u) => `
|
|
<div class="collaborator-avatar" style="background:${u.color || "#4285f4"}" title="${escapeHtml(u.name)}">
|
|
${u.name.charAt(0).toUpperCase()}
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function getUserId() {
|
|
let id = localStorage.getItem("gb-user-id");
|
|
if (!id) {
|
|
id = "user-" + Math.random().toString(36).substr(2, 9);
|
|
localStorage.setItem("gb-user-id", id);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function getUserName() {
|
|
return localStorage.getItem("gb-user-name") || "Anonymous";
|
|
}
|
|
|
|
function toggleChatPanel() {
|
|
state.chatPanelOpen = !state.chatPanelOpen;
|
|
elements.chatPanel?.classList.toggle("collapsed", !state.chatPanelOpen);
|
|
}
|
|
|
|
function handleChatSubmit(e) {
|
|
e.preventDefault();
|
|
const message = elements.chatInput?.value.trim();
|
|
if (!message) return;
|
|
|
|
addChatMessage("user", message);
|
|
if (elements.chatInput) elements.chatInput.value = "";
|
|
|
|
processAICommand(message);
|
|
}
|
|
|
|
function handleSuggestionClick(action) {
|
|
const commands = {
|
|
shorter: "Make the selected text shorter",
|
|
grammar: "Fix grammar and spelling in the document",
|
|
formal: "Make the text more formal",
|
|
summarize: "Summarize this document",
|
|
};
|
|
|
|
const message = commands[action] || action;
|
|
addChatMessage("user", message);
|
|
processAICommand(message);
|
|
}
|
|
|
|
function addChatMessage(role, content) {
|
|
if (!elements.chatMessages) return;
|
|
const div = document.createElement("div");
|
|
div.className = `chat-message ${role}`;
|
|
div.innerHTML = `<div class="message-bubble">${escapeHtml(content)}</div>`;
|
|
elements.chatMessages.appendChild(div);
|
|
elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight;
|
|
}
|
|
|
|
async function processAICommand(command) {
|
|
const lower = command.toLowerCase();
|
|
const selectedText = window.getSelection()?.toString() || "";
|
|
let response = "";
|
|
|
|
if (lower.includes("shorter") || lower.includes("concise")) {
|
|
if (selectedText) {
|
|
response = await callAI("shorten", selectedText);
|
|
} else {
|
|
response =
|
|
"Please select some text first, then ask me to make it shorter.";
|
|
}
|
|
} else if (
|
|
lower.includes("grammar") ||
|
|
lower.includes("spelling") ||
|
|
lower.includes("fix")
|
|
) {
|
|
const text = selectedText || elements.editorContent?.innerText || "";
|
|
response = await callAI("grammar", text);
|
|
} else if (lower.includes("formal")) {
|
|
if (selectedText) {
|
|
response = await callAI("formal", selectedText);
|
|
} else {
|
|
response =
|
|
"Please select some text first, then ask me to make it formal.";
|
|
}
|
|
} else if (lower.includes("casual") || lower.includes("informal")) {
|
|
if (selectedText) {
|
|
response = await callAI("casual", selectedText);
|
|
} else {
|
|
response =
|
|
"Please select some text first, then ask me to make it casual.";
|
|
}
|
|
} else if (lower.includes("summarize") || lower.includes("summary")) {
|
|
const text = selectedText || elements.editorContent?.innerText || "";
|
|
response = await callAI("summarize", text);
|
|
} else if (lower.includes("translate")) {
|
|
const langMatch = lower.match(/to (\w+)/);
|
|
const lang = langMatch ? langMatch[1] : "Spanish";
|
|
const text = selectedText || elements.editorContent?.innerText || "";
|
|
response = await callAI("translate", text, lang);
|
|
} else if (lower.includes("expand") || lower.includes("longer")) {
|
|
if (selectedText) {
|
|
response = await callAI("expand", selectedText);
|
|
} else {
|
|
response = "Please select some text first, then ask me to expand it.";
|
|
}
|
|
} else if (lower.includes("heading") || lower.includes("title")) {
|
|
execCommand("formatBlock", "h1");
|
|
response = "Applied heading format to selected text.";
|
|
} else if (lower.includes("bullet") || lower.includes("list")) {
|
|
execCommand("insertUnorderedList");
|
|
response = "Created a bullet list.";
|
|
} else if (lower.includes("number") && lower.includes("list")) {
|
|
execCommand("insertOrderedList");
|
|
response = "Created a numbered list.";
|
|
} else if (lower.includes("bold")) {
|
|
execCommand("bold");
|
|
response = "Applied bold formatting.";
|
|
} else if (lower.includes("italic")) {
|
|
execCommand("italic");
|
|
response = "Applied italic formatting.";
|
|
} else if (lower.includes("underline")) {
|
|
execCommand("underline");
|
|
response = "Applied underline formatting.";
|
|
} else {
|
|
try {
|
|
const res = await fetch("/api/docs/ai", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
command,
|
|
selectedText,
|
|
docId: state.docId,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
response = data.response || "I processed your request.";
|
|
} catch {
|
|
response =
|
|
"I can help you with:\n• Make text shorter or longer\n• Fix grammar and spelling\n• Translate to another language\n• Change tone (formal/casual)\n• Summarize the document\n• Format as heading, list, etc.";
|
|
}
|
|
}
|
|
|
|
addChatMessage("assistant", response);
|
|
}
|
|
|
|
async function callAI(action, text, extra = "") {
|
|
try {
|
|
const res = await fetch("/api/docs/ai", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action, text, extra, docId: state.docId }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
return data.result || data.response || "Done!";
|
|
}
|
|
return "AI processing failed. Please try again.";
|
|
} catch {
|
|
return "Unable to connect to AI service. Please try again later.";
|
|
}
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return "";
|
|
const div = document.createElement("div");
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function createNewDocument() {
|
|
state.docId = null;
|
|
state.docTitle = "Untitled Document";
|
|
state.isDirty = false;
|
|
state.history = [];
|
|
state.historyIndex = -1;
|
|
|
|
if (elements.docName) elements.docName.value = state.docTitle;
|
|
if (elements.editorContent) elements.editorContent.innerHTML = "";
|
|
|
|
window.history.replaceState({}, "", window.location.pathname);
|
|
saveToHistory();
|
|
updateWordCount();
|
|
elements.editorContent?.focus();
|
|
}
|
|
|
|
window.gbDocs = {
|
|
init,
|
|
createNewDocument,
|
|
saveDocument,
|
|
exportDocument,
|
|
showModal,
|
|
hideModal,
|
|
closeModals,
|
|
toggleChatPanel,
|
|
execCommand,
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|