From 2a81f19b15dde3fb7f41f9e14ad06d92ec8f7566 Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Thu, 18 Jul 2024 14:41:33 +0800 Subject: [PATCH] code editor header controls (#117) --- frontend/app/block/block.less | 16 +- frontend/app/block/block.tsx | 18 +- frontend/app/element/button.less | 16 +- frontend/app/element/button.tsx | 3 +- frontend/app/store/services.ts | 3 + frontend/app/view/codeedit/codeedit.less | 9 - frontend/app/view/codeeditor/codeeditor.less | 29 +++ .../codeeditor.tsx} | 44 ++-- frontend/app/view/{view.less => preview.less} | 10 - frontend/app/view/preview.tsx | 202 +++++++++++++----- frontend/app/view/webview.tsx | 6 +- frontend/types/custom.d.ts | 10 +- pkg/service/fileservice/fileservice.go | 16 +- 13 files changed, 280 insertions(+), 102 deletions(-) delete mode 100644 frontend/app/view/codeedit/codeedit.less create mode 100644 frontend/app/view/codeeditor/codeeditor.less rename frontend/app/view/{codeedit/codeedit.tsx => codeeditor/codeeditor.tsx} (74%) rename frontend/app/view/{view.less => preview.less} (89%) diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less index 86d873e65..a99ec2445 100644 --- a/frontend/app/block/block.less +++ b/frontend/app/block/block.less @@ -149,7 +149,6 @@ border-radius: 3px; align-items: center; padding-left: 7px; - background: rgba(255, 255, 255, 0.1); &.hovered { background: rgba(255, 255, 255, 0.2); @@ -175,9 +174,16 @@ overflow: hidden; text-overflow: ellipsis; box-sizing: border-box; + opacity: 0.7; + font-weight: 500; } } + .button { + margin-left: 3px; + } + + // webview specific. for refresh button .block-frame-header-iconbutton { height: 100%; width: 27px; @@ -186,6 +192,14 @@ justify-content: center; } } + + .block-frame-div-url { + background: rgba(255, 255, 255, 0.1); + + input { + opacity: 1; + } + } } .block-frame-end-icons { diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 1b0575b67..68713bc58 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { useLongClick } from "@/app/hook/useLongClick"; -import { CodeEdit } from "@/app/view/codeedit/codeedit"; +import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; +import { Button } from "@/element/button"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { ContextMenuModel } from "@/store/contextmenu"; @@ -222,11 +223,12 @@ const IconButton = React.memo(({ decl, className }: { decl: HeaderIconButton; cl }); const Input = React.memo(({ decl, className }: { decl: HeaderInput; className: string }) => { - const { value, ref, onChange, onKeyDown, onFocus, onBlur } = decl; + const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl; return (
onChange(e)} @@ -327,6 +329,16 @@ const BlockFrame_Default_Component = ({ {elem.text}
); + } else if (elem.elemtype == "textbutton") { + return ( + + ); } else if (elem.elemtype == "div") { return (
; } else if (blockView === "codeedit") { - viewElem = ; + viewElem = ; } else if (blockView === "web") { const webviewModel = makeWebViewModel(blockId); viewElem = ; diff --git a/frontend/app/element/button.less b/frontend/app/element/button.less index 1f375f716..fba32d4e6 100644 --- a/frontend/app/element/button.less +++ b/frontend/app/element/button.less @@ -37,6 +37,11 @@ background: var(--error-color); } + &.primary.warning { + background: #e6ba1e; + color: #000000; + } + &.primary.outlined, &.primary.greyoutlined { background: none; @@ -126,7 +131,16 @@ opacity: 0.5; } - &.link-button { + &.link { cursor: pointer; } } + +.border-radius-4 { + border-radius: 4px; +} + +.vertical-padding-3 { + padding-top: 3px; + padding-bottom: 3px; +} diff --git a/frontend/app/element/button.tsx b/frontend/app/element/button.tsx index 1cec10795..315ae017a 100644 --- a/frontend/app/element/button.tsx +++ b/frontend/app/element/button.tsx @@ -4,9 +4,10 @@ import "./button.less"; interface ButtonProps extends React.ButtonHTMLAttributes { className?: string; + children?: React.ReactNode; } -const Button: React.FC = React.memo(({ className = "primary", children, disabled, ...props }) => { +const Button = React.memo(({ className = "primary", children, disabled, ...props }: ButtonProps) => { const hasIcon = React.Children.toArray(children).some( (child) => React.isValidElement(child) && (child as React.ReactElement).type === "svg" ); diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 6693eb157..19fa33b50 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -61,6 +61,9 @@ class FileServiceType { RemoveWidget(arg1: number): Promise { return WOS.callBackendService("file", "RemoveWidget", Array.from(arguments)) } + SaveFile(arg1: string, arg2: string): Promise { + return WOS.callBackendService("file", "SaveFile", Array.from(arguments)) + } StatFile(arg1: string): Promise { return WOS.callBackendService("file", "StatFile", Array.from(arguments)) } diff --git a/frontend/app/view/codeedit/codeedit.less b/frontend/app/view/codeedit/codeedit.less deleted file mode 100644 index fe8f87ba1..000000000 --- a/frontend/app/view/codeedit/codeedit.less +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.codeedit { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; -} diff --git a/frontend/app/view/codeeditor/codeeditor.less b/frontend/app/view/codeeditor/codeeditor.less new file mode 100644 index 000000000..c5896e058 --- /dev/null +++ b/frontend/app/view/codeeditor/codeeditor.less @@ -0,0 +1,29 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.code-editor-wrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + align-items: center; + justify-content: center; + + .code-editor { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + + .monaco-editor .slider { + background: rgba(255, 255, 255, 0.4); + border-radius: 4px; + transition: background 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.6); + } + } + } +} diff --git a/frontend/app/view/codeedit/codeedit.tsx b/frontend/app/view/codeeditor/codeeditor.tsx similarity index 74% rename from frontend/app/view/codeedit/codeedit.tsx rename to frontend/app/view/codeeditor/codeeditor.tsx index 734d9723e..caf5c493d 100644 --- a/frontend/app/view/codeedit/codeedit.tsx +++ b/frontend/app/view/codeeditor/codeeditor.tsx @@ -1,14 +1,14 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import "./codeedit.less"; - import { globalStore } from "@/store/global"; import loader from "@monaco-editor/loader"; import { Editor, Monaco } from "@monaco-editor/react"; import * as jotai from "jotai"; import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; -import * as React from "react"; +import { useEffect, useRef, useState } from "react"; + +import "./codeeditor.less"; // there is a global monaco variable (TODO get the correct TS type) declare var monaco: Monaco; @@ -53,6 +53,12 @@ function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions { scrollBeyondLastLine: false, fontSize: 12, fontFamily: "Hack", + smoothScrolling: true, + scrollbar: { + useShadows: false, + verticalScrollbarSize: 5, + horizontalScrollbarSize: 5, + }, }; return opts; } @@ -62,23 +68,33 @@ interface CodeEditProps { text: string; language?: string; filename: string; + onChange?: (text: string) => void; } -export function CodeEdit({ readonly = false, text, language, filename }: CodeEditProps) { - const divRef = React.useRef(null); - const monacoRef = React.useRef(null); - const theme = "wave-theme-dark"; - const [divDims, setDivDims] = React.useState(null); +export function CodeEditor({ readonly = false, text, language, filename, onChange }: CodeEditProps) { + const [divDims, setDivDims] = useState(null); const monacoLoaded = jotai.useAtomValue(monacoLoadedAtom); - React.useEffect(() => { + const monacoRef = useRef(null); + const divRef = useRef(null); + const monacoLoadedRef = useRef(null); + + const theme = "wave-theme-dark"; + + useEffect(() => { if (!divRef.current) { return; } const height = divRef.current.clientHeight; const width = divRef.current.clientWidth; setDivDims({ height, width }); - }, [divRef.current]); + }, []); + + useEffect(() => { + if (monacoLoadedRef.current === null) { + monacoLoadedRef.current = monacoLoaded; + } + }, [monacoLoaded]); function handleEditorMount(editor: MonacoTypes.editor.IStandaloneCodeEditor) { monacoRef.current = editor; @@ -86,16 +102,16 @@ export function CodeEdit({ readonly = false, text, language, filename }: CodeEdi //monaco.editor.setModelLanguage(monacoModel, "text/markdown"); } - function handleEditorChange(newText: string, ev: MonacoTypes.editor.IModelContentChangedEvent) { - // TODO + function handleEditorChange(text: string, ev: MonacoTypes.editor.IModelContentChangedEvent) { + onChange(text); } const editorOpts = defaultEditorOptions(); editorOpts.readOnly = readonly; return ( -
-
+
+
{divDims != null && monacoLoaded ? ( ; viewIcon: jotai.Atom; viewName: jotai.Atom; - viewText: jotai.Atom; + viewText: jotai.Atom; preIconButton: jotai.Atom; endIconButtons: jotai.Atom; + ceReadOnly: jotai.PrimitiveAtom; + isCeView: jotai.PrimitiveAtom; fileName: jotai.WritableAtom; statFile: jotai.Atom>; @@ -46,6 +49,7 @@ export class PreviewModel implements ViewModel { fileMimeType: jotai.Atom>; fileMimeTypeLoadable: jotai.Atom>; fileContent: jotai.Atom>; + newFileContent: jotai.PrimitiveAtom; showHiddenFiles: jotai.PrimitiveAtom; refreshVersion: jotai.PrimitiveAtom; @@ -59,6 +63,8 @@ export class PreviewModel implements ViewModel { this.blockId = blockId; this.showHiddenFiles = jotai.atom(true); this.refreshVersion = jotai.atom(0); + this.ceReadOnly = jotai.atom(true); + this.isCeView = jotai.atom(false); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); this.viewIcon = jotai.atom((get) => { let blockData = get(this.blockAtom); @@ -95,8 +101,46 @@ export class PreviewModel implements ViewModel { }); this.viewName = jotai.atom("Preview"); this.viewText = jotai.atom((get) => { - return get(this.fileName); + const viewTextChildren: HeaderElem[] = [ + { + elemtype: "input", + value: get(this.fileName), + isDisabled: true, + }, + ]; + if (get(this.isCeView)) { + if (get(this.ceReadOnly) == false) { + viewTextChildren.push( + { + elemtype: "textbutton", + text: "Save", + className: "primary warning", + onClick: this.handleFileSave.bind(this), + }, + { + elemtype: "textbutton", + text: "Cancel", + className: "secondary", + onClick: () => this.toggleCodeEditorReadOnly(true), + } + ); + } else { + viewTextChildren.push({ + elemtype: "textbutton", + text: "Edit", + className: "secondary", + onClick: () => this.toggleCodeEditorReadOnly(false), + }); + } + return [ + { + elemtype: "div", + children: viewTextChildren, + }, + ] as HeaderElem[]; + } }); + this.preIconButton = jotai.atom((get) => { const mimeType = util.jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); if (mimeType == "directory") { @@ -105,7 +149,7 @@ export class PreviewModel implements ViewModel { return { elemtype: "iconbutton", icon: "chevron-left", - click: this.onBack.bind(this), + click: this.handleBack.bind(this), }; }); this.endIconButtons = jotai.atom((get) => { @@ -166,11 +210,12 @@ export class PreviewModel implements ViewModel { const fullFile = await get(this.fullFile); return util.base64ToString(fullFile?.data64); }); + this.newFileContent = jotai.atom(""); - this.onBack = this.onBack.bind(this); + this.handleBack = this.handleBack.bind(this); } - onBack() { + handleBack() { const fileName = globalStore.get(this.fileName); if (fileName == null) { return; @@ -183,6 +228,21 @@ export class PreviewModel implements ViewModel { globalStore.set(this.fileName, newPath); } + toggleCodeEditorReadOnly(readOnly: boolean) { + globalStore.set(this.ceReadOnly, readOnly); + } + + async handleFileSave() { + const fileName = globalStore.get(this.fileName); + const newFileContent = globalStore.get(this.newFileContent); + try { + services.FileService.SaveFile(fileName, util.stringToBase64(newFileContent)); + this.toggleCodeEditorReadOnly(true); + } catch (error) { + console.error("Error saving file:", error); + } + } + getSettingsMenuItems(): ContextMenuItem[] { const menuItems: ContextMenuItem[] = []; menuItems.push({ @@ -315,13 +375,34 @@ function CodeEditPreview({ contentAtom, filename, readonly, + isCeViewAtom, + newFileContentAtom, }: { contentAtom: jotai.Atom>; filename: string; readonly: boolean; + isCeViewAtom: jotai.PrimitiveAtom; + newFileContentAtom: jotai.PrimitiveAtom; }) { const fileContent = jotai.useAtomValue(contentAtom); - return ; + const setIsCeView = jotai.useSetAtom(isCeViewAtom); + const setNewFileContent = jotai.useSetAtom(newFileContentAtom); + + useEffect(() => { + setIsCeView(true); + return () => { + setIsCeView(false); + }; + }, [setIsCeView]); + + return ( + setNewFileContent(text)} + /> + ); } function CSVViewPreview({ @@ -374,67 +455,78 @@ function iconForFile(mimeType: string, fileName: string): string { function PreviewView({ blockId, model }: { blockId: string; model: PreviewModel }) { const contentRef = useRef(null); - const blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); const fileNameAtom = model.fileName; const statFileAtom = model.statFile; - const fullFileAtom = model.fullFile; const fileMimeTypeAtom = model.fileMimeType; const fileContentAtom = model.fileContent; - let mimeType = jotai.useAtomValue(fileMimeTypeAtom); - if (mimeType == null) { - mimeType = ""; - } - let fileName = jotai.useAtomValue(fileNameAtom); - const fileInfo = jotai.useAtomValue(statFileAtom); + const newFileContentAtom = model.newFileContent; + const ceReadOnlyAtom = model.ceReadOnly; + const isCeViewAtom = model.isCeView; - // handle streaming files here - let specializedView: React.ReactNode; + const mimeType = jotai.useAtomValue(fileMimeTypeAtom) || ""; + const fileName = jotai.useAtomValue(fileNameAtom); + const fileInfo = jotai.useAtomValue(statFileAtom); + const ceReadOnly = jotai.useAtomValue(ceReadOnlyAtom); let blockIcon = iconForFile(mimeType, fileName); - if ( - mimeType == "application/pdf" || - mimeType.startsWith("video/") || - mimeType.startsWith("audio/") || - mimeType.startsWith("image/") - ) { - specializedView = ; - } else if (fileInfo == null) { - specializedView = ( - File Not Found{util.isBlank(fileName) ? null : JSON.stringify(fileName)} - ); - } else if (fileInfo.size > MaxFileSize) { - specializedView = File Too Large to Preview; - } else if (mimeType === "text/markdown") { - specializedView = ; - } else if (mimeType === "text/csv") { - if (fileInfo.size > MaxCSVSize) { - specializedView = CSV File Too Large to Preview (1MB Max); - } else { - specializedView = ( - { + let view: React.ReactNode = null; + blockIcon = iconForFile(mimeType, fileName); + if ( + mimeType === "application/pdf" || + mimeType.startsWith("video/") || + mimeType.startsWith("audio/") || + mimeType.startsWith("image/") + ) { + view = ; + } else if (!fileInfo) { + view = File Not Found{util.isBlank(fileName) ? null : JSON.stringify(fileName)}; + } else if (fileInfo.size > MaxFileSize) { + view = File Too Large to Preview; + } else if (mimeType === "text/markdown") { + view = ; + } else if (mimeType === "text/csv") { + if (fileInfo.size > MaxCSVSize) { + view = CSV File Too Large to Preview (1MB Max); + } else { + view = ( + + ); + } + } else if (isTextFile(mimeType)) { + view = ( + ); + } else if (mimeType === "directory") { + view = ; + } else { + view = ( +
+
Preview ({mimeType})
+
+ ); } - } else if (isTextFile(mimeType)) { - specializedView = ; - } else if (mimeType === "directory") { - specializedView = ; - } else { - specializedView = ( -
-
Preview ({mimeType})
-
- ); - } - setTimeout(() => { + return view; + })(); + + useEffect(() => { const blockIconOverrideAtom = useBlockAtom(blockId, "blockicon:override", () => { return jotai.atom(null); }) as jotai.PrimitiveAtom; globalStore.set(blockIconOverrideAtom, blockIcon); - }, 10); + }, [blockId, blockIcon]); return (
diff --git a/frontend/app/view/webview.tsx b/frontend/app/view/webview.tsx index aa27faf76..529756d11 100644 --- a/frontend/app/view/webview.tsx +++ b/frontend/app/view/webview.tsx @@ -4,9 +4,9 @@ import { getApi } from "@/app/store/global"; import { WOS, globalStore } from "@/store/global"; import * as services from "@/store/services"; +import clsx from "clsx"; import { WebviewTag } from "electron"; import * as jotai from "jotai"; - import React, { memo, useEffect } from "react"; import "./webview.less"; @@ -17,8 +17,6 @@ export class WebViewModel implements ViewModel { viewIcon: jotai.Atom; viewName: jotai.Atom; viewText: jotai.Atom; - preIconButton: jotai.Atom; - endIconButtons: jotai.Atom; url: jotai.PrimitiveAtom; urlInput: jotai.PrimitiveAtom; urlInputFocused: jotai.PrimitiveAtom; @@ -77,7 +75,7 @@ export class WebViewModel implements ViewModel { }, { elemtype: "div", - className: get(this.urlWrapperClassName), + className: clsx("block-frame-div-url", get(this.urlWrapperClassName)), onMouseOver: this.handleUrlWrapperMouseOver.bind(this), onMouseOut: this.handleUrlWrapperMouseOut.bind(this), children: [ diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 8a478b3e9..0625872b6 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -103,7 +103,7 @@ declare global { type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; - type HeaderElem = HeaderIconButton | HeaderText | HeaderInput | HeaderDiv; + type HeaderElem = HeaderIconButton | HeaderText | HeaderInput | HeaderDiv | HeaderTextButton; type HeaderIconButton = { elemtype: "iconbutton"; @@ -114,6 +114,13 @@ declare global { longClick?: (e: React.MouseEvent) => void; }; + type HeaderTextButton = { + elemtype: "textbutton"; + text: string; + className?: string; + onClick?: (e: React.MouseEvent) => void; + }; + type HeaderText = { elemtype: "text"; text: string; @@ -123,6 +130,7 @@ declare global { elemtype: "input"; value: string; className?: string; + isDisabled?: boolean; ref?: React.MutableRefObject; onChange?: (e: React.ChangeEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; diff --git a/pkg/service/fileservice/fileservice.go b/pkg/service/fileservice/fileservice.go index b45a12265..32b15fc8c 100644 --- a/pkg/service/fileservice/fileservice.go +++ b/pkg/service/fileservice/fileservice.go @@ -1,6 +1,3 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - package fileservice import ( @@ -41,6 +38,19 @@ type FullFile struct { Data64 string `json:"data64"` // base64 encoded } +func (fs *FileService) SaveFile(path string, data64 string) error { + cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path)) + data, err := base64.StdEncoding.DecodeString(data64) + if err != nil { + return fmt.Errorf("failed to decode base64 data: %w", err) + } + err = os.WriteFile(cleanedPath, data, 0644) + if err != nil { + return fmt.Errorf("failed to write file %q: %w", path, err) + } + return nil +} + func (fs *FileService) StatFile(path string) (*FileInfo, error) { cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path)) finfo, err := os.Stat(cleanedPath)