From a343bc75620f1b04579f03fac109701507049316 Mon Sep 17 00:00:00 2001 From: purian23 Date: Wed, 21 Jan 2026 09:16:58 -0500 Subject: [PATCH] feat: DMS Core Chroma Syntax Highlighter - Thanks alecthomas for the project --- core/cmd/dms/commands_chroma.go | 274 +++++++++ core/cmd/dms/commands_common.go | 1 + core/go.mod | 4 + core/go.sum | 8 + quickshell/Modules/Notepad/Notepad.qml | 5 + .../Modules/Notepad/NotepadTextEditor.qml | 579 +++++++++++++----- 6 files changed, 706 insertions(+), 165 deletions(-) create mode 100644 core/cmd/dms/commands_chroma.go diff --git a/core/cmd/dms/commands_chroma.go b/core/cmd/dms/commands_chroma.go new file mode 100644 index 00000000..dd24a543 --- /dev/null +++ b/core/cmd/dms/commands_chroma.go @@ -0,0 +1,274 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/spf13/cobra" + "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + ghtml "github.com/yuin/goldmark/renderer/html" +) + +var ( + chromaLanguage string + chromaStyle string + chromaInline bool + chromaMarkdown bool + + // Caching layer for performance + lexerCache = make(map[string]chroma.Lexer) + styleCache = make(map[string]*chroma.Style) + formatterCache = make(map[bool]*html.Formatter) + cacheMutex sync.RWMutex + maxFileSize = int64(5 * 1024 * 1024) // 5MB default +) + +var chromaCmd = &cobra.Command{ + Use: "chroma [file]", + Short: "Syntax highlight source code", + Long: `Generate syntax-highlighted HTML from source code. + +Reads from file or stdin, outputs HTML with syntax highlighting. +Language is auto-detected from filename or can be specified with --language. + +Examples: + dms chroma main.go + dms chroma --language python script.py + echo "def foo(): pass" | dms chroma -l python + cat code.rs | dms chroma -l rust --style dracula + dms chroma --markdown README.md + dms chroma --markdown --style github-dark notes.md + dms chroma list-languages + dms chroma list-styles`, + Args: cobra.MaximumNArgs(1), + Run: runChroma, +} + +var chromaListLanguagesCmd = &cobra.Command{ + Use: "list-languages", + Short: "List all supported languages", + Run: func(cmd *cobra.Command, args []string) { + for _, name := range lexers.Names(true) { + fmt.Println(name) + } + }, +} + +var chromaListStylesCmd = &cobra.Command{ + Use: "list-styles", + Short: "List all available color styles", + Run: func(cmd *cobra.Command, args []string) { + for _, name := range styles.Names() { + fmt.Println(name) + } + }, +} + +func init() { + chromaCmd.Flags().StringVarP(&chromaLanguage, "language", "l", "", "Language for highlighting (auto-detect if not specified)") + chromaCmd.Flags().StringVarP(&chromaStyle, "style", "s", "monokai", "Color style (monokai, dracula, github, etc.)") + chromaCmd.Flags().BoolVar(&chromaInline, "inline", false, "Output inline styles instead of CSS classes") + chromaCmd.Flags().BoolVarP(&chromaMarkdown, "markdown", "m", false, "Render markdown with syntax-highlighted code blocks") + chromaCmd.Flags().Int64Var(&maxFileSize, "max-size", 5*1024*1024, "Maximum file size to process without warning (bytes)") + + chromaCmd.AddCommand(chromaListLanguagesCmd) + chromaCmd.AddCommand(chromaListStylesCmd) +} + +func getCachedLexer(key string, fallbackFunc func() chroma.Lexer) chroma.Lexer { + cacheMutex.RLock() + if lexer, ok := lexerCache[key]; ok { + cacheMutex.RUnlock() + return lexer + } + cacheMutex.RUnlock() + + lexer := fallbackFunc() + if lexer != nil { + cacheMutex.Lock() + lexerCache[key] = lexer + cacheMutex.Unlock() + } + return lexer +} + +func getCachedStyle(name string) *chroma.Style { + cacheMutex.RLock() + if style, ok := styleCache[name]; ok { + cacheMutex.RUnlock() + return style + } + cacheMutex.RUnlock() + + style := styles.Get(name) + if style == nil { + fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name) + style = styles.Fallback + } + + cacheMutex.Lock() + styleCache[name] = style + cacheMutex.Unlock() + return style +} + +func getCachedFormatter(inline bool) *html.Formatter { + cacheMutex.RLock() + if formatter, ok := formatterCache[inline]; ok { + cacheMutex.RUnlock() + return formatter + } + cacheMutex.RUnlock() + + var formatter *html.Formatter + if inline { + formatter = html.New(html.WithClasses(false), html.TabWidth(4)) + } else { + formatter = html.New(html.WithClasses(true), html.TabWidth(4)) + } + + cacheMutex.Lock() + formatterCache[inline] = formatter + cacheMutex.Unlock() + return formatter +} + +func runChroma(cmd *cobra.Command, args []string) { + var source string + var filename string + + // Read from file or stdin + if len(args) > 0 { + filename = args[0] + + // Check file size before reading + fileInfo, err := os.Stat(filename) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file info: %v\n", err) + os.Exit(1) + } + + if fileInfo.Size() > maxFileSize { + fmt.Fprintf(os.Stderr, "Warning: File size (%d bytes) exceeds recommended limit (%d bytes)\n", + fileInfo.Size(), maxFileSize) + fmt.Fprintf(os.Stderr, "Processing may be slow. Consider using smaller files.\n") + } + + content, err := os.ReadFile(filename) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) + os.Exit(1) + } + source = string(content) + } else { + content, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err) + os.Exit(1) + } + source = string(content) + } + + // Handle empty input + if strings.TrimSpace(source) == "" { + return + } + + // Handle Markdown rendering + if chromaMarkdown { + md := goldmark.New( + goldmark.WithExtensions( + extension.GFM, + highlighting.NewHighlighting( + highlighting.WithStyle(chromaStyle), + highlighting.WithFormatOptions( + html.WithClasses(!chromaInline), + ), + ), + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + ghtml.WithHardWraps(), + ghtml.WithXHTML(), + ), + ) + + var buf bytes.Buffer + if err := md.Convert([]byte(source), &buf); err != nil { + fmt.Fprintf(os.Stderr, "Markdown rendering error: %v\n", err) + os.Exit(1) + } + fmt.Print(buf.String()) + return + } + + // Detect or use specified lexer + var lexer chroma.Lexer + if chromaLanguage != "" { + lexer = getCachedLexer(chromaLanguage, func() chroma.Lexer { + l := lexers.Get(chromaLanguage) + if l == nil { + fmt.Fprintf(os.Stderr, "Unknown language: %s\n", chromaLanguage) + os.Exit(1) + } + return l + }) + } else if filename != "" { + lexer = getCachedLexer("file:"+filename, func() chroma.Lexer { + return lexers.Match(filename) + }) + } + + // Try content analysis if no lexer found (limit to first 1KB for performance) + if lexer == nil { + analyzeContent := source + if len(source) > 1024 { + analyzeContent = source[:1024] + } + lexer = lexers.Analyse(analyzeContent) + if lexer != nil { + fmt.Fprintf(os.Stderr, "Info: Language auto-detected as '%s' from content analysis\n", + lexer.Config().Name) + } + } + + // Fallback to plaintext + if lexer == nil { + fmt.Fprintf(os.Stderr, "Warning: Could not detect language, using plaintext\n") + lexer = lexers.Fallback + } + + lexer = chroma.Coalesce(lexer) + + // Get cached style + style := getCachedStyle(chromaStyle) + + // Get cached formatter + formatter := getCachedFormatter(chromaInline) + + // Tokenize + iterator, err := lexer.Tokenise(nil, source) + if err != nil { + fmt.Fprintf(os.Stderr, "Tokenization error: %v\n", err) + os.Exit(1) + } + + // Format and output + if err := formatter.Format(os.Stdout, style, iterator); err != nil { + fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err) + os.Exit(1) + } +} diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index e1e8ce9d..2c2b3c5a 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -515,6 +515,7 @@ func getCommonCommands() []*cobra.Command { genericNotifyActionCmd, matugenCmd, clipboardCmd, + chromaCmd, doctorCmd, configCmd, } diff --git a/core/go.mod b/core/go.mod index f9fd8477..d3aac359 100644 --- a/core/go.mod +++ b/core/go.mod @@ -4,6 +4,7 @@ go 1.24.6 require ( github.com/Wifx/gonetworkmanager/v2 v2.2.0 + github.com/alecthomas/chroma/v2 v2.17.2 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -28,6 +29,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.2 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc // indirect @@ -38,6 +40,8 @@ require ( github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/yuin/goldmark v1.7.16 // indirect + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/net v0.49.0 // indirect ) diff --git a/core/go.sum b/core/go.sum index 3a3a5610..32fd4986 100644 --- a/core/go.sum +++ b/core/go.sum @@ -179,3 +179,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/alecthomas/chroma/v2 v2.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI= +github.com/alecthomas/chroma/v2 v2.17.2/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= diff --git a/quickshell/Modules/Notepad/Notepad.qml b/quickshell/Modules/Notepad/Notepad.qml index 5894a301..e1ba439c 100644 --- a/quickshell/Modules/Notepad/Notepad.qml +++ b/quickshell/Modules/Notepad/Notepad.qml @@ -23,6 +23,7 @@ Item { property string pendingSaveContent: "" signal hideRequested + signal previewRequested(string content) Ref { service: NotepadStorageService @@ -198,6 +199,10 @@ Item { createNewTab(); } + onPreviewRequested: { + textEditor.togglePreview() + } + onEscapePressed: { root.hideRequested(); } diff --git a/quickshell/Modules/Notepad/NotepadTextEditor.qml b/quickshell/Modules/Notepad/NotepadTextEditor.qml index fed8e2e7..079701cc 100644 --- a/quickshell/Modules/Notepad/NotepadTextEditor.qml +++ b/quickshell/Modules/Notepad/NotepadTextEditor.qml @@ -11,6 +11,10 @@ pragma ComponentBehavior: Bound Column { id: root + Component.onCompleted: { + pluginHighlightedHtml = SettingsData.getBuiltInPluginSetting("dankNotepadModule", "highlightedHtml", "") + } + property alias text: textArea.text property alias textArea: textArea property bool contentLoaded: false @@ -21,10 +25,15 @@ Column { property var searchMatches: [] property int currentMatchIndex: -1 property int matchCount: 0 + property bool inlinePreviewVisible: false + property string previewMode: "split" // split | full + property string pluginHighlightedHtml: "" + property string lastPluginContent: "" signal saveRequested() signal openRequested() signal newRequested() + signal previewRequested() signal escapePressed() signal contentChanged() signal settingsRequested() @@ -50,6 +59,7 @@ Column { lastSavedContent = content textArea.text = content contentLoaded = true + syncContentToPlugin() } ) } @@ -164,6 +174,44 @@ Column { }) } + function togglePreview() { + if (!inlinePreviewVisible) { + inlinePreviewVisible = true + previewMode = "split" + } else if (previewMode === "split") { + previewMode = "full" + } else { + inlinePreviewVisible = false + previewMode = "split" + } + syncContentToPlugin() + } + + function renderPreviewHtml() { + if (!inlinePreviewVisible) return "" + return pluginHighlightedHtml.length > 0 ? pluginHighlightedHtml : "

Rendering preview…

" + } + + function syncContentToPlugin() { + if (!currentTab) + return + + const filePath = currentTab?.filePath || "" + const ext = filePath.split('.').pop().toLowerCase() + const content = textArea.text + + if (content === lastPluginContent && SettingsData.getBuiltInPluginSetting("dankNotepadModule", "previewActive", false) === inlinePreviewVisible) { + return + } + + lastPluginContent = content + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "previewActive", inlinePreviewVisible) + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "currentFilePath", filePath) + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "currentFileExtension", ext) + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "sourceContent", content) + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "updatedAt", Date.now()) + } + function hideSearch() { searchVisible = false searchQuery = "" @@ -174,6 +222,57 @@ Column { textArea.forceActiveFocus() } + function copyPlainTextToClipboard() { + if (!inlinePreviewVisible || !textArea.text) return + + const content = textArea.text + if (content.length > 0) { + const proc = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + property string content: "" + command: ["sh", "-c", "printf '%s' \\"$CONTENT\\" | dms clipboard copy"] + environment: { "CONTENT": content } + running: false + }`, + root, + "copyProc" + ) + proc.content = content + proc.running = true + proc.exited.connect(() => { + ToastService.showInfo(I18n.tr("Copied to clipboard")) + proc.destroy() + }) + } + } + + function copyHtmlToClipboard() { + if (!inlinePreviewVisible || !pluginHighlightedHtml) return + + if (pluginHighlightedHtml.length > 0) { + const proc = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + property string content: "" + command: ["sh", "-c", "printf '%s' \\"$CONTENT\\" | dms clipboard copy"] + environment: { "CONTENT": content } + running: false + }`, + root, + "copyProcHtml" + ) + proc.content = pluginHighlightedHtml + proc.running = true + proc.exited.connect(() => { + ToastService.showInfo(I18n.tr("HTML copied to clipboard")) + proc.destroy() + }) + } + } + spacing: Theme.spacingM StyledRect { @@ -303,7 +402,6 @@ Column { onClicked: root.findNext() } - // Close button DankActionButton { id: closeSearchButton Layout.alignment: Qt.AlignVCenter @@ -323,192 +421,311 @@ Column { border.width: 1 radius: Theme.cornerRadius - DankFlickable { - id: flickable + RowLayout { + id: editorPreviewRow anchors.fill: parent anchors.margins: 1 - clip: true - contentWidth: width - 11 + spacing: Theme.spacingM + + Item { + id: editorPane + visible: !inlinePreviewVisible || previewMode === "split" + Layout.fillHeight: true + Layout.fillWidth: !inlinePreviewVisible || previewMode === "split" + Layout.preferredWidth: inlinePreviewVisible ? parent.width * 0.55 : parent.width + clip: true + + DankFlickable { + id: flickable + anchors.fill: parent + clip: true + contentWidth: width - 11 + + Rectangle { + id: lineNumberArea + anchors.left: parent.left + anchors.top: parent.top + width: SettingsData.notepadShowLineNumbers ? Math.max(30, 32 + Theme.spacingXS) : 0 + height: textArea.contentHeight + textArea.topPadding + textArea.bottomPadding + color: "transparent" + visible: SettingsData.notepadShowLineNumbers + + ListView { + id: lineNumberList + anchors.top: parent.top + anchors.topMargin: textArea.topPadding + anchors.right: parent.right + anchors.rightMargin: 2 + width: 32 + height: textArea.contentHeight + model: SettingsData.notepadShowLineNumbers ? root.lineModel : [] + interactive: false + spacing: 0 + + delegate: Item { + id: lineDelegate + required property int index + required property string modelData + width: 32 + height: measuringText.contentHeight + + Text { + id: measuringText + width: textArea.width - textArea.leftPadding - textArea.rightPadding + text: modelData || " " + font: textArea.font + wrapMode: Text.Wrap + visible: false + } + + StyledText { + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: parent.top + text: index + 1 + font.family: textArea.font.family + font.pixelSize: textArea.font.pixelSize + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4) + horizontalAlignment: Text.AlignRight + } + } + } + } + + TextArea.flickable: TextArea { + id: textArea + placeholderText: "" + placeholderTextColor: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) + font.family: SettingsData.notepadUseMonospace ? SettingsData.monoFontFamily : (SettingsData.notepadFontFamily || SettingsData.fontFamily) + font.pixelSize: SettingsData.notepadFontSize * SettingsData.fontScale + font.letterSpacing: 0 + color: Theme.surfaceText + selectedTextColor: Theme.background + selectionColor: Theme.primary + selectByMouse: true + selectByKeyboard: true + wrapMode: TextArea.Wrap + focus: true + activeFocusOnTab: true + textFormat: TextEdit.PlainText + inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + persistentSelection: true + tabStopDistance: 40 + leftPadding: (SettingsData.notepadShowLineNumbers ? lineNumberArea.width + Theme.spacingXS : Theme.spacingM) + topPadding: Theme.spacingM + rightPadding: Theme.spacingM + bottomPadding: Theme.spacingM + cursorDelegate: Rectangle { + width: 1.5 + radius: 1 + color: Theme.surfaceText + x: textArea.cursorRectangle.x + y: textArea.cursorRectangle.y + height: textArea.cursorRectangle.height + opacity: 1.0 + + SequentialAnimation on opacity { + running: textArea.activeFocus + loops: Animation.Infinite + PropertyAnimation { from: 1.0; to: 0.0; duration: 650; easing.type: Easing.InOutQuad } + PropertyAnimation { from: 0.0; to: 1.0; duration: 650; easing.type: Easing.InOutQuad } + } + } + + Component.onCompleted: { + loadCurrentTabContent() + setTextDocumentLineHeight() + root.updateLineModel() + Qt.callLater(() => { + textArea.forceActiveFocus() + }) + } + + Connections { + target: NotepadStorageService + function onCurrentTabIndexChanged() { + loadCurrentTabContent() + Qt.callLater(() => { + textArea.forceActiveFocus() + }) + } + function onTabsChanged() { + if (NotepadStorageService.tabs.length > 0 && !contentLoaded) {/* Lines 444-445 omitted */} + } + } + + Connections { + target: SettingsData + function onNotepadShowLineNumbersChanged() { + root.updateLineModel() + } + } + + onTextChanged: { + if (contentLoaded && text !== lastSavedContent) { + autoSaveTimer.restart() + } + root.contentChanged() + root.updateLineModel() + pluginSyncTimer.restart() + } + + Keys.onEscapePressed: (event) => { + root.escapePressed() + event.accepted = true + } + + Keys.onPressed: (event) => { + if (event.modifiers & Qt.ControlModifier) { + switch (event.key) { + case Qt.Key_S: + event.accepted = true + root.saveRequested() + break + case Qt.Key_O: + event.accepted = true + root.openRequested() + break + case Qt.Key_N: + event.accepted = true + root.newRequested() + break + case Qt.Key_A: + event.accepted = true + textArea.selectAll() + break + case Qt.Key_F: + event.accepted = true + root.showSearch() + break + case Qt.Key_P: + if (PluginService.isPluginLoaded("dankNotepadModule")) { + event.accepted = true + root.previewRequested() + } + break + } + } + } + + background: Rectangle { + color: "transparent" + } + } + + StyledText { + id: placeholderOverlay + text: I18n.tr("Start typing your notes here...") + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) + font.family: textArea.font.family + font.pixelSize: textArea.font.pixelSize + visible: textArea.text.length === 0 + anchors.left: textArea.left + anchors.top: textArea.top + anchors.leftMargin: textArea.leftPadding + anchors.topMargin: textArea.topPadding + z: textArea.z + 1 + } + } + } Rectangle { - id: lineNumberArea - anchors.left: parent.left - anchors.top: parent.top - width: SettingsData.notepadShowLineNumbers ? Math.max(30, 32 + Theme.spacingXS) : 0 - height: textArea.contentHeight + textArea.topPadding + textArea.bottomPadding - color: "transparent" - visible: SettingsData.notepadShowLineNumbers + id: previewDivider + visible: inlinePreviewVisible && previewMode === "split" + Layout.fillHeight: true + Layout.preferredWidth: 1 + color: Theme.outlineMedium + } - ListView { - id: lineNumberList + Item { + id: previewPane + visible: inlinePreviewVisible + Layout.fillHeight: true + Layout.fillWidth: previewMode === "full" + Layout.preferredWidth: previewMode === "full" ? parent.width : parent.width * 0.45 + clip: true + + // Preview header with copy buttons + Rectangle { + id: previewHeader anchors.top: parent.top - anchors.topMargin: textArea.topPadding + anchors.left: parent.left anchors.right: parent.right - anchors.rightMargin: 2 - width: 32 - height: textArea.contentHeight - model: SettingsData.notepadShowLineNumbers ? root.lineModel : [] - interactive: false - spacing: 0 + height: 36 + color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, Theme.notepadTransparency) + z: 2 - delegate: Item { - id: lineDelegate - required property int index - required property string modelData - width: 32 - height: measuringText.contentHeight + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingS - Text { - id: measuringText - width: textArea.width - textArea.leftPadding - textArea.rightPadding - text: modelData || " " - font: textArea.font - wrapMode: Text.Wrap - visible: false + // Copy plain text button + DankActionButton { + iconName: "content_copy" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceTextMedium + onClicked: copyPlainTextToClipboard() } StyledText { - anchors.right: parent.right - anchors.rightMargin: 4 - anchors.top: parent.top - text: index + 1 - font.family: textArea.font.family - font.pixelSize: textArea.font.pixelSize - color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4) - horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Copy Text") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium } - } - } - } - TextArea.flickable: TextArea { - id: textArea - placeholderText: "" - placeholderTextColor: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) - font.family: SettingsData.notepadUseMonospace ? SettingsData.monoFontFamily : (SettingsData.notepadFontFamily || SettingsData.fontFamily) - font.pixelSize: SettingsData.notepadFontSize * SettingsData.fontScale - font.letterSpacing: 0 - color: Theme.surfaceText - selectedTextColor: Theme.background - selectionColor: Theme.primary - selectByMouse: true - selectByKeyboard: true - wrapMode: TextArea.Wrap - focus: true - activeFocusOnTab: true - textFormat: TextEdit.PlainText - inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase - persistentSelection: true - tabStopDistance: 40 - leftPadding: (SettingsData.notepadShowLineNumbers ? lineNumberArea.width + Theme.spacingXS : Theme.spacingM) - topPadding: Theme.spacingM - rightPadding: Theme.spacingM - bottomPadding: Theme.spacingM - cursorDelegate: Rectangle { - width: 1.5 - radius: 1 - color: Theme.surfaceText - x: textArea.cursorRectangle.x - y: textArea.cursorRectangle.y - height: textArea.cursorRectangle.height - opacity: 1.0 + Rectangle { + width: 1 + height: 20 + color: Theme.outlineVariant + anchors.verticalCenter: parent.verticalCenter + } - SequentialAnimation on opacity { - running: textArea.activeFocus - loops: Animation.Infinite - PropertyAnimation { from: 1.0; to: 0.0; duration: 650; easing.type: Easing.InOutQuad } - PropertyAnimation { from: 0.0; to: 1.0; duration: 650; easing.type: Easing.InOutQuad } - } - } + // Copy HTML button + DankActionButton { + iconName: "code" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceTextMedium + onClicked: copyHtmlToClipboard() + } - Component.onCompleted: { - loadCurrentTabContent() - setTextDocumentLineHeight() - root.updateLineModel() - Qt.callLater(() => { - textArea.forceActiveFocus() - }) - } - - Connections { - target: NotepadStorageService - function onCurrentTabIndexChanged() { - loadCurrentTabContent() - Qt.callLater(() => { - textArea.forceActiveFocus() - }) - } - function onTabsChanged() { - if (NotepadStorageService.tabs.length > 0 && !contentLoaded) { - loadCurrentTabContent() + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Copy HTML") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium } } } - Connections { - target: SettingsData - function onNotepadShowLineNumbersChanged() { - root.updateLineModel() + DankFlickable { + id: previewFlickable + anchors.top: previewHeader.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Theme.spacingS + clip: true + contentWidth: width - 11 + contentHeight: previewText.paintedHeight + Theme.spacingM * 2 + + Text { + id: previewText + width: parent.width - Theme.spacingM + padding: Theme.spacingM + wrapMode: Text.WordWrap + textFormat: Text.RichText + text: inlinePreviewVisible ? renderPreviewHtml() : "" + color: Theme.surfaceText + font.family: SettingsData.notepadFontFamily || SettingsData.fontFamily + font.pixelSize: Theme.fontSizeMedium + linkColor: Theme.primary + + onLinkActivated: url => Qt.openUrlExternally(url) } } - - onTextChanged: { - if (contentLoaded && text !== lastSavedContent) { - autoSaveTimer.restart() - } - root.contentChanged() - root.updateLineModel() - } - - Keys.onEscapePressed: (event) => { - root.escapePressed() - event.accepted = true - } - - Keys.onPressed: (event) => { - if (event.modifiers & Qt.ControlModifier) { - switch (event.key) { - case Qt.Key_S: - event.accepted = true - root.saveRequested() - break - case Qt.Key_O: - event.accepted = true - root.openRequested() - break - case Qt.Key_N: - event.accepted = true - root.newRequested() - break - case Qt.Key_A: - event.accepted = true - selectAll() - break - case Qt.Key_F: - event.accepted = true - root.showSearch() - break - } - } - } - - background: Rectangle { - color: "transparent" - } - } - - StyledText { - id: placeholderOverlay - text: I18n.tr("Start typing your notes here...") - color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) - font.family: textArea.font.family - font.pixelSize: textArea.font.pixelSize - visible: textArea.text.length === 0 - anchors.left: textArea.left - anchors.top: textArea.top - anchors.leftMargin: textArea.leftPadding - anchors.topMargin: textArea.topPadding - z: textArea.z + 1 } } } @@ -575,6 +792,24 @@ Column { color: Theme.surfaceTextMedium } } + + Row { + spacing: Theme.spacingS + visible: PluginService.isPluginLoaded("dankNotepadModule") + DankActionButton { + iconName: inlinePreviewVisible ? "visibility" : "visibility_off" + iconSize: Theme.iconSize - 2 + iconColor: Theme.surfaceText + enabled: textArea.text.length > 0 + onClicked: root.previewRequested() + } + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Preview") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium + } + } } DankActionButton { @@ -646,4 +881,18 @@ Column { autoSaveToSession() } } + + Timer { + id: pluginSyncTimer + interval: 350 + repeat: false + onTriggered: syncContentToPlugin() + } + + Connections { + target: SettingsData + function onBuiltInPluginSettingsChanged() { + pluginHighlightedHtml = SettingsData.getBuiltInPluginSetting("dankNotepadModule", "highlightedHtml", "") + } + } }