├── .gitignore ├── .idea ├── .gitignore ├── gradle.xml ├── kotlinc.xml ├── misc.xml └── vcs.xml ├── .run └── Run IDE with Plugin.run.xml ├── CLAUDE.md ├── LICENSE ├── README.md ├── build.gradle.kts ├── examples └── bookmarkCanvas.xml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── restart-runide.sh ├── settings.gradle.kts ├── src └── main │ ├── kotlin │ └── org │ │ └── mwalker │ │ └── bookmarkcanvas │ │ ├── actions │ │ ├── AddFileToCanvasAction.kt │ │ ├── AddFileToCanvasWebAction.kt │ │ ├── AddToCanvasAction.kt │ │ ├── AddToCanvasWebAction.kt │ │ ├── ClearCanvasAction.kt │ │ ├── ClearCanvasWebAction.kt │ │ ├── ExportCanvasAction.kt │ │ ├── ExportCanvasWebAction.kt │ │ ├── FindOnCanvasAction.kt │ │ ├── GridWebActions.kt │ │ ├── HomeAction.kt │ │ ├── RedoAction.kt │ │ ├── RefreshBookmarksAction.kt │ │ ├── RefreshBookmarksWebAction.kt │ │ ├── ToggleShowGridAction.kt │ │ ├── ToggleSnapToGridAction.kt │ │ ├── UndoAction.kt │ │ ├── UndoRedoWebActions.kt │ │ ├── ZoomInAction.kt │ │ ├── ZoomOutAction.kt │ │ └── ZoomWebActions.kt │ │ ├── model │ │ ├── BookmarkNode.kt │ │ ├── CanvasState.kt │ │ └── NodeConnection.kt │ │ ├── services │ │ ├── BookmarkService.kt │ │ └── CanvasPersistenceService.kt │ │ └── ui │ │ ├── CanvasConnectionManager.kt │ │ ├── CanvasConstants.kt │ │ ├── CanvasContextMenuManager.kt │ │ ├── CanvasEventHandler.kt │ │ ├── CanvasInterface.kt │ │ ├── CanvasNodeManager.kt │ │ ├── CanvasPanel.kt │ │ ├── CanvasSelectionManager.kt │ │ ├── CanvasToolWindow.kt │ │ ├── CanvasToolbar.kt │ │ ├── CanvasZoomManager.kt │ │ ├── EditSourceDialog.kt │ │ ├── KotlinSnippetHighlighter.kt │ │ ├── NodeComponent.kt │ │ ├── NodeContextMenuManager.kt │ │ ├── NodeEventHandler.kt │ │ ├── NodeUIManager.kt │ │ ├── WebCanvasPanel.kt │ │ ├── WebCanvasToolbar.kt │ │ ├── WebViewCanvasToolWindow.kt │ │ ├── colors.kt │ │ └── util.kt │ └── resources │ ├── META-INF │ ├── plugin.xml │ ├── pluginIcon.svg │ └── pluginIcon_old.svg │ ├── icons │ ├── book-bookmark-solid.svg │ └── book.png │ └── web │ ├── canvas.css │ ├── canvas.html │ ├── canvas.js │ └── debug.html ├── targetStyle.html └── web ├── bookmark-canvas ├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public │ └── vite.svg ├── src │ ├── App.css │ ├── App.tsx │ ├── assets │ │ └── react.svg │ ├── components │ │ ├── BookmarkNode.tsx │ │ ├── Canvas.tsx │ │ └── CodeDisplay.tsx │ ├── index.css │ ├── main.tsx │ ├── store │ │ └── canvasStore.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── reference.png /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea/modules.xml 9 | .idea/jarRepositories.xml 10 | .idea/compiler.xml 11 | .idea/libraries/ 12 | *.iws 13 | *.iml 14 | *.ipr 15 | out/ 16 | !**/src/main/**/out/ 17 | !**/src/test/**/out/ 18 | 19 | ### Eclipse ### 20 | .apt_generated 21 | .classpath 22 | .factorypath 23 | .project 24 | .settings 25 | .springBeans 26 | .sts4-cache 27 | bin/ 28 | !**/src/main/**/bin/ 29 | !**/src/test/**/bin/ 30 | 31 | ### NetBeans ### 32 | /nbproject/private/ 33 | /nbbuild/ 34 | /dist/ 35 | /nbdist/ 36 | /.nb-gradle/ 37 | 38 | ### VS Code ### 39 | .vscode/ 40 | 41 | ### Mac OS ### 42 | .DS_Store 43 | src/main/resources/web/react-canvas.html 44 | src/main/resources/web/vite.svg 45 | runide.pid 46 | runIde.log 47 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.run/Run IDE with Plugin.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # BookmarkCanvas Development Guide 2 | 3 | ## Operation Notes 4 | - If there is any uncertainty or ambiguity in the requirements, ask for clarification 5 | 6 | ## Useful info for project 7 | - With AWT/Swing, events don't propagate up the component hierarchy when a listener is added to a child component, even if the child component's listener only overrides a single event type. 8 | 9 | ## Build & Run Commands 10 | - Build plugin: `./gradlew build` 11 | - Run in IDE: `./gradlew runIde` 12 | - Run tests: `./gradlew test` 13 | - Run single test: `./gradlew test --tests "org.mwalker.bookmarkcanvas.TestClassName"` 14 | - Clean: `./gradlew clean` 15 | - Verify plugin: `./gradlew verifyPlugin` 16 | 17 | ## Code Style Guidelines 18 | - **Language**: Kotlin 1.9.25 with JVM target 17 19 | - **Naming**: Use camelCase for methods/variables, PascalCase for classes 20 | - **Formatting**: 4-space indentation, max line length 120 characters, always trailing commas 21 | - **Imports**: Organize imports, no wildcards, alphabetical order 22 | - **Classes**: Prefer data classes for models, single responsibility pattern 23 | - **Nullability**: Use nullable types (Type?) explicitly, avoid !! operator 24 | - **Error handling**: Use try/catch for expected exceptions, return Result for operations 25 | - **UI Components**: Use IntelliJ's UI components (JBColor, JBPanel) for theme consistency 26 | - **Architecture**: Follow MVC pattern with services layer for business logic 27 | 28 | ## Project Structure 29 | - Actions: Canvas-related user actions 30 | - Model: Data classes for bookmarks, canvas state 31 | - Services: Business logic and persistence 32 | - UI: Visual components and rendering -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BookmarkCanvas 2 | 3 | This plugin provides a canvas for organizing bookmarks in IntelliJ IDEs such as IntelliJ IDEA, PyCharm, Android Studio, and WebStorm. 4 | The goal is to make it easy to create a visual representation both of bookmarks and of the relationships between them. 5 | The canvas should make it easy to quickly jump to a bookmarked location 6 | 7 | This was written 99% by Claude 3.7 through [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) 8 | 9 | 10 | https://github.com/user-attachments/assets/ae1bb649-2954-4f61-befa-3b252eb74a3e 11 | 12 | 13 | ## Features 14 | 15 | - Add a bookmark to the canvas from the "Bookmarks" tool window 16 | - Add a bookmark to the canvas from the "Bookmarks" modal dialog 17 | - Add a bookmark to the canvas from the canvas itself 18 | - Navigate to a bookmark from the canvas by double clicking on it 19 | - Remove a bookmark from the canvas 20 | - Move a bookmark around the canvas 21 | - Zoom in and out on the canvas 22 | - Pan around the canvas 23 | - Save the canvas layout to a file 24 | - Load a canvas layout from a file 25 | - Create connections between bookmarks 26 | - Remove a connection between bookmarks 27 | - Configure the amount of context to show around a bookmark (e.g. 5 lines above and below) 28 | - Configure the layout of the canvas (e.g. grid (with snap to grid), freeform) 29 | - Change the title for a node 30 | - show/hide the code for a node (ie. the node can show just the title or the title and the code) 31 | 32 | ## Getting Started 33 | 34 | ### Prerequisites 35 | 36 | - IntelliJ IDEA 2023.2.6+ 37 | - Java 17+ 38 | 39 | ### Installation 40 | 41 | 42 | 43 | 44 | 1. Build the plugin using Gradle: 45 | ``` 46 | ./gradlew build 47 | ``` 48 | 49 | 2. Install the plugin in IntelliJ IDEA: 50 | - Go to Settings/Preferences > Plugins > ⚙️ > Install Plugin from Disk 51 | - Select the generated ZIP file from `build/distributions/` 52 | 53 | ## Usage 54 | 55 | 56 | 57 | ## Development 58 | 59 | See [CLAUDE.md](CLAUDE.md) for development guidelines. 60 | 61 | ### TODO 62 | - syntax highlighting for code in the node 63 | - fix issue with font size for snippet when context changed 64 | - fix undo/redo 65 | - auto sizing 66 | - proper scaling of snippet text with node size 67 | - section with nodes for recent cursor locations 68 | - fix resize handle UI 69 | 70 | ## License 71 | 72 | 73 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java") 3 | id("org.jetbrains.kotlin.jvm") version "1.9.25" 4 | id("org.jetbrains.intellij") version "1.17.4" 5 | } 6 | 7 | group = "org.mwalker.bookmarkcanvas" 8 | version = "1.0.13" 9 | 10 | repositories { 11 | mavenCentral() 12 | } 13 | 14 | // Configure Gradle IntelliJ Plugin - read more: https://github.com/JetBrains/gradle-intellij-plugin 15 | intellij { 16 | // version.set("2022.2.3") 17 | version.set("2023.2.6") 18 | type.set("IC") // Target IDE Platform 19 | 20 | updateSinceUntilBuild.set(false) 21 | plugins.set(listOf(/* Plugin Dependencies */)) 22 | } 23 | tasks { 24 | // Set the JVM compatibility versions 25 | withType { 26 | sourceCompatibility = "17" 27 | targetCompatibility = "17" 28 | } 29 | 30 | withType { 31 | kotlinOptions.jvmTarget = "17" 32 | } 33 | 34 | patchPluginXml { 35 | sinceBuild.set("222") 36 | changeNotes.set(""" 37 | First version of the Bookmark Canvas plugin for visual code exploration. 38 | """.trimIndent()) 39 | } 40 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib 2 | kotlin.stdlib.default.dependency=false 3 | # Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html 4 | org.gradle.configuration-cache=true 5 | # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html 6 | org.gradle.caching=true 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwalkerr/BookmarkCanvas/f2c17a7a4af607a7c441354de514c7fd14b2dd7d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /restart-runide.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to restart the runIde process 4 | # Usage: ./restart-runide.sh 5 | 6 | PID_FILE="runide.pid" 7 | LOG_FILE="runIde.log" 8 | 9 | echo "🔄 Restarting runIde process..." 10 | 11 | # Kill existing process if it exists 12 | if [ -f "$PID_FILE" ]; then 13 | OLD_PID=$(cat "$PID_FILE") 14 | if kill -0 "$OLD_PID" 2>/dev/null; then 15 | echo "🛑 Killing existing process (PID: $OLD_PID)" 16 | kill "$OLD_PID" 17 | # Wait a moment for the process to terminate 18 | sleep 2 19 | # Force kill if still running 20 | if kill -0 "$OLD_PID" 2>/dev/null; then 21 | echo "🔥 Force killing process" 22 | kill -9 "$OLD_PID" 23 | fi 24 | else 25 | echo "🗑️ Old PID file found but process not running" 26 | fi 27 | rm -f "$PID_FILE" 28 | fi 29 | 30 | # Clean up any zombie Gradle processes 31 | echo "🧹 Cleaning up zombie Gradle processes..." 32 | GRADLE_PIDS=$(ps aux | grep -E "(gradle|kotlin)" | grep -v grep | awk '{print $2}') 33 | if [ ! -z "$GRADLE_PIDS" ]; then 34 | echo "🛑 Found zombie processes: $GRADLE_PIDS" 35 | echo "$GRADLE_PIDS" | xargs kill -9 2>/dev/null || true 36 | fi 37 | 38 | # Stop any running Gradle daemons 39 | echo "🛑 Stopping Gradle daemons..." 40 | ./gradlew --stop 2>/dev/null || true 41 | 42 | # Start new process 43 | echo "🚀 Starting new runIde process..." 44 | nohup ./gradlew runIde > "$LOG_FILE" 2>&1 & 45 | NEW_PID=$! 46 | 47 | # Save the new PID 48 | echo "$NEW_PID" > "$PID_FILE" 49 | 50 | echo "✅ Started runIde with PID: $NEW_PID" 51 | echo "📝 Logs: tail -f $LOG_FILE" 52 | echo "🛑 Stop: kill $NEW_PID (or just run this script again)" 53 | 54 | # Show initial log output 55 | echo "" 56 | echo "📋 Initial output:" 57 | sleep 2 58 | tail -10 "$LOG_FILE" -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenCentral() 4 | gradlePluginPortal() 5 | } 6 | } 7 | 8 | rootProject.name = "BookmarkCanvas" -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/AddFileToCanvasAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 4 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 5 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 6 | import com.intellij.openapi.actionSystem.AnAction 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.actionSystem.CommonDataKeys 9 | import com.intellij.openapi.diagnostic.Logger 10 | import com.intellij.openapi.project.Project 11 | import com.intellij.openapi.vfs.VirtualFile 12 | import com.intellij.openapi.wm.ToolWindow 13 | import com.intellij.openapi.wm.ToolWindowManager 14 | import org.jetbrains.annotations.NotNull 15 | import org.mwalker.bookmarkcanvas.ui.CanvasToolbar 16 | import java.util.UUID 17 | 18 | class AddFileToCanvasAction : AnAction() { 19 | companion object { 20 | private val LOG = Logger.getInstance(AddFileToCanvasAction::class.java) 21 | } 22 | 23 | override fun actionPerformed(@NotNull e: AnActionEvent) { 24 | LOG.info("AddFileToCanvasAction.actionPerformed called") 25 | val project = e.project ?: return 26 | val file = e.getData(CommonDataKeys.VIRTUAL_FILE) ?: return 27 | 28 | // Create a node for the file with line 0 29 | val node = createNodeFromFile(project, file) 30 | 31 | // Get or create the canvas state for this project 32 | val canvasState = CanvasPersistenceService.getInstance().getCanvasState(project) 33 | 34 | // Add the node 35 | canvasState.addNode(node) 36 | 37 | // Save state 38 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasState) 39 | 40 | // Make sure the tool window is visible 41 | val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("BookmarkCanvas") 42 | if (toolWindow != null && !toolWindow.isVisible) { 43 | toolWindow.show(null) 44 | } 45 | 46 | // Refresh the UI if the tool window is open 47 | if (toolWindow != null) { 48 | // Find the canvas panel and trigger a refresh 49 | val canvasPanel = findCanvasPanel(toolWindow) 50 | if (canvasPanel != null) { 51 | canvasPanel.addNodeComponent(node) 52 | canvasPanel.revalidate() 53 | canvasPanel.repaint() 54 | } 55 | } 56 | } 57 | 58 | private fun createNodeFromFile(project: Project, file: VirtualFile): BookmarkNode { 59 | // Create relative path from project base 60 | val filePath = file.path.replace(project.basePath + "/", "") 61 | 62 | return BookmarkNode( 63 | bookmarkId = "file_bookmark_" + UUID.randomUUID().toString(), 64 | filePath = filePath, 65 | lineNumber0Based = 0, 66 | lineContent = null 67 | ) 68 | } 69 | 70 | private fun findCanvasPanel(toolWindow: ToolWindow): CanvasPanel? { 71 | val component = toolWindow.contentManager.getContent(0)?.component 72 | return (component as? CanvasToolbar)?.canvasPanel 73 | } 74 | 75 | override fun update(@NotNull e: AnActionEvent) { 76 | // Enable the action when we have a file selected 77 | val project = e.project 78 | val file = e.getData(CommonDataKeys.VIRTUAL_FILE) 79 | e.presentation.isEnabledAndVisible = ( 80 | project != null && file != null && !file.isDirectory 81 | ) 82 | } 83 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/AddFileToCanvasWebAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasInterface 4 | import org.mwalker.bookmarkcanvas.services.BookmarkService 5 | import com.intellij.openapi.actionSystem.AnAction 6 | import com.intellij.openapi.actionSystem.AnActionEvent 7 | import com.intellij.openapi.actionSystem.CommonDataKeys 8 | import com.intellij.openapi.project.Project 9 | import org.jetbrains.annotations.NotNull 10 | 11 | class AddFileToCanvasWebAction( 12 | private val project: Project, 13 | private val canvasPanel: CanvasInterface 14 | ) : AnAction("Add File to Canvas", "Add current file to canvas", null) { 15 | 16 | override fun actionPerformed(@NotNull e: AnActionEvent) { 17 | // Create a file node from the current file 18 | val node = BookmarkService.createFileNodeFromCurrentFile(project) ?: return 19 | 20 | // Add to canvas 21 | canvasPanel.addNodeComponent(node) 22 | } 23 | 24 | override fun update(@NotNull e: AnActionEvent) { 25 | // Only enable the action when we have an open file 26 | e.presentation.isEnabledAndVisible = ( 27 | e.getData(CommonDataKeys.VIRTUAL_FILE) != null 28 | ) 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/AddToCanvasAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 4 | import org.mwalker.bookmarkcanvas.model.CanvasState 5 | import org.mwalker.bookmarkcanvas.services.BookmarkService 6 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 7 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 8 | import com.intellij.openapi.actionSystem.AnAction 9 | import com.intellij.openapi.actionSystem.AnActionEvent 10 | import com.intellij.openapi.actionSystem.CommonDataKeys 11 | import com.intellij.openapi.project.Project 12 | import com.intellij.openapi.wm.ToolWindow 13 | import com.intellij.openapi.wm.ToolWindowManager 14 | import org.jetbrains.annotations.NotNull 15 | import javax.swing.JComponent 16 | import javax.swing.JScrollPane 17 | import com.intellij.openapi.diagnostic.Logger 18 | import org.mwalker.bookmarkcanvas.ui.CanvasToolbar 19 | 20 | 21 | class AddToCanvasAction : AnAction() { 22 | companion object { 23 | private val LOG = Logger.getInstance(AddToCanvasAction::class.java) 24 | } 25 | override fun actionPerformed(@NotNull e: AnActionEvent) { 26 | LOG.info("AddToCanvasAction.actionPerformed called, project: ${e.project}") 27 | val project = e.project ?: return 28 | LOG.info("AddToCanvasAction.actionPerformed called, project: ${e.project}, node: ${BookmarkService.createNodeFromCurrentPosition(e.project!!)}") 29 | 30 | // Create a node from the current editor position 31 | val node = BookmarkService.createNodeFromCurrentPosition(project) ?: return 32 | 33 | // Get or create the canvas state for this project 34 | val canvasState = CanvasPersistenceService.getInstance().getCanvasState(project) 35 | 36 | // Add the node 37 | canvasState.addNode(node) 38 | 39 | // Save state 40 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasState) 41 | 42 | 43 | // Make sure the tool window is visible 44 | val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("BookmarkCanvas") 45 | LOG.info("AddToCanvasAction.actionPerformed called, toolWindow: $toolWindow, isVisible: ${toolWindow?.isVisible}") 46 | if (toolWindow != null && !toolWindow.isVisible) { 47 | toolWindow.show(null) 48 | } 49 | 50 | LOG.info("AddToCanvasAction.actionPerformed called, toolWindow: $toolWindow, isActive: ${toolWindow?.isActive}") 51 | // Refresh the UI if the tool window is open 52 | // if (toolWindow != null && toolWindow.isActive) { 53 | if (toolWindow != null) { 54 | // Find the canvas panel and trigger a refresh 55 | val canvasPanel = findCanvasPanel(toolWindow) 56 | LOG.info("AddToCanvasAction.actionPerformed called, canvasPanel: $canvasPanel") 57 | if (canvasPanel != null) { 58 | canvasPanel.addNodeComponent(node) 59 | canvasPanel.revalidate() 60 | canvasPanel.repaint() 61 | } 62 | } 63 | } 64 | 65 | private fun findCanvasPanel(toolWindow: ToolWindow): CanvasPanel? { 66 | val component = toolWindow.contentManager.getContent(0)?.component 67 | return (component as? CanvasToolbar)?.canvasPanel 68 | } 69 | 70 | override fun update(@NotNull e: AnActionEvent) { 71 | // Only enable the action when we have an open editor 72 | val project = e.project 73 | e.presentation.isEnabledAndVisible = ( 74 | project != null && 75 | e.getData(CommonDataKeys.EDITOR) != null 76 | ) 77 | } 78 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/AddToCanvasWebAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 4 | import org.mwalker.bookmarkcanvas.model.CanvasState 5 | import org.mwalker.bookmarkcanvas.services.BookmarkService 6 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 7 | import org.mwalker.bookmarkcanvas.ui.CanvasInterface 8 | import org.mwalker.bookmarkcanvas.ui.WebCanvasPanel 9 | import com.intellij.openapi.actionSystem.AnAction 10 | import com.intellij.openapi.actionSystem.AnActionEvent 11 | import com.intellij.openapi.actionSystem.CommonDataKeys 12 | import com.intellij.openapi.project.Project 13 | import com.intellij.openapi.wm.ToolWindow 14 | import com.intellij.openapi.wm.ToolWindowManager 15 | import org.jetbrains.annotations.NotNull 16 | import javax.swing.JComponent 17 | import javax.swing.JScrollPane 18 | import com.intellij.openapi.diagnostic.Logger 19 | import org.mwalker.bookmarkcanvas.ui.WebCanvasToolbar 20 | 21 | class AddToCanvasWebAction( 22 | private val project: Project, 23 | private val canvasPanel: CanvasInterface 24 | ) : AnAction("Add to Canvas", "Add current position to canvas", null) { 25 | 26 | companion object { 27 | private val LOG = Logger.getInstance(AddToCanvasWebAction::class.java) 28 | } 29 | 30 | override fun actionPerformed(@NotNull e: AnActionEvent) { 31 | LOG.info("AddToCanvasWebAction.actionPerformed called") 32 | 33 | // Create a node from the current editor position 34 | val node = BookmarkService.createNodeFromCurrentPosition(project) ?: return 35 | 36 | // Add to canvas 37 | canvasPanel.addNodeComponent(node) 38 | } 39 | 40 | override fun update(@NotNull e: AnActionEvent) { 41 | // Only enable the action when we have an open editor 42 | e.presentation.isEnabledAndVisible = ( 43 | e.getData(CommonDataKeys.EDITOR) != null 44 | ) 45 | } 46 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/ClearCanvasAction.kt: -------------------------------------------------------------------------------- 1 | //package org.mwalker.bookmarkcanvas.actions 2 | // 3 | // 4 | // 5 | // 6 | // 7 | //import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 8 | //import org.mwalker.bookmarkcanvas.ui.CanvasPanel 9 | //import com.intellij.icons.AllIcons 10 | //import com.intellij.openapi.actionSystem.AnAction 11 | //import com.intellij.openapi.actionSystem.AnActionEvent 12 | //import com.intellij.openapi.project.Project 13 | //import com.intellij.openapi.ui.Messages 14 | //import org.jetbrains.annotations.NotNull 15 | //class ClearCanvasAction( 16 | // private val project: Project, 17 | // private val canvasPanel: CanvasPanel 18 | //) : AnAction("Clear Canvas", "Remove all nodes from canvas", AllIcons.Actions.GC) { 19 | // override fun actionPerformed(@NotNull e: AnActionEvent) { 20 | // val result = Messages.showYesNoDialog( 21 | // "Are you sure you want to clear all nodes from the canvas?", 22 | // "Clear Canvas", 23 | // AllIcons.General.QuestionDialog 24 | // ) 25 | // if (result == Messages.YES) { 26 | // canvasPanel.clearCanvas() 27 | // } 28 | // } 29 | //} -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/ClearCanvasWebAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasInterface 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.project.Project 7 | import org.jetbrains.annotations.NotNull 8 | 9 | class ClearCanvasWebAction( 10 | private val project: Project, 11 | private val canvasPanel: CanvasInterface 12 | ) : AnAction("Clear Canvas", "Clear all nodes from canvas", null) { 13 | 14 | override fun actionPerformed(@NotNull e: AnActionEvent) { 15 | canvasPanel.clearCanvas() 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/ExportCanvasAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 4 | import com.intellij.execution.configurations.ParameterTargetValuePart 5 | import com.intellij.icons.AllIcons 6 | import com.intellij.openapi.actionSystem.AnAction 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.fileChooser.FileChooserFactory 9 | import com.intellij.openapi.fileChooser.FileSaverDescriptor 10 | import com.intellij.openapi.fileChooser.FileSaverDialog 11 | import com.intellij.openapi.project.Project 12 | import com.intellij.openapi.vfs.VirtualFile 13 | import org.jetbrains.annotations.NotNull 14 | import java.awt.Graphics2D 15 | import java.awt.image.BufferedImage 16 | import javax.imageio.ImageIO 17 | 18 | class ExportCanvasAction( 19 | private val project: Project, 20 | private val canvasPanel: CanvasPanel 21 | ) : AnAction("Export Canvas", "Export canvas as image", AllIcons.ToolbarDecorator.Export) { 22 | 23 | override fun actionPerformed(@NotNull e: AnActionEvent) { 24 | val descriptor = FileSaverDescriptor( 25 | "Export Canvas", "Choose where to save the canvas image", "png", "jpg" 26 | ) 27 | 28 | val dialog = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project) 29 | val wrapper = dialog.save(null as VirtualFile?, "code_canvas.png") 30 | 31 | if (wrapper != null) { 32 | try { 33 | // Create image from canvas 34 | val size = canvasPanel.size 35 | val image = BufferedImage( 36 | size.width, size.height, BufferedImage.TYPE_INT_ARGB 37 | ) 38 | val g2 = image.createGraphics() 39 | canvasPanel.paint(g2) 40 | g2.dispose() 41 | 42 | // Save image 43 | ImageIO.write(image, "png", wrapper.file) 44 | } catch (ex: Exception) { 45 | ex.printStackTrace() 46 | } 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/ExportCanvasWebAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasInterface 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.project.Project 7 | import org.jetbrains.annotations.NotNull 8 | 9 | class ExportCanvasWebAction( 10 | private val project: Project, 11 | private val canvasPanel: CanvasInterface 12 | ) : AnAction("Export Canvas", "Export canvas data", null) { 13 | 14 | override fun actionPerformed(@NotNull e: AnActionEvent) { 15 | // TODO: Implement web-based export functionality 16 | // This would need to be implemented differently from the AWT version 17 | } 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/FindOnCanvasAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 4 | import org.mwalker.bookmarkcanvas.services.BookmarkService 5 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 6 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 7 | import com.intellij.openapi.actionSystem.AnAction 8 | import com.intellij.openapi.actionSystem.AnActionEvent 9 | import com.intellij.openapi.actionSystem.CommonDataKeys 10 | import com.intellij.openapi.project.Project 11 | import com.intellij.openapi.wm.ToolWindow 12 | import com.intellij.openapi.wm.ToolWindowManager 13 | import org.jetbrains.annotations.NotNull 14 | import com.intellij.openapi.diagnostic.Logger 15 | import org.mwalker.bookmarkcanvas.ui.CanvasToolbar 16 | import com.intellij.openapi.editor.Editor 17 | import com.intellij.openapi.vfs.VirtualFile 18 | 19 | class FindOnCanvasAction : AnAction() { 20 | companion object { 21 | private val LOG = Logger.getInstance(FindOnCanvasAction::class.java) 22 | } 23 | 24 | override fun actionPerformed(@NotNull e: AnActionEvent) { 25 | val project = e.project ?: return 26 | val editor = e.getData(CommonDataKeys.EDITOR) ?: return 27 | val virtualFile = e.getData(CommonDataKeys.VIRTUAL_FILE) ?: return 28 | 29 | val currentLine = editor.caretModel.logicalPosition.line 30 | val filePath = getRelativeFilePath(project, virtualFile) 31 | 32 | LOG.info("FindOnCanvasAction: Looking for nodes containing $filePath:$currentLine") 33 | 34 | // Get the canvas state and find matching nodes 35 | val canvasState = CanvasPersistenceService.getInstance().getCanvasState(project) 36 | val matchingNodes = findNodesContainingLine(canvasState.nodes.values, filePath, currentLine) 37 | 38 | if (matchingNodes.isEmpty()) { 39 | LOG.info("No nodes found containing the current line") 40 | return 41 | } 42 | 43 | LOG.info("Found ${matchingNodes.size} nodes containing the current line") 44 | 45 | // Make sure the tool window is visible 46 | val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("BookmarkCanvas") 47 | if (toolWindow != null && !toolWindow.isVisible) { 48 | toolWindow.show(null) 49 | } 50 | 51 | // Center and select the matching nodes 52 | if (toolWindow != null) { 53 | val canvasPanel = findCanvasPanel(toolWindow) 54 | if (canvasPanel != null) { 55 | centerAndSelectNodes(canvasPanel, matchingNodes) 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * Find nodes that contain the specified line within their span 62 | */ 63 | private fun findNodesContainingLine(nodes: Collection, filePath: String, currentLine: Int): List { 64 | return nodes.filter { node -> 65 | // Check if the file path matches (handle both absolute and relative paths) 66 | val nodeFilePath = getNodeFilePath(node) 67 | val isFileMatch = nodeFilePath == filePath || 68 | nodeFilePath.endsWith("/$filePath") || 69 | filePath.endsWith("/$nodeFilePath") 70 | 71 | if (!isFileMatch) return@filter false 72 | 73 | // Check if the current line is within the node's span 74 | val nodeStartLine = node.lineNumber0Based 75 | val nodeEndLine = if (node.showCodeSnippet && node.contextLinesAfter > 0) { 76 | nodeStartLine + node.contextLinesAfter 77 | } else { 78 | nodeStartLine 79 | } 80 | 81 | currentLine >= nodeStartLine && currentLine <= nodeEndLine 82 | } 83 | } 84 | 85 | /** 86 | * Get the file path for comparison, handling both absolute and relative paths 87 | */ 88 | private fun getNodeFilePath(node: BookmarkNode): String { 89 | return if (node.filePath.startsWith("/") || node.filePath.contains(":\\")) { 90 | // Absolute path - extract the relative part 91 | node.filePath.substringAfterLast("/") 92 | } else { 93 | node.filePath 94 | } 95 | } 96 | 97 | /** 98 | * Get relative file path from project root 99 | */ 100 | private fun getRelativeFilePath(project: Project, virtualFile: VirtualFile): String { 101 | val projectBasePath = project.basePath ?: return virtualFile.path 102 | return if (virtualFile.path.startsWith(projectBasePath)) { 103 | virtualFile.path.substring(projectBasePath.length + 1) 104 | } else { 105 | virtualFile.path 106 | } 107 | } 108 | 109 | /** 110 | * Center the canvas view on the matching nodes and select them 111 | */ 112 | private fun centerAndSelectNodes(canvasPanel: CanvasPanel, nodes: List) { 113 | // Clear current selection 114 | canvasPanel.selectionManager.clearSelection() 115 | 116 | // Find the node components for the matching nodes 117 | val nodeComponents = nodes.mapNotNull { node -> 118 | canvasPanel.nodeComponents[node.id] 119 | } 120 | 121 | if (nodeComponents.isEmpty()) return 122 | 123 | // Add all matching nodes to selection 124 | for (nodeComponent in nodeComponents) { 125 | canvasPanel.selectedNodes.add(nodeComponent) 126 | nodeComponent.isSelected = true 127 | nodeComponent.repaint() 128 | } 129 | 130 | // Find the extreme points of all nodes in logical coordinates 131 | val extremePoints = findExtremePoints(nodeComponents) 132 | 133 | // Get the canvas size 134 | val canvasWidth = canvasPanel.width 135 | val canvasHeight = canvasPanel.height 136 | 137 | // Calculate the logical bounds with padding 138 | val padding = 50 // Padding in logical coordinates 139 | val logicalWidth = extremePoints.maxX - extremePoints.minX + padding * 2 140 | val logicalHeight = extremePoints.maxY - extremePoints.minY + padding * 2 141 | 142 | // Calculate zoom to fit all nodes with padding 143 | val zoomX = canvasWidth.toDouble() / logicalWidth 144 | val zoomY = canvasHeight.toDouble() / logicalHeight 145 | 146 | // Apply different max zoom limits based on number of nodes 147 | val maxZoom = if (nodeComponents.size == 1) { 148 | 0.5 // More conservative zoom for single nodes to avoid over-zooming 149 | } else { 150 | 0.5 // Standard zoom limit for multiple nodes 151 | } 152 | 153 | val targetZoom = minOf(zoomX, zoomY, maxZoom) 154 | 155 | // Set the zoom factor 156 | canvasPanel._zoomFactor = targetZoom 157 | canvasPanel.canvasState.zoomFactor = targetZoom 158 | 159 | // Calculate the center point of the logical bounds 160 | val logicalCenterX = extremePoints.minX + (extremePoints.maxX - extremePoints.minX) / 2 - padding 161 | val logicalCenterY = extremePoints.minY + (extremePoints.maxY - extremePoints.minY) / 2 - padding 162 | 163 | // Calculate how much to offset all nodes to center the view 164 | val targetLogicalCenterX = canvasWidth / (2 * targetZoom) 165 | val targetLogicalCenterY = canvasHeight / (2 * targetZoom) 166 | 167 | val offsetX = (targetLogicalCenterX - logicalCenterX).toInt() 168 | val offsetY = (targetLogicalCenterY - logicalCenterY).toInt() 169 | 170 | // Apply offset to all nodes to center the view 171 | for (nodeComp in canvasPanel.nodeComponents.values) { 172 | val currentPos = nodeComp.node.position 173 | nodeComp.node.position = java.awt.Point( 174 | currentPos.x + offsetX, 175 | currentPos.y + offsetY 176 | ) 177 | } 178 | 179 | // Update canvas and save state 180 | canvasPanel.zoomManager.updateCanvasSize() 181 | canvasPanel.revalidate() 182 | canvasPanel.repaint() 183 | 184 | // Save the updated state 185 | CanvasPersistenceService.getInstance().saveCanvasState(canvasPanel.project, canvasPanel.canvasState) 186 | 187 | LOG.info("Centered and selected ${nodeComponents.size} nodes with zoom ${targetZoom}") 188 | } 189 | 190 | /** 191 | * Data class to hold extreme points of nodes 192 | */ 193 | private data class ExtremePoints( 194 | val minX: Int, 195 | val minY: Int, 196 | val maxX: Int, 197 | val maxY: Int 198 | ) 199 | 200 | /** 201 | * Find the extreme points (corners) of all the given node components in logical coordinates 202 | */ 203 | private fun findExtremePoints(nodeComponents: List): ExtremePoints { 204 | if (nodeComponents.isEmpty()) return ExtremePoints(0, 0, 0, 0) 205 | 206 | var minX = Int.MAX_VALUE 207 | var minY = Int.MAX_VALUE 208 | var maxX = Int.MIN_VALUE 209 | var maxY = Int.MIN_VALUE 210 | 211 | for (nodeComp in nodeComponents) { 212 | // Use logical position (node.position) not screen position (nodeComp.x/y) 213 | val logicalX = nodeComp.node.position.x 214 | val logicalY = nodeComp.node.position.y 215 | val logicalWidth = if (nodeComp.node.width > 0) nodeComp.node.width else nodeComp.preferredSize.width 216 | val logicalHeight = if (nodeComp.node.height > 0) nodeComp.node.height else nodeComp.preferredSize.height 217 | 218 | // Find extreme points 219 | minX = minOf(minX, logicalX) 220 | minY = minOf(minY, logicalY) 221 | maxX = maxOf(maxX, logicalX + logicalWidth) 222 | maxY = maxOf(maxY, logicalY + logicalHeight) 223 | } 224 | 225 | return ExtremePoints(minX, minY, maxX, maxY) 226 | } 227 | 228 | /** 229 | * Find the canvas panel from the tool window 230 | */ 231 | private fun findCanvasPanel(toolWindow: ToolWindow): CanvasPanel? { 232 | val component = toolWindow.contentManager.getContent(0)?.component 233 | return (component as? CanvasToolbar)?.canvasPanel 234 | } 235 | 236 | override fun update(@NotNull e: AnActionEvent) { 237 | // Only enable the action when we have an open editor 238 | val project = e.project 239 | val editor = e.getData(CommonDataKeys.EDITOR) 240 | 241 | e.presentation.isEnabledAndVisible = ( 242 | project != null && 243 | editor != null 244 | ) 245 | } 246 | 247 | /** 248 | * Check if there are any nodes in the canvas for the current project 249 | */ 250 | private fun hasNodesInCanvas(project: Project?): Boolean { 251 | if (project == null) return false 252 | val canvasState = CanvasPersistenceService.getInstance().getCanvasState(project) 253 | return canvasState.nodes.isNotEmpty() 254 | } 255 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/GridWebActions.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasInterface 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.project.Project 7 | import org.jetbrains.annotations.NotNull 8 | 9 | class ToggleSnapToGridWebAction( 10 | private val project: Project, 11 | private val canvasPanel: CanvasInterface 12 | ) : AnAction("Toggle Snap to Grid", "Toggle snap to grid", null) { 13 | 14 | override fun actionPerformed(@NotNull e: AnActionEvent) { 15 | // Note: This is simplified - in a full implementation you'd track the current state 16 | canvasPanel.setSnapToGrid(true) // This should toggle based on current state 17 | } 18 | } 19 | 20 | class ToggleShowGridWebAction( 21 | private val project: Project, 22 | private val canvasPanel: CanvasInterface 23 | ) : AnAction("Toggle Show Grid", "Toggle grid visibility", null) { 24 | 25 | override fun actionPerformed(@NotNull e: AnActionEvent) { 26 | // Note: This is simplified - in a full implementation you'd track the current state 27 | canvasPanel.setShowGrid(true) // This should toggle based on current state 28 | } 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/HomeAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import com.intellij.icons.AllIcons 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 7 | import javax.swing.Icon 8 | import com.intellij.openapi.util.IconLoader 9 | 10 | class HomeAction(private val canvasPanel: CanvasPanel) : AnAction("Go to Home Node", "Go to the top-left node in the canvas", AllIcons.Nodes.HomeFolder) { 11 | 12 | override fun actionPerformed(e: AnActionEvent) { 13 | // Find the top-left most node and recenter on it 14 | canvasPanel.goToTopLeftNode() 15 | } 16 | 17 | override fun update(e: AnActionEvent) { 18 | // Enable the action only if we have nodes 19 | e.presentation.isEnabled = canvasPanel.canvasState.nodes.isNotEmpty() 20 | } 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/RedoAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 4 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 5 | import com.intellij.icons.AllIcons 6 | import com.intellij.openapi.actionSystem.AnAction 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.project.DumbAware 9 | import org.jetbrains.annotations.NotNull 10 | 11 | class RedoAction() : AnAction("Redo", "Redo the last undone action", AllIcons.Actions.Redo), DumbAware { 12 | 13 | constructor(canvasPanel: CanvasPanel) : this() { 14 | // Constructor for toolbar actions 15 | this.canvasPanel = canvasPanel 16 | } 17 | 18 | private var canvasPanel: CanvasPanel? = null 19 | 20 | override fun actionPerformed(@NotNull e: AnActionEvent) { 21 | // This action only works when called from the toolbar where canvasPanel is set 22 | val panel = canvasPanel ?: return 23 | val project = e.project ?: return 24 | val canvasState = panel.canvasState 25 | 26 | if (canvasState.redo()) { 27 | // After redoing, refresh the canvas 28 | panel.refreshFromState() 29 | 30 | // Save the updated state 31 | val service = CanvasPersistenceService.getInstance() 32 | service.saveCanvasState(project, canvasState) 33 | } 34 | } 35 | 36 | override fun update(@NotNull e: AnActionEvent) { 37 | // Only enable if we have a panel reference 38 | val panel = canvasPanel 39 | e.presentation.isEnabled = panel != null && panel.canvasState.canRedo() 40 | } 41 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/RefreshBookmarksAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 4 | import org.mwalker.bookmarkcanvas.services.BookmarkService 5 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 6 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 7 | import com.intellij.icons.AllIcons 8 | import com.intellij.openapi.actionSystem.AnAction 9 | import com.intellij.openapi.actionSystem.AnActionEvent 10 | import com.intellij.openapi.diagnostic.Logger 11 | import com.intellij.openapi.fileEditor.FileDocumentManager 12 | import com.intellij.openapi.project.Project 13 | import com.intellij.openapi.vfs.LocalFileSystem 14 | import com.intellij.psi.PsiManager 15 | import org.jetbrains.annotations.NotNull 16 | import com.intellij.notification.NotificationGroup 17 | import com.intellij.notification.NotificationType 18 | import com.intellij.notification.Notifications 19 | 20 | /** 21 | * Helper class with shared utility methods for bookmark validation 22 | */ 23 | object BookmarkValidator { 24 | private val LOG = Logger.getInstance(BookmarkValidator::class.java) 25 | private val NOTIFICATION_GROUP = NotificationGroup.balloonGroup("Bookmark Canvas") 26 | 27 | /** 28 | * Verify all bookmarks in the canvas state to ensure line numbers are accurate 29 | * Returns the number of bookmarks that were updated or marked invalid 30 | */ 31 | fun verifyAllBookmarks(project: Project, canvasState: org.mwalker.bookmarkcanvas.model.CanvasState): Int { 32 | var updatedCount = 0 33 | var validCount = 0 34 | var invalidCount = 0 35 | 36 | // Check all nodes in the canvas 37 | for (node in canvasState.nodes.values) { 38 | val wasValid = node.isValidBookmark 39 | val isValid = verifyBookmarkLineNumber(project, node) 40 | 41 | // If validity state changed, count it 42 | if (wasValid != isValid) { 43 | if (isValid) validCount++ else invalidCount++ 44 | updatedCount++ 45 | } 46 | 47 | // Update the node's valid state 48 | node.isValidBookmark = isValid 49 | } 50 | 51 | // Show notification with results 52 | val message = when { 53 | updatedCount == 0 -> "All bookmarks are up to date" 54 | invalidCount > 0 -> "Found $invalidCount invalid bookmarks (marked with red border)" 55 | else -> "Updated $validCount bookmarks" 56 | } 57 | 58 | NOTIFICATION_GROUP.createNotification( 59 | "Bookmark Verification", 60 | message, 61 | if (invalidCount > 0) NotificationType.WARNING else NotificationType.INFORMATION, 62 | null 63 | ).notify(project) 64 | 65 | return updatedCount 66 | } 67 | 68 | /** 69 | * Verifies if a bookmark's line number is accurate and tries to relocate it if needed 70 | * Returns true if the bookmark is valid (either as-is or after relocation) 71 | */ 72 | fun verifyBookmarkLineNumber(project: Project, node: BookmarkNode): Boolean { 73 | // Skip if no line content is saved for either single-line or multi-line bookmarks 74 | if (node.lineContent.isNullOrBlank() && node.multiLineContent.isNullOrEmpty()) { 75 | return true // Assume valid if we don't have content to verify 76 | } 77 | 78 | try { 79 | // Handle both absolute paths and project-relative paths 80 | val file = if (node.filePath.startsWith("/") || node.filePath.contains(":\\")) { 81 | // Absolute path 82 | LocalFileSystem.getInstance().findFileByPath(node.filePath) 83 | } else { 84 | // Project-relative path 85 | project.baseDir.findFileByRelativePath(node.filePath) 86 | } ?: return false // File not found 87 | 88 | val psiFile = PsiManager.getInstance(project).findFile(file) 89 | ?: return false // Cannot read file 90 | 91 | val document = psiFile.viewProvider.document 92 | ?: return false // Cannot read document 93 | 94 | // Handle multi-line bookmarks first 95 | if (node.showCodeSnippet && node.contextLinesAfter > 0 && 96 | !node.multiLineContent.isNullOrEmpty()) { 97 | 98 | // Check if multi-line content still matches at current location 99 | if (checkMultiLineContentMatch(document, node.lineNumber0Based, node.multiLineContent!!)) { 100 | return true 101 | } 102 | 103 | // Try to find the multi-line content elsewhere 104 | val newStartLine = findMultiLineContentByContent(document, node.multiLineContent!!, node.lineNumber0Based) 105 | if (newStartLine != null) { 106 | LOG.info("Updated multi-line bookmark from line ${node.lineNumber0Based} to $newStartLine") 107 | node.lineNumber0Based = newStartLine 108 | // Refresh content after line number change 109 | node.refreshContent(project) 110 | return true 111 | } 112 | 113 | return false 114 | } 115 | 116 | // Handle single-line bookmarks 117 | // Check if current line matches stored content 118 | val lineContent = if (node.lineNumber0Based >= 0 && node.lineNumber0Based < document.lineCount) { 119 | val lineStart = document.getLineStartOffset(node.lineNumber0Based) 120 | val lineEnd = document.getLineEndOffset(node.lineNumber0Based) 121 | document.getText(com.intellij.openapi.util.TextRange(lineStart, lineEnd)) 122 | } else { 123 | null 124 | } 125 | 126 | // If line content matches, bookmark is valid 127 | if (lineContent?.trim() == node.lineContent?.trim()) { 128 | return true 129 | } 130 | 131 | // Try to relocate 132 | val trimmedContent = node.lineContent?.trim() ?: return false 133 | val text = document.text 134 | val lines = text.lines() 135 | 136 | // Search for matching line 137 | var bestMatchLine: Int? = null 138 | var minDistance = Int.MAX_VALUE 139 | 140 | lines.forEachIndexed { index, line -> 141 | if (line.contains(trimmedContent)) { 142 | val distance = kotlin.math.abs(index - node.lineNumber0Based) 143 | if (distance < minDistance) { 144 | minDistance = distance 145 | bestMatchLine = index 146 | } 147 | } 148 | } 149 | 150 | // Update line number if found 151 | if (bestMatchLine != null) { 152 | LOG.info("Updated bookmark line from ${node.lineNumber0Based} to $bestMatchLine") 153 | node.lineNumber0Based = bestMatchLine!! 154 | // Refresh content after line number change 155 | node.refreshContent(project) 156 | return true 157 | } 158 | 159 | // No match found 160 | return false 161 | } catch (e: Exception) { 162 | LOG.error("Error verifying bookmark line", e) 163 | return false 164 | } 165 | } 166 | 167 | /** 168 | * Checks if the multi-line content still matches at the expected location 169 | */ 170 | private fun checkMultiLineContentMatch(document: com.intellij.openapi.editor.Document, startLine: Int, expectedContent: List): Boolean { 171 | if (startLine < 0 || startLine >= document.lineCount) return false 172 | 173 | val documentLines = document.text.lines() 174 | for (i in expectedContent.indices) { 175 | val currentLine = startLine + i 176 | if (currentLine >= documentLines.size) return false 177 | 178 | val expectedLine = expectedContent[i].trim() 179 | val actualLine = documentLines[currentLine].trim() 180 | 181 | if (expectedLine != actualLine) return false 182 | } 183 | return true 184 | } 185 | 186 | /** 187 | * Finds where the multi-line content block has moved to in the document 188 | */ 189 | private fun findMultiLineContentByContent(document: com.intellij.openapi.editor.Document, expectedContent: List, originalLineNumber: Int): Int? { 190 | if (expectedContent.isEmpty()) return null 191 | 192 | val documentLines = document.text.lines() 193 | val contentSize = expectedContent.size 194 | 195 | // Search for the first line and then verify the rest of the block 196 | val firstLineContent = expectedContent[0].trim() 197 | if (firstLineContent.isEmpty()) return null 198 | 199 | var bestMatch: Int? = null 200 | var minDistance = Int.MAX_VALUE 201 | 202 | for (startLine in 0 until documentLines.size - contentSize + 1) { 203 | if (documentLines[startLine].trim() == firstLineContent) { 204 | // Check if the entire block matches at this position 205 | var allMatch = true 206 | for (i in expectedContent.indices) { 207 | val lineIndex = startLine + i 208 | if (lineIndex >= documentLines.size) { 209 | allMatch = false 210 | break 211 | } 212 | 213 | val expectedLine = expectedContent[i].trim() 214 | val actualLine = documentLines[lineIndex].trim() 215 | 216 | if (expectedLine != actualLine) { 217 | allMatch = false 218 | break 219 | } 220 | } 221 | 222 | if (allMatch) { 223 | val distance = kotlin.math.abs(startLine - originalLineNumber) 224 | if (distance < minDistance) { 225 | minDistance = distance 226 | bestMatch = startLine 227 | } 228 | } 229 | } 230 | } 231 | 232 | return bestMatch 233 | } 234 | } 235 | 236 | class RefreshBookmarksAction( 237 | private val project: Project, 238 | private val canvasPanel: CanvasPanel 239 | ) : AnAction("Refresh Bookmarks", "Import all bookmarks from IDE", AllIcons.Actions.Refresh) { 240 | private val LOG = Logger.getInstance(RefreshBookmarksAction::class.java) 241 | 242 | override fun actionPerformed(@NotNull e: AnActionEvent) { 243 | // Get all bookmarks from IDE 244 | val bookmarkNodes = BookmarkService.getAllBookmarkNodes(project) 245 | 246 | // Get current canvas state 247 | val canvasState = CanvasPersistenceService.getInstance().getCanvasState(project) 248 | 249 | // Add any new bookmarks (avoid duplicates) 250 | var changed = false 251 | for (node in bookmarkNodes) { 252 | if (!canvasState.nodes.containsKey(node.id)) { 253 | canvasState.addNode(node) 254 | canvasPanel.addNodeComponent(node) 255 | changed = true 256 | } 257 | } 258 | 259 | // Save state and refresh the canvas 260 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasState) 261 | canvasPanel.refreshFromState() 262 | } 263 | } 264 | 265 | /** 266 | * Action to verify all bookmarks on the canvas and update their status 267 | */ 268 | class VerifyBookmarksAction( 269 | private val project: Project, 270 | private val canvasPanel: CanvasPanel 271 | ) : AnAction("Verify Bookmarks", "Check all bookmarks for accuracy", AllIcons.Actions.Refresh) { 272 | 273 | override fun actionPerformed(@NotNull e: AnActionEvent) { 274 | // Get current canvas state 275 | val canvasState = CanvasPersistenceService.getInstance().getCanvasState(project) 276 | 277 | // Verify all bookmarks 278 | BookmarkValidator.verifyAllBookmarks(project, canvasState) 279 | 280 | // Save state and refresh UI 281 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasState) 282 | canvasPanel.refreshFromState() 283 | } 284 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/RefreshBookmarksWebAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasInterface 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.project.Project 7 | import org.jetbrains.annotations.NotNull 8 | 9 | class RefreshBookmarksWebAction( 10 | private val project: Project, 11 | private val canvasPanel: CanvasInterface 12 | ) : AnAction("Refresh Bookmarks", "Refresh bookmark positions", null) { 13 | 14 | override fun actionPerformed(@NotNull e: AnActionEvent) { 15 | canvasPanel.refreshFromState() 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/ToggleShowGridAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 4 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 5 | import com.intellij.icons.AllIcons 6 | import com.intellij.openapi.actionSystem.AnAction 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.actionSystem.Toggleable 9 | import org.jetbrains.annotations.NotNull 10 | 11 | class ToggleShowGridAction( 12 | private val canvasPanel: CanvasPanel 13 | ) : AnAction("Show Grid", "Show or hide the grid", AllIcons.Graph.Grid), Toggleable { 14 | 15 | override fun actionPerformed(@NotNull e: AnActionEvent) { 16 | val newValue = !canvasPanel.showGrid 17 | canvasPanel.setShowGrid(newValue) 18 | Toggleable.setSelected(e.presentation, newValue) 19 | 20 | // Save state after changing preference 21 | val project = e.project ?: return 22 | val service = CanvasPersistenceService.getInstance() 23 | service.saveCanvasState(project, canvasPanel.canvasState) 24 | } 25 | 26 | override fun update(@NotNull e: AnActionEvent) { 27 | super.update(e) 28 | Toggleable.setSelected(e.presentation, canvasPanel.showGrid) 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/ToggleSnapToGridAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 4 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 5 | import com.intellij.icons.AllIcons 6 | import com.intellij.openapi.actionSystem.AnAction 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.actionSystem.Toggleable 9 | import org.jetbrains.annotations.NotNull 10 | 11 | class ToggleSnapToGridAction( 12 | private val canvasPanel: CanvasPanel 13 | ) : AnAction("Snap to Grid", "Align nodes to a grid", AllIcons.Graph.SnapToGrid), Toggleable { 14 | 15 | override fun actionPerformed(@NotNull e: AnActionEvent) { 16 | val newValue = !canvasPanel.snapToGrid 17 | canvasPanel.setSnapToGrid(newValue) 18 | Toggleable.setSelected(e.presentation, newValue) 19 | 20 | // Save state after changing preference 21 | val project = e.project ?: return 22 | val service = CanvasPersistenceService.getInstance() 23 | service.saveCanvasState(project, canvasPanel.canvasState) 24 | } 25 | 26 | override fun update(@NotNull e: AnActionEvent) { 27 | super.update(e) 28 | Toggleable.setSelected(e.presentation, canvasPanel.snapToGrid) 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/UndoAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 4 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 5 | import com.intellij.icons.AllIcons 6 | import com.intellij.openapi.actionSystem.AnAction 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.project.DumbAware 9 | import org.jetbrains.annotations.NotNull 10 | 11 | class UndoAction() : AnAction("Undo", "Undo the last action", AllIcons.Actions.Undo), DumbAware { 12 | 13 | constructor(canvasPanel: CanvasPanel) : this() { 14 | // Constructor for toolbar actions 15 | this.canvasPanel = canvasPanel 16 | } 17 | 18 | private var canvasPanel: CanvasPanel? = null 19 | 20 | override fun actionPerformed(@NotNull e: AnActionEvent) { 21 | // This action only works when called from the toolbar where canvasPanel is set 22 | val panel = canvasPanel ?: return 23 | val project = e.project ?: return 24 | val canvasState = panel.canvasState 25 | 26 | if (canvasState.undo()) { 27 | // After undoing, refresh the canvas 28 | panel.refreshFromState() 29 | 30 | // Save the updated state 31 | val service = CanvasPersistenceService.getInstance() 32 | service.saveCanvasState(project, canvasState) 33 | } 34 | } 35 | 36 | override fun update(@NotNull e: AnActionEvent) { 37 | // Only enable if we have a panel reference 38 | val panel = canvasPanel 39 | e.presentation.isEnabled = panel != null && panel.canvasState.canUndo() 40 | } 41 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/UndoRedoWebActions.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasInterface 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.project.Project 7 | import org.jetbrains.annotations.NotNull 8 | 9 | class UndoWebAction( 10 | private val project: Project, 11 | private val canvasPanel: CanvasInterface 12 | ) : AnAction("Undo", "Undo last action", null) { 13 | 14 | override fun actionPerformed(@NotNull e: AnActionEvent) { 15 | canvasPanel.undo() 16 | } 17 | } 18 | 19 | class RedoWebAction( 20 | private val project: Project, 21 | private val canvasPanel: CanvasInterface 22 | ) : AnAction("Redo", "Redo last undone action", null) { 23 | 24 | override fun actionPerformed(@NotNull e: AnActionEvent) { 25 | canvasPanel.redo() 26 | } 27 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/ZoomInAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 4 | import com.intellij.icons.AllIcons 5 | import com.intellij.openapi.actionSystem.AnAction 6 | import com.intellij.openapi.actionSystem.AnActionEvent 7 | import org.jetbrains.annotations.NotNull 8 | 9 | class ZoomInAction( 10 | private val canvasPanel: CanvasPanel 11 | ) : AnAction("Zoom In", "Increase canvas zoom", AllIcons.Graph.ZoomIn) { 12 | 13 | override fun actionPerformed(@NotNull e: AnActionEvent) { 14 | canvasPanel.zoomIn() 15 | } 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/ZoomOutAction.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasPanel 4 | import com.intellij.icons.AllIcons 5 | import com.intellij.openapi.actionSystem.AnAction 6 | import com.intellij.openapi.actionSystem.AnActionEvent 7 | import org.jetbrains.annotations.NotNull 8 | 9 | class ZoomOutAction( 10 | private val canvasPanel: CanvasPanel 11 | ) : AnAction("Zoom Out", "Decrease canvas zoom", AllIcons.Graph.ZoomOut) { 12 | 13 | override fun actionPerformed(@NotNull e: AnActionEvent) { 14 | canvasPanel.zoomOut() 15 | } 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/actions/ZoomWebActions.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.actions 2 | 3 | import org.mwalker.bookmarkcanvas.ui.CanvasInterface 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.project.Project 7 | import org.jetbrains.annotations.NotNull 8 | 9 | class ZoomInWebAction( 10 | private val project: Project, 11 | private val canvasPanel: CanvasInterface 12 | ) : AnAction("Zoom In", "Zoom in on canvas", null) { 13 | 14 | override fun actionPerformed(@NotNull e: AnActionEvent) { 15 | canvasPanel.zoomIn() 16 | } 17 | } 18 | 19 | class ZoomOutWebAction( 20 | private val project: Project, 21 | private val canvasPanel: CanvasInterface 22 | ) : AnAction("Zoom Out", "Zoom out on canvas", null) { 23 | 24 | override fun actionPerformed(@NotNull e: AnActionEvent) { 25 | canvasPanel.zoomOut() 26 | } 27 | } 28 | 29 | class HomeWebAction( 30 | private val project: Project, 31 | private val canvasPanel: CanvasInterface 32 | ) : AnAction("Home", "Go to home position", null) { 33 | 34 | override fun actionPerformed(@NotNull e: AnActionEvent) { 35 | canvasPanel.goHome() 36 | } 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/model/CanvasState.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.model 2 | import com.intellij.openapi.diagnostic.Logger 3 | 4 | 5 | class CanvasState { 6 | companion object { 7 | private val LOG = Logger.getInstance(CanvasState::class.java) 8 | } 9 | val nodes = mutableMapOf() 10 | val connections = mutableListOf() 11 | 12 | // Grid preferences 13 | var snapToGrid: Boolean = false 14 | var showGrid: Boolean = false 15 | 16 | // Canvas view state 17 | var zoomFactor: Double = 1.0 18 | var scrollPositionX: Int = 0 19 | var scrollPositionY: Int = 0 20 | 21 | // History for undo functionality 22 | private val history = mutableListOf() 23 | private var historyIndex = -1 24 | private var isUndoRedo = false 25 | 26 | init { 27 | // Save initial state 28 | saveSnapshot() 29 | } 30 | 31 | fun addNode(node: org.mwalker.bookmarkcanvas.model.BookmarkNode) { 32 | LOG.info("Adding node: $node") 33 | if (!isUndoRedo) saveSnapshot() 34 | nodes[node.id] = node 35 | } 36 | 37 | fun removeNode(nodeId: String) { 38 | LOG.info("Removing node: $nodeId") 39 | if (!isUndoRedo) saveSnapshot() 40 | nodes.remove(nodeId) 41 | // Also remove any connections involving this node 42 | connections.removeIf { it.sourceNodeId == nodeId || it.targetNodeId == nodeId } 43 | } 44 | 45 | fun addConnection(connection: org.mwalker.bookmarkcanvas.model.NodeConnection) { 46 | // LOG.info("Adding connection: $connection") 47 | if (!isUndoRedo) saveSnapshot() 48 | connections.add(connection) 49 | } 50 | 51 | fun removeConnection(connectionId: String) { 52 | LOG.info("Removing connection: $connectionId") 53 | if (!isUndoRedo) saveSnapshot() 54 | connections.removeIf { it.id == connectionId } 55 | } 56 | 57 | fun updateNodePosition(nodeId: String, x: Int, y: Int) { 58 | val node = nodes[nodeId] ?: return 59 | if (node.positionX == x && node.positionY == y) return 60 | 61 | if (!isUndoRedo) saveSnapshot() 62 | node.positionX = x 63 | node.positionY = y 64 | } 65 | 66 | fun setGridPreferences(snapToGrid: Boolean, showGrid: Boolean) { 67 | if (this.snapToGrid == snapToGrid && this.showGrid == showGrid) return 68 | 69 | if (!isUndoRedo) saveSnapshot() 70 | this.snapToGrid = snapToGrid 71 | this.showGrid = showGrid 72 | } 73 | 74 | // Undo/Redo functionality 75 | fun canUndo(): Boolean = historyIndex > 0 76 | 77 | fun canRedo(): Boolean = historyIndex < history.size - 1 78 | 79 | fun undo(): Boolean { 80 | if (!canUndo()) return false 81 | 82 | historyIndex-- 83 | applySnapshot(history[historyIndex]) 84 | return true 85 | } 86 | 87 | fun redo(): Boolean { 88 | if (!canRedo()) return false 89 | 90 | historyIndex++ 91 | applySnapshot(history[historyIndex]) 92 | return true 93 | } 94 | 95 | private fun saveSnapshot() { 96 | // Remove any redo states if we're making a new change 97 | if (historyIndex < history.size - 1) { 98 | history.subList(historyIndex + 1, history.size).clear() 99 | } 100 | 101 | // Create a snapshot 102 | val snapshot = CanvasStateSnapshot( 103 | nodes = nodes.mapValues { it.value.copy() }, 104 | connections = connections.map { it.copy() }, 105 | snapToGrid = snapToGrid, 106 | showGrid = showGrid, 107 | zoomFactor = zoomFactor, 108 | scrollPositionX = scrollPositionX, 109 | scrollPositionY = scrollPositionY 110 | ) 111 | 112 | // Add to history, limit history size to prevent memory issues 113 | if (history.size >= 30) { 114 | history.removeAt(0) 115 | } 116 | history.add(snapshot) 117 | historyIndex = history.size - 1 118 | } 119 | 120 | private fun applySnapshot(snapshot: CanvasStateSnapshot) { 121 | isUndoRedo = true 122 | 123 | // Clear current state 124 | nodes.clear() 125 | connections.clear() 126 | 127 | // Apply snapshot 128 | nodes.putAll(snapshot.nodes) 129 | connections.addAll(snapshot.connections) 130 | snapToGrid = snapshot.snapToGrid 131 | showGrid = snapshot.showGrid 132 | zoomFactor = snapshot.zoomFactor 133 | scrollPositionX = snapshot.scrollPositionX 134 | scrollPositionY = snapshot.scrollPositionY 135 | 136 | isUndoRedo = false 137 | } 138 | 139 | // Inner class to store snapshots 140 | private data class CanvasStateSnapshot( 141 | val nodes: Map, 142 | val connections: List, 143 | val snapToGrid: Boolean, 144 | val showGrid: Boolean, 145 | val zoomFactor: Double, 146 | val scrollPositionX: Int, 147 | val scrollPositionY: Int 148 | ) 149 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/model/NodeConnection.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.model 2 | 3 | import java.awt.Color 4 | import java.util.* 5 | 6 | data class NodeConnection( 7 | val id: String = UUID.randomUUID().toString(), 8 | val sourceNodeId: String, 9 | val targetNodeId: String, 10 | var label: String = "", 11 | var colorRGB: Int = Color.GRAY.rgb 12 | ) { 13 | 14 | // Color as a computed property 15 | var color: Color 16 | get() = Color(colorRGB) 17 | set(value) { 18 | colorRGB = value.rgb 19 | } 20 | 21 | constructor(sourceNodeId: String, targetNodeId: String) : this( 22 | id = UUID.randomUUID().toString(), 23 | sourceNodeId = sourceNodeId, 24 | targetNodeId = targetNodeId 25 | ) 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/services/BookmarkService.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.services 2 | 3 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 4 | import com.intellij.ide.bookmark.BookmarksManager 5 | import com.intellij.ide.bookmark.LineBookmark 6 | import com.intellij.openapi.diagnostic.Logger 7 | import com.intellij.openapi.fileEditor.FileDocumentManager 8 | import com.intellij.openapi.fileEditor.FileEditorManager 9 | import com.intellij.openapi.project.Project 10 | 11 | class BookmarkService { 12 | companion object { 13 | private val LOG = Logger.getInstance(BookmarkService::class.java) 14 | 15 | /** 16 | * Resolves path macros in file paths and makes the path relative to the project when possible. 17 | * Handles system path variables like USER_HOME. 18 | */ 19 | private fun resolveFilePath(project: Project, originalPath: String): String { 20 | LOG.info("Original path: $originalPath, resolving with PathMacroManager") 21 | // Use PathMacroManager to resolve path macros 22 | var filePath = com.intellij.openapi.util.io.FileUtil.toSystemDependentName(originalPath) 23 | 24 | // Try to resolve macros with PathMacroManager 25 | val pathMacroManager = com.intellij.openapi.components.PathMacroManager.getInstance(project) 26 | filePath = pathMacroManager.expandPath(filePath) 27 | 28 | // Make path relative to project if possible 29 | if (project.basePath != null && filePath.startsWith(project.basePath!!)) { 30 | filePath = filePath.replace(project.basePath!! + "/", "") 31 | } 32 | LOG.info("Resolved path: $filePath") 33 | return filePath 34 | } 35 | fun createNodeFromCurrentPosition(project: Project): BookmarkNode? { 36 | val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return null 37 | 38 | val document = editor.document 39 | val file = FileDocumentManager.getInstance().getFile(document) ?: return null 40 | 41 | // Resolve the file path 42 | val filePath = resolveFilePath(project, file.path) 43 | 44 | // Check if there's a text selection 45 | val selectionModel = editor.selectionModel 46 | if (selectionModel.hasSelection()) { 47 | // Get the selection bounds 48 | val selectionStart = selectionModel.selectionStart 49 | val selectionEnd = selectionModel.selectionEnd 50 | 51 | // Get the line numbers where the selection starts and ends 52 | val startLine = document.getLineNumber(selectionStart) 53 | val endLine = document.getLineNumber(selectionEnd) 54 | val linesInSelection = endLine - startLine + 1 55 | 56 | // Store the first line content for navigation purposes 57 | val firstLineStart = document.getLineStartOffset(startLine) 58 | val firstLineEnd = document.getLineEndOffset(startLine) 59 | val firstLineContent = document.getText(com.intellij.openapi.util.TextRange(firstLineStart, firstLineEnd)) 60 | 61 | // Create a node with the selection information 62 | return BookmarkNode( 63 | bookmarkId = "bookmark_" + System.currentTimeMillis(), 64 | filePath = filePath, 65 | lineNumber0Based = startLine, 66 | lineContent = firstLineContent, 67 | showCodeSnippet = true, 68 | contextLinesBefore = 0, 69 | contextLinesAfter = linesInSelection - 1 70 | ) 71 | } else { 72 | // Regular bookmark - just use the current line 73 | val line = editor.caretModel.logicalPosition.line 74 | 75 | // Get the line content 76 | val lineContent = if (line >= 0 && line < document.lineCount) { 77 | val lineStart = document.getLineStartOffset(line) 78 | val lineEnd = document.getLineEndOffset(line) 79 | document.getText(com.intellij.openapi.util.TextRange(lineStart, lineEnd)) 80 | } else null 81 | 82 | return BookmarkNode( 83 | bookmarkId = "bookmark_" + System.currentTimeMillis(), 84 | filePath = filePath, 85 | lineNumber0Based = line, 86 | lineContent = lineContent 87 | ) 88 | } 89 | } 90 | 91 | fun createFileNodeFromCurrentFile(project: Project): BookmarkNode? { 92 | val editor = FileEditorManager.getInstance(project).selectedTextEditor 93 | val file = if (editor != null) { 94 | FileDocumentManager.getInstance().getFile(editor.document) 95 | } else { 96 | // Try to get the currently selected file in the project view 97 | val fileEditorManager = FileEditorManager.getInstance(project) 98 | fileEditorManager.selectedFiles.firstOrNull() 99 | } ?: return null 100 | 101 | // Resolve the file path 102 | val filePath = resolveFilePath(project, file.path) 103 | 104 | return BookmarkNode( 105 | bookmarkId = "file_" + System.currentTimeMillis(), 106 | filePath = filePath, 107 | lineNumber0Based = 0, 108 | lineContent = "File: ${file.name}" 109 | ) 110 | } 111 | 112 | fun getAllBookmarkNodes(project: Project): List { 113 | val result = mutableListOf() 114 | 115 | val bookmarkManager = BookmarksManager.getInstance(project) 116 | val bookmarks = bookmarkManager?.bookmarks?.mapNotNull { it as? LineBookmark 117 | } ?: return listOf() 118 | 119 | for (bookmark in bookmarks) { 120 | LOG.info("Bookmark: $bookmark") 121 | LOG.info("Bookmark attributes: ${bookmark.attributes}") 122 | // Convert IDE bookmarks to our model using our path resolution method 123 | val filePath = resolveFilePath(project, bookmark.file.path) 124 | 125 | // Get the line content 126 | val lineContent = try { 127 | val file = bookmark.file 128 | val document = FileDocumentManager.getInstance().getDocument(file) 129 | if (document != null && bookmark.line >= 0 && bookmark.line < document.lineCount) { 130 | val lineStart = document.getLineStartOffset(bookmark.line) 131 | val lineEnd = document.getLineEndOffset(bookmark.line) 132 | document.getText(com.intellij.openapi.util.TextRange(lineStart, lineEnd)) 133 | } else null 134 | } catch (e: Exception) { 135 | null 136 | } 137 | 138 | val node = BookmarkNode( 139 | bookmarkId = bookmark.toString(), // Or a unique ID 140 | // displayName = bookmark.description, 141 | // displayName = (bookmark.file.name + ":" + bookmark.line), // Or file:line 142 | filePath = filePath, 143 | lineNumber0Based = bookmark.line, 144 | lineContent = lineContent 145 | ) 146 | 147 | result.add(node) 148 | } 149 | 150 | return result 151 | } 152 | } 153 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasConnectionManager.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.openapi.project.Project 4 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 5 | import org.mwalker.bookmarkcanvas.model.NodeConnection 6 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 7 | import org.mwalker.bookmarkcanvas.ui.CanvasColors 8 | import java.awt.BasicStroke 9 | import java.awt.Color 10 | import java.awt.Graphics2D 11 | import java.awt.Point 12 | import java.awt.geom.Line2D 13 | import java.awt.geom.Point2D 14 | 15 | /** 16 | * Manages connections between nodes on the canvas 17 | */ 18 | class CanvasConnectionManager( 19 | private val canvasPanel: CanvasPanel, 20 | private val project: Project 21 | ) { 22 | /** 23 | * Creates a new connection between two bookmark nodes. 24 | * If a connection already exists between the nodes, it will be removed instead. 25 | */ 26 | fun createNewConnection(source: BookmarkNode, target: BookmarkNode) { 27 | // Check if connection already exists 28 | val existingConnection = canvasPanel.canvasState.connections.find { 29 | (it.sourceNodeId == source.id && it.targetNodeId == target.id) || 30 | (it.sourceNodeId == target.id && it.targetNodeId == source.id) 31 | } 32 | 33 | if (existingConnection != null) { 34 | // Remove existing connection 35 | canvasPanel.canvasState.removeConnection(existingConnection.id) 36 | } else { 37 | // Create new connection 38 | val connection = NodeConnection(source.id, target.id) 39 | canvasPanel.canvasState.addConnection(connection) 40 | } 41 | 42 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasPanel.canvasState) 43 | canvasPanel.repaint() 44 | } 45 | 46 | /** 47 | * Draws a connection between two nodes 48 | */ 49 | fun drawConnection(g2d: Graphics2D, source: NodeComponent, target: NodeComponent, color: Color) { 50 | // Calculate center points 51 | val startPoint = Point( 52 | source.x + source.width / 2, 53 | source.y + source.height / 2 54 | ) 55 | 56 | val endPoint = Point( 57 | target.x + target.width / 2, 58 | target.y + target.height / 2 59 | ) 60 | 61 | // Draw the line 62 | g2d.color = color 63 | g2d.stroke = BasicStroke(2.0f) 64 | g2d.drawLine(startPoint.x, startPoint.y, endPoint.x, endPoint.y) 65 | 66 | // Draw the arrowhead 67 | drawArrowHead(g2d, startPoint, endPoint) 68 | } 69 | 70 | /** 71 | * Draws a temporary connection during connection creation 72 | */ 73 | fun drawTemporaryConnection(g2d: Graphics2D, startNode: NodeComponent, endPoint: Point) { 74 | val startPoint = Point( 75 | startNode.x + startNode.width / 2, 76 | startNode.y + startNode.height / 2 77 | ) 78 | g2d.color = CanvasColors.CONNECTION_COLOR 79 | g2d.stroke = BasicStroke( 80 | 2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 81 | 0f, floatArrayOf(5f), 0f 82 | ) 83 | g2d.drawLine(startPoint.x, startPoint.y, endPoint.x, endPoint.y) 84 | } 85 | 86 | /** 87 | * Draws an arrow head at the end of a connection 88 | */ 89 | private fun drawArrowHead(g2d: Graphics2D, start: Point, end: Point) { 90 | // Calculate arrowhead 91 | val dx = end.x - start.x 92 | val dy = end.y - start.y 93 | val length = Math.sqrt((dx * dx + dy * dy).toDouble()) 94 | val dirX = dx / length 95 | val dirY = dy / length 96 | 97 | val arrowSize = 10.0 98 | val arrowAngle = Math.PI / 6 // 30 degrees 99 | 100 | val p1 = Point2D.Double( 101 | end.x - arrowSize * (dirX * Math.cos(arrowAngle) + dirY * Math.sin(arrowAngle)), 102 | end.y - arrowSize * (dirY * Math.cos(arrowAngle) - dirX * Math.sin(arrowAngle)) 103 | ) 104 | 105 | val p2 = Point2D.Double( 106 | end.x - arrowSize * (dirX * Math.cos(arrowAngle) - dirY * Math.sin(arrowAngle)), 107 | end.y - arrowSize * (dirY * Math.cos(arrowAngle) + dirX * Math.sin(arrowAngle)) 108 | ) 109 | 110 | // Draw arrowhead 111 | g2d.draw(Line2D.Double(end.x.toDouble(), end.y.toDouble(), p1.x, p1.y)) 112 | g2d.draw(Line2D.Double(end.x.toDouble(), end.y.toDouble(), p2.x, p2.y)) 113 | } 114 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasConstants.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | /** 4 | * Centralized constants used throughout the Bookmark Canvas UI 5 | */ 6 | object CanvasConstants { 7 | // Node component constants 8 | const val RESIZE_HANDLE_SIZE = 10 9 | const val TITLE_PADDING = 8 // Padding for title panel (kept the same) 10 | const val CONTENT_PADDING = 6 // Reduced padding for code snippets (was 12) 11 | 12 | // Font sizes 13 | const val BASE_TITLE_FONT_SIZE = 12 14 | const val BASE_CODE_FONT_SIZE = 12 15 | const val MIN_VISIBLE_TITLE_SIZE = 6 16 | const val MIN_VISIBLE_CODE_SIZE = 6 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasContextMenuManager.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.openapi.project.Project 4 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 5 | import org.mwalker.bookmarkcanvas.services.BookmarkService 6 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 7 | import java.awt.BorderLayout 8 | import java.awt.Component 9 | import java.awt.FlowLayout 10 | import java.awt.Point 11 | import javax.swing.JButton 12 | import javax.swing.JDialog 13 | import javax.swing.JLabel 14 | import javax.swing.JList 15 | import javax.swing.JMenuItem 16 | import javax.swing.JOptionPane 17 | import javax.swing.JPanel 18 | import javax.swing.JPopupMenu 19 | import javax.swing.JScrollPane 20 | import javax.swing.DefaultListCellRenderer 21 | 22 | /** 23 | * Manages context menus on the canvas 24 | */ 25 | class CanvasContextMenuManager( 26 | private val canvasPanel: CanvasPanel, 27 | private val project: Project 28 | ) { 29 | /** 30 | * Shows the canvas context menu at the specified point 31 | */ 32 | fun showCanvasContextMenu(point: Point) { 33 | val menu = JPopupMenu() 34 | 35 | val addBookmarkItem = JMenuItem("Add Bookmark") 36 | addBookmarkItem.addActionListener { 37 | showAddBookmarkDialog(point) 38 | } 39 | menu.add(addBookmarkItem) 40 | 41 | menu.show(canvasPanel, point.x, point.y) 42 | } 43 | 44 | /** 45 | * Shows dialog to select and add a bookmark 46 | */ 47 | fun showAddBookmarkDialog(point: Point) { 48 | // Get all available bookmarks 49 | val allBookmarks = BookmarkService.getAllBookmarkNodes(project) 50 | 51 | if (allBookmarks.isEmpty()) { 52 | JOptionPane.showMessageDialog( 53 | canvasPanel, 54 | "No bookmarks found. Create bookmarks in your editor first.", 55 | "No Bookmarks", 56 | JOptionPane.INFORMATION_MESSAGE 57 | ) 58 | return 59 | } 60 | 61 | // Create dialog for selecting a bookmark 62 | val dialog = JDialog() 63 | dialog.title = "Select Bookmark" 64 | dialog.layout = BorderLayout() 65 | dialog.isModal = true 66 | 67 | // Add escape key handler to close the dialog 68 | dialog.rootPane.registerKeyboardAction( 69 | { dialog.dispose() }, 70 | javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_ESCAPE, 0), 71 | javax.swing.JComponent.WHEN_IN_FOCUSED_WINDOW 72 | ) 73 | 74 | val bookmarkList = JList(allBookmarks.toTypedArray()) 75 | bookmarkList.cellRenderer = object : DefaultListCellRenderer() { 76 | override fun getListCellRendererComponent( 77 | list: JList<*>?, 78 | value: Any?, 79 | index: Int, 80 | isSelected: Boolean, 81 | cellHasFocus: Boolean 82 | ): Component { 83 | val label = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) as JLabel 84 | val bookmark = value as BookmarkNode 85 | label.text = bookmark.getDisplayText() 86 | return label 87 | } 88 | } 89 | 90 | // Add double-click handler to add the selected bookmark 91 | bookmarkList.addMouseListener(object : java.awt.event.MouseAdapter() { 92 | override fun mouseClicked(e: java.awt.event.MouseEvent) { 93 | if (e.clickCount == 2) { 94 | val selectedBookmark = bookmarkList.selectedValue 95 | if (selectedBookmark != null) { 96 | // Create a copy of the bookmark with new position 97 | val nodeCopy = selectedBookmark.copy( 98 | id = "bookmark_" + System.currentTimeMillis(), 99 | positionX = (point.x / canvasPanel.zoomFactor).toInt(), 100 | positionY = (point.y / canvasPanel.zoomFactor).toInt() 101 | ) 102 | 103 | canvasPanel.canvasState.addNode(nodeCopy) 104 | canvasPanel.addNodeComponent(nodeCopy) 105 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasPanel.canvasState) 106 | canvasPanel.repaint() 107 | dialog.dispose() 108 | } 109 | } 110 | } 111 | }) 112 | 113 | val scrollPane = JScrollPane(bookmarkList) 114 | dialog.add(scrollPane, BorderLayout.CENTER) 115 | 116 | val buttonPanel = JPanel() 117 | buttonPanel.layout = FlowLayout(FlowLayout.RIGHT) 118 | val addButton = JButton("Add") 119 | addButton.addActionListener { 120 | val selectedBookmark = bookmarkList.selectedValue 121 | if (selectedBookmark != null) { 122 | // Create a copy of the bookmark with new position 123 | val nodeCopy = selectedBookmark.copy( 124 | id = "bookmark_" + System.currentTimeMillis(), 125 | positionX = (point.x / canvasPanel.zoomFactor).toInt(), 126 | positionY = (point.y / canvasPanel.zoomFactor).toInt() 127 | ) 128 | 129 | canvasPanel.canvasState.addNode(nodeCopy) 130 | canvasPanel.addNodeComponent(nodeCopy) 131 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasPanel.canvasState) 132 | canvasPanel.repaint() 133 | } 134 | dialog.dispose() 135 | } 136 | 137 | val cancelButton = JButton("Cancel") 138 | cancelButton.addActionListener { dialog.dispose() } 139 | 140 | buttonPanel.add(addButton) 141 | buttonPanel.add(cancelButton) 142 | dialog.add(buttonPanel, BorderLayout.SOUTH) 143 | 144 | dialog.pack() 145 | dialog.setSize(400, 300) 146 | dialog.setLocationRelativeTo(canvasPanel) 147 | dialog.isVisible = true 148 | } 149 | 150 | /** 151 | * Adds all bookmarks to the canvas 152 | */ 153 | fun refreshBookmarks() { 154 | // Fetch all bookmarks from the IDE 155 | val bookmarks = BookmarkService.getAllBookmarkNodes(project) 156 | 157 | // For each bookmark not already on canvas, add a new node 158 | var added = 0 159 | for (bookmark in bookmarks) { 160 | // Check if this bookmark is already on the canvas 161 | val existing = canvasPanel.canvasState.nodes.values.find { 162 | it.bookmarkId == bookmark.bookmarkId 163 | } 164 | 165 | if (existing == null) { 166 | // Find free position for new node 167 | val offset = canvasPanel.nodeComponents.size * 30 168 | val newNode = bookmark.copy( 169 | id = "bookmark_" + System.currentTimeMillis(), 170 | positionX = 100 + offset, 171 | positionY = 100 + offset 172 | ) 173 | 174 | canvasPanel.canvasState.addNode(newNode) 175 | canvasPanel.addNodeComponent(newNode) 176 | added++ 177 | } 178 | } 179 | 180 | if (added > 0) { 181 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasPanel.canvasState) 182 | canvasPanel.repaint() 183 | } 184 | } 185 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasInterface.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 4 | 5 | /** 6 | * Common interface for both AWT-based and web-based canvas panels 7 | */ 8 | interface CanvasInterface { 9 | fun addNodeComponent(node: BookmarkNode) 10 | fun clearCanvas() 11 | fun refreshFromState() 12 | fun setSnapToGrid(value: Boolean) 13 | fun setShowGrid(value: Boolean) 14 | fun zoomIn() 15 | fun zoomOut() 16 | fun goHome() 17 | fun undo() 18 | fun redo() 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasNodeManager.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.openapi.diagnostic.Logger 4 | import com.intellij.openapi.project.Project 5 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 6 | import java.awt.Point 7 | 8 | /** 9 | * Manages node components on the canvas 10 | */ 11 | class CanvasNodeManager( 12 | private val canvasPanel: CanvasPanel, 13 | private val project: Project 14 | ) { 15 | private val LOG = Logger.getInstance(CanvasNodeManager::class.java) 16 | 17 | /** 18 | * Adds a node component to the canvas 19 | */ 20 | fun addNodeComponent(node: BookmarkNode) { 21 | val nodeComponent = NodeComponent(node, project) 22 | // Add at index 0 to ensure new nodes appear on top of existing ones 23 | canvasPanel.add(nodeComponent, 0) 24 | 25 | // For new nodes, place at 0,0 or shift existing nodes if needed 26 | if (node.positionX == 100 && node.positionY == 100) { 27 | // Check if any node exists at 0,0 28 | val isOriginOccupied = canvasPanel.nodeComponents.values.any { 29 | val posX = (it.node.positionX / canvasPanel.zoomFactor).toInt() 30 | val posY = (it.node.positionY / canvasPanel.zoomFactor).toInt() 31 | posX == 0 && posY == 0 32 | } 33 | 34 | if (isOriginOccupied) { 35 | // Shift all existing nodes down to make space 36 | for (existingNode in canvasPanel.nodeComponents.values) { 37 | existingNode.node.positionY += 100 38 | val newY = (existingNode.node.positionY * canvasPanel.zoomFactor).toInt() 39 | val newX = (existingNode.node.positionX * canvasPanel.zoomFactor).toInt() 40 | existingNode.setLocation(newX, newY) 41 | } 42 | } 43 | 44 | // Place new node at 0,0 45 | node.positionX = 0 46 | node.positionY = 0 47 | } 48 | 49 | // Apply zoom and snap if necessary 50 | var x = (node.positionX * canvasPanel.zoomFactor).toInt() 51 | var y = (node.positionY * canvasPanel.zoomFactor).toInt() 52 | 53 | // We don't snap when initially adding nodes, only when dragging them 54 | // This ensures toggling snap doesn't change node positions 55 | 56 | val prefSize = nodeComponent.preferredSize 57 | val scaledWidth = (prefSize.width * canvasPanel.zoomFactor).toInt() 58 | val scaledHeight = (prefSize.height * canvasPanel.zoomFactor).toInt() 59 | 60 | nodeComponent.setBounds(x, y, scaledWidth, scaledHeight) 61 | 62 | // Apply font scaling to the new node 63 | nodeComponent.updateFontSizes(canvasPanel.zoomFactor) 64 | 65 | canvasPanel.nodeComponents[node.id] = nodeComponent 66 | 67 | // Clear existing selection and select the newly added node 68 | canvasPanel.selectionManager.clearSelection() 69 | canvasPanel.selectedNodes.add(nodeComponent) 70 | nodeComponent.isSelected = true 71 | nodeComponent.repaint() 72 | 73 | // Ensure canvas state is saved after adding node 74 | canvasPanel.saveState() 75 | } 76 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasPanel.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.openapi.diagnostic.Logger 4 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 5 | import org.mwalker.bookmarkcanvas.model.CanvasState 6 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 7 | import com.intellij.openapi.project.Project 8 | import org.mwalker.bookmarkcanvas.ui.CanvasColors 9 | import java.awt.* 10 | import java.awt.event.* 11 | import javax.swing.* 12 | 13 | /** 14 | * Main canvas panel for displaying and interacting with bookmark nodes 15 | */ 16 | class CanvasPanel(val project: Project) : JPanel(), CanvasInterface { 17 | val canvasState: CanvasState = CanvasPersistenceService.getInstance().getCanvasState(project) 18 | val nodeComponents = mutableMapOf() 19 | val selectedNodes = mutableSetOf() 20 | var connectionStartNode: NodeComponent? = null 21 | var dragStartPoint: Point? = null 22 | var isPanning = false 23 | var isDrawingSelectionBox = false 24 | var selectionStart: Point? = null 25 | var selectionEnd: Point? = null 26 | var tempConnectionEndPoint: Point? = null 27 | var _zoomFactor = 1.0 28 | val zoomFactor: Double get() = _zoomFactor 29 | // Delegate grid properties to canvas state 30 | private var _snapToGrid: Boolean = false 31 | private var _showGrid: Boolean = false 32 | 33 | // Grid properties 34 | val GRID_SIZE = 20 35 | 36 | // Helper managers 37 | private val nodeManager: CanvasNodeManager 38 | val selectionManager: CanvasSelectionManager 39 | private val connectionManager: CanvasConnectionManager 40 | val zoomManager: CanvasZoomManager 41 | private val contextMenuManager: CanvasContextMenuManager 42 | private val eventHandler: CanvasEventHandler 43 | 44 | companion object { 45 | private val LOG = Logger.getInstance(NodeComponent::class.java) 46 | } 47 | 48 | // Cached grid for performance 49 | private var gridCache: Image? = null 50 | private var gridCacheZoom = 0.0 51 | private var gridCacheSize = Dimension(0, 0) 52 | 53 | init { 54 | // Initialize grid settings from canvas state 55 | _snapToGrid = canvasState.snapToGrid 56 | _showGrid = canvasState.showGrid 57 | _zoomFactor = canvasState.zoomFactor 58 | 59 | layout = null // Free positioning 60 | background = CanvasColors.CANVAS_BACKGROUND 61 | 62 | // Initialize managers 63 | nodeManager = CanvasNodeManager(this, project) 64 | selectionManager = CanvasSelectionManager(this) 65 | connectionManager = CanvasConnectionManager(this, project) 66 | zoomManager = CanvasZoomManager(this, nodeComponents) 67 | contextMenuManager = CanvasContextMenuManager(this, project) 68 | eventHandler = CanvasEventHandler(this, project) 69 | 70 | // Set up the canvas 71 | initializeNodes() 72 | eventHandler.setupEventListeners() 73 | 74 | // Don't need a fixed size since we're not using scrollbars anymore 75 | // The canvas will be sized to fit the parent container 76 | } 77 | 78 | private fun initializeNodes() { 79 | canvasState.nodes.values.forEach { node -> 80 | nodeManager.addNodeComponent(node) 81 | } 82 | } 83 | 84 | fun saveState() { 85 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasState) 86 | } 87 | 88 | override fun addNodeComponent(node: BookmarkNode) { 89 | nodeManager.addNodeComponent(node) 90 | } 91 | 92 | override fun refreshFromState() { 93 | // First clear existing node components 94 | removeAll() 95 | nodeComponents.clear() 96 | selectedNodes.clear() 97 | connectionStartNode = null 98 | tempConnectionEndPoint = null 99 | 100 | // Recreate node components from canvas state 101 | for (node in canvasState.nodes.values) { 102 | nodeManager.addNodeComponent(node) 103 | } 104 | 105 | // Request repaint to reflect all changes 106 | revalidate() 107 | repaint() 108 | } 109 | 110 | fun clearSelection() { 111 | selectionManager.clearSelection() 112 | } 113 | 114 | fun finalizeSelection() { 115 | selectionManager.finalizeSelection() 116 | } 117 | 118 | fun getSelectionRectangle(): Rectangle { 119 | return selectionManager.getSelectionRectangle() 120 | } 121 | 122 | fun createNewConnection(source: BookmarkNode, target: BookmarkNode) { 123 | connectionManager.createNewConnection(source, target) 124 | } 125 | 126 | fun showCanvasContextMenu(point: Point) { 127 | contextMenuManager.showCanvasContextMenu(point) 128 | } 129 | 130 | fun showAddBookmarkDialog(point: Point) { 131 | contextMenuManager.showAddBookmarkDialog(point) 132 | } 133 | 134 | fun refreshBookmarks() { 135 | contextMenuManager.refreshBookmarks() 136 | } 137 | 138 | override fun clearCanvas() { 139 | removeAll() 140 | nodeComponents.clear() 141 | selectedNodes.clear() 142 | canvasState.nodes.clear() 143 | canvasState.connections.clear() 144 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasState) 145 | repaint() 146 | } 147 | 148 | override fun zoomIn() { 149 | zoomManager.zoomIn() 150 | } 151 | 152 | override fun zoomOut() { 153 | zoomManager.zoomOut() 154 | } 155 | 156 | /** 157 | * Zooms by a custom factor (for more precise control with trackpad gestures) 158 | */ 159 | fun zoomBy(factor: Double) { 160 | zoomManager.zoomBy(factor) 161 | } 162 | 163 | fun zoomBy(factor: Double, centerPoint: Point) { 164 | zoomManager.zoomBy(factor, centerPoint) 165 | } 166 | 167 | fun updateCanvasSize() { 168 | zoomManager.updateCanvasSize() 169 | } 170 | 171 | fun goToTopLeftNode() { 172 | zoomManager.goToTopLeftNode() 173 | } 174 | 175 | override fun goHome() { 176 | goToTopLeftNode() 177 | } 178 | 179 | override fun undo() { 180 | if (canvasState.undo()) { 181 | refreshFromState() 182 | } 183 | } 184 | 185 | override fun redo() { 186 | if (canvasState.redo()) { 187 | refreshFromState() 188 | } 189 | } 190 | 191 | // Property accessors 192 | val snapToGrid: Boolean 193 | get() = _snapToGrid 194 | 195 | val showGrid: Boolean 196 | get() = _showGrid 197 | 198 | override fun setSnapToGrid(value: Boolean) { 199 | _snapToGrid = value 200 | _showGrid = value // Show grid when snap is enabled 201 | 202 | // Update canvas state 203 | canvasState.setGridPreferences(_snapToGrid, _showGrid) 204 | 205 | // Persist the state 206 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasState) 207 | 208 | // Invalidate grid cache 209 | invalidateGridCache() 210 | } 211 | 212 | override fun setShowGrid(value: Boolean) { 213 | _showGrid = value 214 | 215 | // Update canvas state 216 | canvasState.setGridPreferences(_snapToGrid, _showGrid) 217 | 218 | // Persist the state 219 | CanvasPersistenceService.getInstance().saveCanvasState(project, canvasState) 220 | 221 | // Invalidate grid cache 222 | invalidateGridCache() 223 | } 224 | 225 | override fun paintComponent(g: Graphics) { 226 | super.paintComponent(g) 227 | val g2d = g.create() as Graphics2D 228 | 229 | // Set rendering hints for smoother lines 230 | g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) 231 | 232 | // Draw grid if enabled using cached grid 233 | if (showGrid) { 234 | // Check if we need to refresh the grid cache 235 | if (needToRegenerateGridCache()) { 236 | generateGridCache() 237 | } 238 | // Draw cached grid 239 | gridCache?.let { g2d.drawImage(it, 0, 0, null) } 240 | } 241 | 242 | // Draw connections between nodes 243 | for (connection in canvasState.connections) { 244 | val source = nodeComponents[connection.sourceNodeId] 245 | val target = nodeComponents[connection.targetNodeId] 246 | 247 | if (source != null && target != null) { 248 | connectionManager.drawConnection(g2d, source, target, connection.color) 249 | } 250 | } 251 | 252 | // Draw temporary connection if creating one 253 | if (connectionStartNode != null && tempConnectionEndPoint != null) { 254 | connectionManager.drawTemporaryConnection(g2d, connectionStartNode!!, tempConnectionEndPoint!!) 255 | } 256 | 257 | g2d.dispose() 258 | } 259 | 260 | /** 261 | * Check if we need to regenerate the grid cache 262 | */ 263 | private fun needToRegenerateGridCache(): Boolean { 264 | // Only check if grid is being shown 265 | if (!showGrid) return false 266 | 267 | // If no cache exists, we need to generate it 268 | if (gridCache == null) return true 269 | 270 | // If zoom changed, we need to regenerate 271 | if (gridCacheZoom != zoomFactor) return true 272 | 273 | // If panel size increased beyond cache size, regenerate 274 | if (gridCacheSize.width < width || gridCacheSize.height < height) return true 275 | 276 | return false 277 | } 278 | 279 | /** 280 | * Generate the grid cache image for better performance 281 | */ 282 | private fun generateGridCache() { 283 | val scaledGridSize = (GRID_SIZE * zoomFactor).toInt() 284 | 285 | // Create image with some padding for scrolling 286 | val cacheWidth = width + scaledGridSize 287 | val cacheHeight = height + scaledGridSize 288 | 289 | // Create the cache image at the current size 290 | val cache = createImage(cacheWidth, cacheHeight) 291 | val g2d = cache.graphics as Graphics2D 292 | 293 | // Fill background with explicit grid background color 294 | g2d.color = CanvasColors.GRID_BACKGROUND 295 | g2d.fillRect(0, 0, cacheWidth, cacheHeight) 296 | 297 | // Draw the grid lines 298 | g2d.color = CanvasColors.GRID_COLOR 299 | 300 | // Draw vertical lines 301 | var x = 0 302 | while (x < cacheWidth) { 303 | g2d.drawLine(x, 0, x, cacheHeight) 304 | x += scaledGridSize 305 | } 306 | 307 | // Draw horizontal lines 308 | var y = 0 309 | while (y < cacheHeight) { 310 | g2d.drawLine(0, y, cacheWidth, y) 311 | y += scaledGridSize 312 | } 313 | 314 | g2d.dispose() 315 | 316 | // Store the cache and its properties 317 | gridCache = cache 318 | gridCacheZoom = zoomFactor 319 | gridCacheSize = Dimension(cacheWidth, cacheHeight) 320 | } 321 | 322 | /** 323 | * Force regenerate grid cache, e.g. when theme changes 324 | */ 325 | fun invalidateGridCache() { 326 | gridCache = null 327 | repaint() 328 | } 329 | 330 | // Paint the selection box in the glass pane layer to ensure it's on top 331 | override fun paint(g: Graphics) { 332 | super.paint(g) 333 | 334 | // After painting everything else, draw the selection box on top if active 335 | if (isDrawingSelectionBox && selectionStart != null && selectionEnd != null) { 336 | selectionManager.drawSelectionBox(g) 337 | } 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasSelectionManager.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import java.awt.BasicStroke 4 | import java.awt.Graphics 5 | import java.awt.Graphics2D 6 | import java.awt.Rectangle 7 | import java.awt.RenderingHints 8 | import org.mwalker.bookmarkcanvas.ui.CanvasColors 9 | 10 | /** 11 | * Manages selection behavior on the canvas 12 | */ 13 | class CanvasSelectionManager( 14 | private val canvasPanel: CanvasPanel 15 | ) { 16 | /** 17 | * Clears the current selection 18 | */ 19 | fun clearSelection() { 20 | for (nodeComp in canvasPanel.selectedNodes) { 21 | nodeComp.isSelected = false 22 | nodeComp.repaint() 23 | } 24 | canvasPanel.selectedNodes.clear() 25 | } 26 | 27 | /** 28 | * Finalizes the selection process after drawing a selection box 29 | */ 30 | fun finalizeSelection() { 31 | if (canvasPanel.selectionStart == null || canvasPanel.selectionEnd == null) return 32 | 33 | val rect = getSelectionRectangle() 34 | val previouslySelectedCount = canvasPanel.selectedNodes.size 35 | val initiallySelected = HashSet(canvasPanel.selectedNodes) 36 | 37 | // Select all nodes that intersect with the selection rectangle 38 | for (nodeComp in canvasPanel.nodeComponents.values) { 39 | val nodeBounds = Rectangle(nodeComp.x, nodeComp.y, nodeComp.width, nodeComp.height) 40 | 41 | if (rect.intersects(nodeBounds)) { 42 | canvasPanel.selectedNodes.add(nodeComp) 43 | nodeComp.isSelected = true 44 | } 45 | } 46 | 47 | // If selection changed from single to multi or vice versa, repaint all selected nodes 48 | if ((previouslySelectedCount == 1 && canvasPanel.selectedNodes.size > 1) || 49 | (previouslySelectedCount > 1 && canvasPanel.selectedNodes.size == 1)) { 50 | for (nodeComp in canvasPanel.selectedNodes) { 51 | nodeComp.repaint() 52 | } 53 | } else { 54 | // Otherwise, just repaint newly selected nodes 55 | for (nodeComp in canvasPanel.selectedNodes) { 56 | if (!initiallySelected.contains(nodeComp)) { 57 | nodeComp.repaint() 58 | } 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * Gets the rectangle representing the current selection area 65 | */ 66 | fun getSelectionRectangle(): Rectangle { 67 | val x = Math.min(canvasPanel.selectionStart!!.x, canvasPanel.selectionEnd!!.x) 68 | val y = Math.min(canvasPanel.selectionStart!!.y, canvasPanel.selectionEnd!!.y) 69 | val width = Math.abs(canvasPanel.selectionEnd!!.x - canvasPanel.selectionStart!!.x) 70 | val height = Math.abs(canvasPanel.selectionEnd!!.y - canvasPanel.selectionStart!!.y) 71 | 72 | return Rectangle(x, y, width, height) 73 | } 74 | 75 | /** 76 | * Draws the selection box 77 | */ 78 | fun drawSelectionBox(g: Graphics) { 79 | val g2d = g.create() as Graphics2D 80 | g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) 81 | 82 | val rect = getSelectionRectangle() 83 | 84 | // Fill with semi-transparent color 85 | g2d.color = CanvasColors.SELECTION_BOX_COLOR 86 | g2d.fill(rect) 87 | 88 | // Draw border 89 | g2d.color = CanvasColors.SELECTION_BOX_BORDER_COLOR 90 | g2d.stroke = BasicStroke(1.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) 91 | g2d.draw(rect) 92 | 93 | g2d.dispose() 94 | } 95 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasToolWindow.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.openapi.project.Project 4 | import com.intellij.openapi.wm.ToolWindow 5 | import com.intellij.openapi.wm.ToolWindowFactory 6 | import com.intellij.ui.content.ContentFactory 7 | import org.jetbrains.annotations.NotNull 8 | 9 | class CanvasToolWindow : ToolWindowFactory { 10 | override fun createToolWindowContent(@NotNull project: Project, @NotNull toolWindow: ToolWindow) { 11 | // Create the toolbar panel instead of just the canvas 12 | val canvasToolbar = org.mwalker.bookmarkcanvas.ui.CanvasToolbar(project) 13 | 14 | val content = ContentFactory.SERVICE.getInstance().createContent( 15 | canvasToolbar, "", false) 16 | toolWindow.contentManager.addContent(content) 17 | } 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasToolbar.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import org.mwalker.bookmarkcanvas.actions.* 4 | import com.intellij.openapi.actionSystem.ActionManager 5 | import com.intellij.openapi.actionSystem.DefaultActionGroup 6 | import com.intellij.openapi.project.Project 7 | import com.intellij.openapi.ui.SimpleToolWindowPanel 8 | import java.awt.Dimension 9 | 10 | class CanvasToolbar(private val project: Project) : SimpleToolWindowPanel(true, true) { 11 | val canvasPanel: org.mwalker.bookmarkcanvas.ui.CanvasPanel 12 | 13 | /** 14 | * Finds a NodeComponent by BookmarkNode id 15 | */ 16 | fun findNodeComponent(nodeId: String): NodeComponent? { 17 | return canvasPanel.nodeComponents[nodeId] 18 | } 19 | 20 | init { 21 | // Create the canvas panel 22 | canvasPanel = org.mwalker.bookmarkcanvas.ui.CanvasPanel(project) 23 | 24 | // No scroll pane - relying on canvas panning and zooming 25 | 26 | // Create toolbar actions 27 | val actionGroup = DefaultActionGroup("CANVAS_TOOLBAR", false) 28 | // Add editing actions 29 | actionGroup.add(UndoAction(canvasPanel)) 30 | actionGroup.add(RedoAction(canvasPanel)) 31 | 32 | // Add separator 33 | actionGroup.addSeparator() 34 | 35 | // Add other actions 36 | actionGroup.add(ToggleSnapToGridAction(canvasPanel)) 37 | actionGroup.add(ToggleShowGridAction(canvasPanel)) 38 | actionGroup.add(ExportCanvasAction(project, canvasPanel)) 39 | 40 | // Add separator for bookmark management 41 | actionGroup.addSeparator() 42 | 43 | // Add refresh and verify actions 44 | // actionGroup.add(RefreshBookmarksAction(project, canvasPanel)) 45 | actionGroup.add(org.mwalker.bookmarkcanvas.actions.VerifyBookmarksAction(project, canvasPanel)) 46 | 47 | // Add separator for zoom and navigation 48 | actionGroup.addSeparator() 49 | 50 | actionGroup.add(org.mwalker.bookmarkcanvas.actions.HomeAction(canvasPanel)) 51 | actionGroup.add(org.mwalker.bookmarkcanvas.actions.ZoomInAction(canvasPanel)) 52 | actionGroup.add(ZoomOutAction(canvasPanel)) 53 | 54 | val actionToolbar = ActionManager.getInstance() 55 | .createActionToolbar("BookmarkCanvasToolbar", actionGroup, true) 56 | 57 | // Set the target component to fix the warning 58 | actionToolbar.setTargetComponent(canvasPanel) 59 | 60 | // Set the toolbar and content 61 | toolbar = actionToolbar.component 62 | setContent(canvasPanel) 63 | // setContent(KotlinSnippetHighlighter(project)) 64 | } 65 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/CanvasZoomManager.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.openapi.diagnostic.Logger 4 | import java.awt.Dimension 5 | import java.awt.Point 6 | 7 | /** 8 | * Manages zoom and navigation on the canvas 9 | */ 10 | class CanvasZoomManager( 11 | private val canvasPanel: CanvasPanel, 12 | private val nodeComponents: MutableMap 13 | ) { 14 | private val LOG = Logger.getInstance(CanvasZoomManager::class.java) 15 | 16 | /** 17 | * Increases the zoom level 18 | */ 19 | fun zoomIn() { 20 | zoomBy(1.2) 21 | } 22 | 23 | /** 24 | * Decreases the zoom level 25 | */ 26 | fun zoomOut() { 27 | zoomBy(1.0 / 1.2) 28 | } 29 | 30 | /** 31 | * Increases the zoom level centered around a specific point 32 | */ 33 | fun zoomIn(centerPoint: Point) { 34 | zoomBy(1.2, centerPoint) 35 | } 36 | 37 | /** 38 | * Decreases the zoom level centered around a specific point 39 | */ 40 | fun zoomOut(centerPoint: Point) { 41 | zoomBy(1.0 / 1.2, centerPoint) 42 | } 43 | 44 | /** 45 | * Zoom by a specific factor (allows for more precise control with trackpad) 46 | */ 47 | fun zoomBy(factor: Double) { 48 | zoomBy(factor, null) 49 | } 50 | 51 | /** 52 | * Zoom by a specific factor centered around a specific point 53 | */ 54 | fun zoomBy(factor: Double, centerPoint: Point?) { 55 | val oldZoomFactor = canvasPanel._zoomFactor 56 | 57 | // Apply the zoom factor 58 | canvasPanel._zoomFactor *= factor 59 | 60 | // Enforce min/max zoom limits 61 | if (canvasPanel._zoomFactor < 0.1) canvasPanel._zoomFactor = 0.1 62 | if (canvasPanel._zoomFactor > 10.0) canvasPanel._zoomFactor = 10.0 63 | 64 | // Calculate the actual zoom change that was applied (considering limits) 65 | val actualZoomChange = canvasPanel._zoomFactor / oldZoomFactor 66 | 67 | // Update canvas state 68 | canvasPanel.canvasState.zoomFactor = canvasPanel._zoomFactor 69 | 70 | // If we have a center point, adjust the canvas offset to keep that point fixed 71 | if (centerPoint != null && actualZoomChange != 1.0) { 72 | // Calculate the offset needed to keep the center point visually fixed 73 | val offsetDeltaX = centerPoint.x * (1.0 - actualZoomChange) 74 | val offsetDeltaY = centerPoint.y * (1.0 - actualZoomChange) 75 | 76 | // Apply offset to all nodes to simulate the canvas being repositioned 77 | for (nodeComp in nodeComponents.values) { 78 | // Adjust the logical position by the offset (in logical coordinates) 79 | val logicalOffsetX = (offsetDeltaX / canvasPanel._zoomFactor).toInt() 80 | val logicalOffsetY = (offsetDeltaY / canvasPanel._zoomFactor).toInt() 81 | 82 | nodeComp.node.position = Point( 83 | nodeComp.node.position.x + logicalOffsetX, 84 | nodeComp.node.position.y + logicalOffsetY 85 | ) 86 | } 87 | } 88 | 89 | // Update component positions and sizes 90 | updateCanvasSize() 91 | 92 | // Explicitly repaint all components to ensure text visibility 93 | for (nodeComp in nodeComponents.values) { 94 | nodeComp.revalidate() 95 | nodeComp.repaint() 96 | } 97 | 98 | // Force grid cache update since zoom level changed 99 | canvasPanel.invalidateGridCache() 100 | 101 | // Save view state 102 | saveViewState() 103 | canvasPanel.repaint() 104 | } 105 | 106 | /** 107 | * Saves the current view state (zoom) to the canvas state 108 | */ 109 | private fun saveViewState() { 110 | // Since we no longer use scrollbars, we just need to save the zoom factor 111 | 112 | // Save state using persistence service 113 | org.mwalker.bookmarkcanvas.services.CanvasPersistenceService.getInstance() 114 | .saveCanvasState(canvasPanel.project, canvasPanel.canvasState) 115 | } 116 | 117 | /** 118 | * Updates the canvas size and scales all components 119 | */ 120 | fun updateCanvasSize() { 121 | // Adjust size to fit parent container 122 | canvasPanel.parent?.let { parent -> 123 | canvasPanel.preferredSize = parent.size 124 | } 125 | 126 | // Update the scale for all components 127 | for (nodeComp in nodeComponents.values) { 128 | val originalPos = nodeComp.node.position 129 | val scaledX = (originalPos.x * canvasPanel.zoomFactor).toInt() 130 | val scaledY = (originalPos.y * canvasPanel.zoomFactor).toInt() 131 | nodeComp.setLocation(scaledX, scaledY) 132 | 133 | // Scale the size based on the node's persisted size or preferred size 134 | if (nodeComp.node.width > 0 && nodeComp.node.height > 0) { 135 | // Use persisted size scaled to current zoom 136 | val scaledWidth = (nodeComp.node.width * canvasPanel.zoomFactor).toInt() 137 | val scaledHeight = (nodeComp.node.height * canvasPanel.zoomFactor).toInt() 138 | nodeComp.setSize(scaledWidth, scaledHeight) 139 | nodeComp.preferredSize = Dimension(scaledWidth, scaledHeight) 140 | } else { 141 | // Fall back to preferred size 142 | val prefSize = nodeComp.preferredSize 143 | val scaledWidth = (prefSize.width * canvasPanel.zoomFactor).toInt() 144 | val scaledHeight = (prefSize.height * canvasPanel.zoomFactor).toInt() 145 | nodeComp.setSize(scaledWidth, scaledHeight) 146 | } 147 | 148 | // Update font sizes based on zoom factor 149 | nodeComp.updateFontSizes(canvasPanel.zoomFactor) 150 | } 151 | 152 | canvasPanel.revalidate() 153 | } 154 | 155 | /** 156 | * Positions the canvas to show the top-left node 157 | */ 158 | fun goToTopLeftNode() { 159 | // Find the top-left most node (minimum x and y position) 160 | if (canvasPanel.canvasState.nodes.isEmpty()) return 161 | 162 | val minX = canvasPanel.canvasState.nodes.values.minOfOrNull { 163 | it.positionX 164 | } ?: return 165 | 166 | val minY = canvasPanel.canvasState.nodes.values.minOfOrNull { 167 | it.positionY 168 | } ?: return 169 | 170 | for (nodeComp in nodeComponents.values) { 171 | val x = nodeComp.node.positionX - minX 172 | val y = nodeComp.node.positionY - minY 173 | nodeComp.setLocation(x, y) 174 | nodeComp.node.positionX = x 175 | nodeComp.node.positionY = y 176 | } 177 | 178 | // Then reset the zoom factor 179 | canvasPanel._zoomFactor = 0.8 180 | canvasPanel.canvasState.zoomFactor = canvasPanel._zoomFactor 181 | updateCanvasSize() 182 | canvasPanel.invalidateGridCache() // Force grid cache update 183 | canvasPanel.repaint() 184 | 185 | // Save the state 186 | org.mwalker.bookmarkcanvas.services.CanvasPersistenceService.getInstance() 187 | .saveCanvasState(canvasPanel.project, canvasPanel.canvasState) 188 | 189 | LOG.info("Reset view position and saved state") 190 | } 191 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/EditSourceDialog.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.openapi.ui.DialogWrapper 4 | import com.intellij.openapi.project.Project 5 | import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory 6 | import com.intellij.openapi.ui.TextFieldWithBrowseButton 7 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 8 | import java.awt.GridBagConstraints 9 | import java.awt.GridBagLayout 10 | import java.awt.Insets 11 | import javax.swing.* 12 | 13 | /** 14 | * Dialog for editing the source information of a BookmarkNode 15 | */ 16 | class EditSourceDialog( 17 | private val project: Project, 18 | private val node: BookmarkNode 19 | ) : DialogWrapper(project) { 20 | 21 | private val filePathField = TextFieldWithBrowseButton().apply { 22 | text = node.filePath 23 | addBrowseFolderListener( 24 | "Select File", 25 | "Choose the source file", 26 | project, 27 | FileChooserDescriptorFactory.createSingleFileDescriptor() 28 | ) 29 | } 30 | private val lineNumberField = JTextField((node.lineNumber0Based + 1).toString(), 10) 31 | private val contextBeforeField = JTextField(node.contextLinesBefore.toString(), 5) 32 | private val contextAfterField = JTextField(node.contextLinesAfter.toString(), 5) 33 | 34 | init { 35 | title = "Edit Source Information" 36 | init() 37 | } 38 | 39 | override fun createCenterPanel(): JComponent { 40 | val panel = JPanel(GridBagLayout()) 41 | val gbc = GridBagConstraints() 42 | 43 | // File path 44 | gbc.gridx = 0 45 | gbc.gridy = 0 46 | gbc.anchor = GridBagConstraints.WEST 47 | gbc.insets = Insets(5, 5, 5, 5) 48 | panel.add(JLabel("File Path:"), gbc) 49 | 50 | gbc.gridx = 1 51 | gbc.fill = GridBagConstraints.HORIZONTAL 52 | gbc.weightx = 1.0 53 | panel.add(filePathField, gbc) 54 | 55 | // Line number 56 | gbc.gridx = 0 57 | gbc.gridy = 1 58 | gbc.fill = GridBagConstraints.NONE 59 | gbc.weightx = 0.0 60 | panel.add(JLabel("Line Number:"), gbc) 61 | 62 | gbc.gridx = 1 63 | gbc.fill = GridBagConstraints.HORIZONTAL 64 | gbc.weightx = 1.0 65 | panel.add(lineNumberField, gbc) 66 | 67 | // Context lines before 68 | gbc.gridx = 0 69 | gbc.gridy = 2 70 | gbc.fill = GridBagConstraints.NONE 71 | gbc.weightx = 0.0 72 | panel.add(JLabel("Context Lines Before:"), gbc) 73 | 74 | gbc.gridx = 1 75 | gbc.fill = GridBagConstraints.HORIZONTAL 76 | gbc.weightx = 1.0 77 | panel.add(contextBeforeField, gbc) 78 | 79 | // Context lines after 80 | gbc.gridx = 0 81 | gbc.gridy = 3 82 | gbc.fill = GridBagConstraints.NONE 83 | gbc.weightx = 0.0 84 | panel.add(JLabel("Context Lines After:"), gbc) 85 | 86 | gbc.gridx = 1 87 | gbc.fill = GridBagConstraints.HORIZONTAL 88 | gbc.weightx = 1.0 89 | panel.add(contextAfterField, gbc) 90 | 91 | return panel 92 | } 93 | 94 | override fun doOKAction() { 95 | if (validateInput()) { 96 | applyChanges() 97 | super.doOKAction() 98 | } 99 | } 100 | 101 | private fun validateInput(): Boolean { 102 | // Validate line number 103 | try { 104 | val lineNumber = lineNumberField.text.toInt() 105 | if (lineNumber < 1) { 106 | JOptionPane.showMessageDialog( 107 | contentPanel, 108 | "Line number must be 1 or greater", 109 | "Invalid Input", 110 | JOptionPane.ERROR_MESSAGE 111 | ) 112 | return false 113 | } 114 | } catch (e: NumberFormatException) { 115 | JOptionPane.showMessageDialog( 116 | contentPanel, 117 | "Line number must be a valid number", 118 | "Invalid Input", 119 | JOptionPane.ERROR_MESSAGE 120 | ) 121 | return false 122 | } 123 | 124 | // Validate context lines 125 | try { 126 | val contextBefore = contextBeforeField.text.toInt() 127 | val contextAfter = contextAfterField.text.toInt() 128 | if (contextBefore < 0 || contextAfter < 0) { 129 | JOptionPane.showMessageDialog( 130 | contentPanel, 131 | "Context lines must be 0 or greater", 132 | "Invalid Input", 133 | JOptionPane.ERROR_MESSAGE 134 | ) 135 | return false 136 | } 137 | } catch (e: NumberFormatException) { 138 | JOptionPane.showMessageDialog( 139 | contentPanel, 140 | "Context lines must be valid numbers", 141 | "Invalid Input", 142 | JOptionPane.ERROR_MESSAGE 143 | ) 144 | return false 145 | } 146 | 147 | // Validate file path (basic check) 148 | if (filePathField.text.isBlank()) { 149 | JOptionPane.showMessageDialog( 150 | contentPanel, 151 | "File path cannot be empty", 152 | "Invalid Input", 153 | JOptionPane.ERROR_MESSAGE 154 | ) 155 | return false 156 | } 157 | 158 | return true 159 | } 160 | 161 | private fun applyChanges() { 162 | node.filePath = filePathField.text.trim() 163 | node.lineNumber0Based = lineNumberField.text.toInt() - 1 164 | node.contextLinesBefore = contextBeforeField.text.toInt() 165 | node.contextLinesAfter = contextAfterField.text.toInt() 166 | 167 | // Refresh content to reflect any changes 168 | node.refreshContent(project) 169 | } 170 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/NodeContextMenuManager.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import org.mwalker.bookmarkcanvas.model.BookmarkNode 4 | import org.mwalker.bookmarkcanvas.services.CanvasPersistenceService 5 | import com.intellij.openapi.diagnostic.Logger 6 | import com.intellij.openapi.project.Project 7 | import java.awt.BorderLayout 8 | import java.awt.Dimension 9 | import javax.swing.JMenu 10 | import javax.swing.JMenuItem 11 | import javax.swing.JOptionPane 12 | import javax.swing.JPopupMenu 13 | 14 | /** 15 | * Manages context menu creation and handling for NodeComponent 16 | */ 17 | class NodeContextMenuManager( 18 | private val nodeComponent: NodeComponent, 19 | private val node: BookmarkNode, 20 | private val project: Project, 21 | private val titlePanel: javax.swing.JPanel 22 | ) { 23 | private val LOG = Logger.getInstance(NodeContextMenuManager::class.java) 24 | private var menu: JPopupMenu? = null 25 | 26 | /** 27 | * Creates and configures the context menu 28 | */ 29 | fun createContextMenu(): JPopupMenu { 30 | val menu = JPopupMenu() 31 | 32 | // Navigate option 33 | val navigateItem = JMenuItem("Navigate to Bookmark") 34 | navigateItem.addActionListener { 35 | node.navigateToBookmark(project) 36 | } 37 | menu.add(navigateItem) 38 | 39 | // Edit title option 40 | val editTitleItem = JMenuItem("Edit Title") 41 | editTitleItem.addActionListener { 42 | showEditTitleDialog() 43 | } 44 | menu.add(editTitleItem) 45 | 46 | // Edit source option 47 | val editSourceItem = JMenuItem("Edit Source") 48 | editSourceItem.addActionListener { 49 | showEditSourceDialog() 50 | } 51 | menu.add(editSourceItem) 52 | 53 | // Toggle code snippet option 54 | val toggleSnippetItem = JMenuItem( 55 | if (node.showCodeSnippet) "Hide Code Snippet" else "Show Code Snippet" 56 | ) 57 | toggleSnippetItem.addActionListener { 58 | toggleCodeSnippet() 59 | // Update the menu item text after toggle 60 | toggleSnippetItem.text = if (node.showCodeSnippet) "Hide Code Snippet" else "Show Code Snippet" 61 | } 62 | menu.add(toggleSnippetItem) 63 | 64 | // Remove option 65 | val removeItem = JMenuItem("Remove from Canvas") 66 | removeItem.addActionListener { 67 | removeFromCanvas() 68 | } 69 | menu.add(removeItem) 70 | 71 | // Context lines submenu 72 | val contextLinesMenu = JMenu("Context Lines") 73 | val setContextLinesItem = JMenuItem("Set Context Lines...") 74 | setContextLinesItem.addActionListener { 75 | showContextLinesDialog() 76 | } 77 | contextLinesMenu.add(setContextLinesItem) 78 | menu.add(contextLinesMenu) 79 | 80 | // Connection creation option 81 | val createConnectionItem = JMenuItem("Create Connection From This Node") 82 | createConnectionItem.addActionListener { 83 | startConnection() 84 | } 85 | menu.add(createConnectionItem) 86 | 87 | menu.addSeparator() 88 | 89 | // Z-order options 90 | val sendToFrontItem = JMenuItem("Send to Front") 91 | sendToFrontItem.addActionListener { 92 | sendToFront() 93 | } 94 | menu.add(sendToFrontItem) 95 | 96 | val sendToBackItem = JMenuItem("Send to Back") 97 | sendToBackItem.addActionListener { 98 | sendToBack() 99 | } 100 | menu.add(sendToBackItem) 101 | 102 | this.menu = menu 103 | return menu 104 | } 105 | 106 | /** 107 | * Shows the context menu at the specified coordinates 108 | */ 109 | fun showContextMenu(x: Int, y: Int) { 110 | menu?.show(nodeComponent, x, y) 111 | } 112 | 113 | /** 114 | * @return The created popup menu 115 | */ 116 | fun getMenu(): JPopupMenu? = menu 117 | 118 | /** 119 | * Shows a dialog to edit the node title 120 | */ 121 | private fun showEditTitleDialog() { 122 | val input = JOptionPane.showInputDialog( 123 | nodeComponent, 124 | "Enter new title:", 125 | node.getDisplayText() 126 | ) 127 | 128 | if (!input.isNullOrEmpty()) { 129 | node.displayName = input 130 | 131 | // Signal to update title text 132 | (nodeComponent as? NodeComponentInternal)?.updateTitle(input) 133 | 134 | // Save changes 135 | saveCanvasState() 136 | } 137 | } 138 | 139 | /** 140 | * Shows a dialog to edit the source information 141 | */ 142 | private fun showEditSourceDialog() { 143 | val dialog = EditSourceDialog(project, node) 144 | if (dialog.showAndGet()) { 145 | // Invalidate cached code snippet to ensure fresh content is displayed 146 | NodeUIManager.invalidateSnippetCache(node.id) 147 | 148 | // Refresh the node component layout to reflect changes 149 | (nodeComponent as? NodeComponentInternal)?.refreshLayout() 150 | 151 | // Update title if using default name (no custom displayName) 152 | if (node.displayName == null) { 153 | (nodeComponent as? NodeComponentInternal)?.updateTitle(node.getDisplayText()) 154 | } 155 | 156 | // Save changes 157 | saveCanvasState() 158 | } 159 | } 160 | 161 | /** 162 | * Toggles the display of code snippet 163 | */ 164 | private fun toggleCodeSnippet() { 165 | node.showCodeSnippet = !node.showCodeSnippet 166 | 167 | // Refresh content since multi-line content may need to be captured or cleared 168 | node.refreshContent(project) 169 | 170 | // Signal the node component to update its layout 171 | (nodeComponent as? NodeComponentInternal)?.refreshLayout() 172 | 173 | // Save changes 174 | saveCanvasState() 175 | } 176 | 177 | /** 178 | * Removes the node from canvas 179 | */ 180 | private fun removeFromCanvas() { 181 | val canvas = nodeComponent.parent as? CanvasPanel 182 | canvas?.let { 183 | it.canvasState.removeNode(node.id) 184 | it.remove(nodeComponent) 185 | it.repaint() 186 | saveCanvasState() 187 | } 188 | } 189 | 190 | /** 191 | * Shows dialog to configure context lines 192 | */ 193 | private fun showContextLinesDialog() { 194 | val input = JOptionPane.showInputDialog( 195 | nodeComponent, 196 | "Enter number of context lines (before,after):", 197 | "${node.contextLinesBefore},${node.contextLinesAfter}" 198 | ) 199 | 200 | if (!input.isNullOrEmpty()) { 201 | try { 202 | val parts = input.split(",") 203 | if (parts.size == 2) { 204 | node.contextLinesBefore = parts[0].trim().toInt() 205 | node.contextLinesAfter = parts[1].trim().toInt() 206 | 207 | // Refresh content to capture new context lines 208 | node.refreshContent(project) 209 | 210 | if (node.showCodeSnippet) { 211 | // Signal to refresh the layout with new context lines 212 | (nodeComponent as? NodeComponentInternal)?.refreshLayout() 213 | } 214 | 215 | saveCanvasState() 216 | } 217 | } catch (ex: NumberFormatException) { 218 | JOptionPane.showMessageDialog(nodeComponent, "Invalid input format") 219 | } 220 | } 221 | } 222 | 223 | /** 224 | * Initiates a connection from this node 225 | */ 226 | private fun startConnection() { 227 | val canvas = nodeComponent.parent as? CanvasPanel 228 | canvas?.connectionStartNode = nodeComponent 229 | } 230 | 231 | /** 232 | * Sends the node to the front (top z-order) 233 | */ 234 | private fun sendToFront() { 235 | val canvas = nodeComponent.parent as? CanvasPanel 236 | canvas?.let { 237 | // Remove and re-add at index 0 to bring to front 238 | it.remove(nodeComponent) 239 | it.add(nodeComponent, 0) 240 | it.revalidate() 241 | it.repaint() 242 | } 243 | } 244 | 245 | /** 246 | * Sends the node to the back (bottom z-order) 247 | */ 248 | private fun sendToBack() { 249 | val canvas = nodeComponent.parent as? CanvasPanel 250 | canvas?.let { 251 | // Remove and re-add at the last position to send to back 252 | it.remove(nodeComponent) 253 | it.add(nodeComponent) 254 | it.revalidate() 255 | it.repaint() 256 | } 257 | } 258 | 259 | /** 260 | * Saves the current canvas state 261 | */ 262 | private fun saveCanvasState() { 263 | val canvas = nodeComponent.parent as? CanvasPanel 264 | canvas?.let { 265 | CanvasPersistenceService.getInstance().saveCanvasState(project, it.canvasState) 266 | } 267 | } 268 | 269 | /** 270 | * Interface for internal NodeComponent operations 271 | * needed by the context menu manager 272 | */ 273 | interface NodeComponentInternal { 274 | fun updateTitle(title: String) 275 | fun refreshLayout() 276 | } 277 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/WebCanvasToolbar.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.openapi.project.Project 4 | import com.intellij.ui.components.JBPanel 5 | import java.awt.BorderLayout 6 | import javax.swing.JToolBar 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.actionSystem.DataContext 9 | import com.intellij.openapi.actionSystem.Presentation 10 | 11 | /** 12 | * Web-based canvas toolbar that contains the WebCanvasPanel and toolbar buttons 13 | */ 14 | class WebCanvasToolbar(private val project: Project) : JBPanel(BorderLayout()) { 15 | private val webCanvasPanel: WebCanvasPanel 16 | private val toolbar: JToolBar 17 | 18 | init { 19 | // Create the web canvas panel 20 | webCanvasPanel = WebCanvasPanel(project) 21 | 22 | // Create toolbar with web canvas actions 23 | toolbar = createToolbar() 24 | 25 | // Layout components 26 | add(toolbar, BorderLayout.NORTH) 27 | add(webCanvasPanel, BorderLayout.CENTER) 28 | } 29 | 30 | private fun createToolbar(): JToolBar { 31 | val toolbar = JToolBar() 32 | toolbar.isFloatable = false 33 | 34 | // Add web-compatible action buttons manually 35 | val addButton = toolbar.add(createAction("Add") { 36 | org.mwalker.bookmarkcanvas.actions.AddToCanvasWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 37 | }) 38 | addButton.toolTipText = "Add to Canvas" 39 | 40 | val addFileButton = toolbar.add(createAction("Add File") { 41 | org.mwalker.bookmarkcanvas.actions.AddFileToCanvasWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 42 | }) 43 | addFileButton.toolTipText = "Add File to Canvas" 44 | 45 | toolbar.addSeparator() 46 | 47 | val clearButton = toolbar.add(createAction("Clear") { 48 | org.mwalker.bookmarkcanvas.actions.ClearCanvasWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 49 | }) 50 | clearButton.toolTipText = "Clear Canvas" 51 | 52 | val refreshButton = toolbar.add(createAction("Refresh") { 53 | org.mwalker.bookmarkcanvas.actions.RefreshBookmarksWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 54 | }) 55 | refreshButton.toolTipText = "Refresh Bookmarks" 56 | 57 | toolbar.addSeparator() 58 | 59 | val undoButton = toolbar.add(createAction("Undo") { 60 | org.mwalker.bookmarkcanvas.actions.UndoWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 61 | }) 62 | undoButton.toolTipText = "Undo" 63 | 64 | val redoButton = toolbar.add(createAction("Redo") { 65 | org.mwalker.bookmarkcanvas.actions.RedoWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 66 | }) 67 | redoButton.toolTipText = "Redo" 68 | 69 | toolbar.addSeparator() 70 | 71 | val zoomInButton = toolbar.add(createAction("Zoom In") { 72 | org.mwalker.bookmarkcanvas.actions.ZoomInWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 73 | }) 74 | zoomInButton.toolTipText = "Zoom In" 75 | 76 | val zoomOutButton = toolbar.add(createAction("Zoom Out") { 77 | org.mwalker.bookmarkcanvas.actions.ZoomOutWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 78 | }) 79 | zoomOutButton.toolTipText = "Zoom Out" 80 | 81 | val homeButton = toolbar.add(createAction("Home") { 82 | org.mwalker.bookmarkcanvas.actions.HomeWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 83 | }) 84 | homeButton.toolTipText = "Go Home" 85 | 86 | toolbar.addSeparator() 87 | 88 | val snapGridButton = toolbar.add(createAction("Snap Grid") { 89 | org.mwalker.bookmarkcanvas.actions.ToggleSnapToGridWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 90 | }) 91 | snapGridButton.toolTipText = "Toggle Snap to Grid" 92 | 93 | val showGridButton = toolbar.add(createAction("Show Grid") { 94 | org.mwalker.bookmarkcanvas.actions.ToggleShowGridWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 95 | }) 96 | showGridButton.toolTipText = "Toggle Show Grid" 97 | 98 | toolbar.addSeparator() 99 | 100 | val exportButton = toolbar.add(createAction("Export") { 101 | org.mwalker.bookmarkcanvas.actions.ExportCanvasWebAction(project, webCanvasPanel).actionPerformed(createMockActionEvent()) 102 | }) 103 | exportButton.toolTipText = "Export Canvas" 104 | 105 | return toolbar 106 | } 107 | 108 | private fun createAction(name: String, actionHandler: () -> Unit): javax.swing.Action { 109 | return object : javax.swing.AbstractAction(name) { 110 | override fun actionPerformed(e: java.awt.event.ActionEvent?) { 111 | actionHandler() 112 | } 113 | } 114 | } 115 | 116 | private fun createMockActionEvent(): AnActionEvent { 117 | return AnActionEvent.createFromDataContext( 118 | "MockAction", 119 | Presentation(), 120 | DataContext { dataId -> 121 | when (dataId) { 122 | "project" -> project 123 | else -> null 124 | } 125 | } 126 | ) 127 | } 128 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/WebViewCanvasToolWindow.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.openapi.project.Project 4 | import com.intellij.openapi.wm.ToolWindow 5 | import com.intellij.openapi.wm.ToolWindowFactory 6 | import com.intellij.ui.content.ContentFactory 7 | import com.intellij.ui.components.JBLabel 8 | import com.intellij.ui.components.JBPanel 9 | import org.jetbrains.annotations.NotNull 10 | import java.awt.BorderLayout 11 | import javax.swing.SwingConstants 12 | 13 | class WebViewCanvasToolWindow : ToolWindowFactory { 14 | override fun createToolWindowContent(@NotNull project: Project, @NotNull toolWindow: ToolWindow) { 15 | val contentComponent = try { 16 | // Check if JCEF is available before creating the web canvas 17 | Class.forName("com.intellij.ui.jcef.JBCefApp") 18 | val jcefAppClass = Class.forName("com.intellij.ui.jcef.JBCefApp") 19 | val isSupportedMethod = jcefAppClass.getMethod("isSupported") 20 | val isSupported = isSupportedMethod.invoke(null) as Boolean 21 | 22 | if (isSupported) { 23 | // Create the web-based canvas toolbar 24 | WebCanvasToolbar(project) 25 | } else { 26 | createFallbackPanel("JCEF is not supported in this IntelliJ installation") 27 | } 28 | } catch (e: ClassNotFoundException) { 29 | createFallbackPanel("JCEF is not available in this IntelliJ installation") 30 | } catch (e: Exception) { 31 | createFallbackPanel("Error initializing web canvas: ${e.message}") 32 | } 33 | 34 | val content = ContentFactory.getInstance().createContent( 35 | contentComponent, "", false) 36 | toolWindow.contentManager.addContent(content) 37 | } 38 | 39 | private fun createFallbackPanel(message: String): JBPanel<*> { 40 | val panel = JBPanel>(BorderLayout()) 41 | val label = JBLabel("

Web Canvas Unavailable

$message

Please use the regular Bookmark Canvas instead.

") 42 | label.horizontalAlignment = SwingConstants.CENTER 43 | panel.add(label, BorderLayout.CENTER) 44 | return panel 45 | } 46 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/colors.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.ui.JBColor 4 | import java.awt.Color 5 | 6 | /** 7 | * Centralized color definitions for the Bookmark Canvas 8 | * Updated to match GitHub dark theme style 9 | */ 10 | object CanvasColors { 11 | // Canvas colors 12 | val CANVAS_BACKGROUND = JBColor( 13 | Color(240, 240, 240), // Light mode 14 | Color(13, 17, 23) // Dark mode - GitHub dark: #0d1117 15 | ) 16 | val GRID_BACKGROUND = JBColor( 17 | Color(245, 245, 245), // Light mode 18 | Color(13, 17, 23) // Dark mode - GitHub dark: #0d1117 19 | ) 20 | val GRID_COLOR = JBColor( 21 | Color(210, 210, 210), // Light mode 22 | Color(22, 27, 34) // Dark mode - GitHub dark: #161b22 23 | ) 24 | val SELECTION_BOX_COLOR = JBColor( 25 | Color(100, 150, 255, 50), // Light mode with transparency 26 | Color(80, 120, 200, 50) // Dark mode with transparency 27 | ) 28 | val SELECTION_BOX_BORDER_COLOR = JBColor( 29 | Color(70, 130, 230), // Light mode 30 | Color(100, 150, 230) // Dark mode 31 | ) 32 | 33 | // Node colors 34 | val NODE_BACKGROUND = JBColor( 35 | Color(250, 250, 250), // Light mode 36 | Color(22, 27, 34), // Dark mode - GitHub dark: #161b22 37 | ) 38 | val NODE_TEXT_COLOR = JBColor( 39 | Color(0, 0, 0), // Light mode 40 | Color(201, 209, 217), // Dark mode - GitHub dark: #c9d1d9 41 | ) 42 | val SNIPPET_BACKGROUND = JBColor( 43 | Color(245, 245, 245), // Light mode 44 | Color(22, 27, 34), // Dark mode - GitHub dark: #161b22 45 | ) 46 | val SNIPPET_TEXT_COLOR = JBColor( 47 | Color(20, 20, 20), // Light mode 48 | Color(201, 209, 217), // Dark mode - GitHub dark: #c9d1d9 49 | ) 50 | val RESIZE_HANDLE_COLOR = JBColor( 51 | Color(120, 120, 120), // Light mode - darker for better contrast 52 | Color(210, 210, 210), // Dark mode - lighter for better contrast 53 | ) 54 | val SELECTION_BORDER_COLOR = JBColor( 55 | Color(0, 120, 215), // Light mode 56 | Color(0, 180, 100), // Dark mode - greenish blue 57 | ) 58 | val GROUP_SELECTION_BORDER_COLOR = JBColor( 59 | Color(0, 180, 100), // Light mode - greenish blue 60 | Color(88, 166, 124) // Dark mode - subtle green 61 | ) 62 | val SELECTION_HEADER_COLOR = JBColor( 63 | Color(210, 230, 255), // Light mode 64 | Color(33, 38, 45) // Dark mode - GitHub dark: #21262d 65 | ) 66 | 67 | // Connection colors 68 | val CONNECTION_COLOR = JBColor.GRAY 69 | 70 | // Border colors 71 | val BORDER_COLOR = JBColor( 72 | Color(200, 200, 200), // Light mode 73 | Color(48, 54, 61) // Dark mode - GitHub dark: #30363d 74 | ) 75 | 76 | // Invalid bookmark border color 77 | val INVALID_BOOKMARK_BORDER_COLOR = JBColor( 78 | Color(220, 50, 50), // Light mode - red 79 | Color(220, 50, 50) // Dark mode - same red 80 | ) 81 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/mwalker/bookmarkcanvas/ui/util.kt: -------------------------------------------------------------------------------- 1 | package org.mwalker.bookmarkcanvas.ui 2 | 3 | import com.intellij.ui.Gray 4 | import java.awt.* 5 | import java.awt.event.MouseEvent 6 | import java.awt.font.FontRenderContext 7 | import javax.swing.SwingUtilities 8 | import javax.swing.text.JTextComponent 9 | import com.intellij.ui.JBColor 10 | 11 | /** 12 | * Forwards a mouse event from one component to another, converting coordinates. 13 | */ 14 | fun forwardMouseEvent(src: Component, e: MouseEvent, target: Component) { 15 | // Convert coordinates from source to target component 16 | val parentEvent = SwingUtilities.convertMouseEvent(src, e, target) 17 | 18 | // Dispatch the new event to the target 19 | target.dispatchEvent(parentEvent) 20 | 21 | // Consume the original event to prevent default handling 22 | e.consume() 23 | } 24 | 25 | /** 26 | * Calculates text height using LineBreakMeasurer for accurate line wrapping 27 | */ 28 | fun calculateTextHeight(text: String, font: Font, width: Int, frc: FontRenderContext): Int { 29 | if (text.isEmpty()) return 20 30 | 31 | val attributedString = java.text.AttributedString(text) 32 | attributedString.addAttribute(java.awt.font.TextAttribute.FONT, font) 33 | 34 | val iterator = attributedString.iterator 35 | val measurer = java.awt.font.LineBreakMeasurer(iterator, frc) 36 | 37 | var y = 0 38 | measurer.position = 0 39 | // Calculate height by measuring each line 40 | while (measurer.position < text.length) { 41 | val layout = measurer.nextLayout(width.toFloat()) 42 | y += ((layout.ascent + layout.descent + layout.leading) * 1.5).toInt() 43 | } 44 | 45 | return y.coerceAtLeast(20) // Minimum height of 20 pixels 46 | } 47 | 48 | /** 49 | * Gets FontRenderContext from a component or creates a default one if not available 50 | */ 51 | fun getFontRenderContext(component: Component): FontRenderContext { 52 | return (component.getGraphics() as? Graphics2D)?.fontRenderContext 53 | ?: FontRenderContext(null, true, true) 54 | } 55 | 56 | /** 57 | * Calculates optimal dimensions for a code snippet based on its content 58 | */ 59 | fun calculateCodeSnippetDimensions(code: String, font: Font, frc: FontRenderContext, maxWidth: Int = 600): Dimension { 60 | if (code.isEmpty()) return Dimension(250, 100) 61 | 62 | val lines = code.split('\n') 63 | var maxLineWidth = 0 64 | var totalHeight = 0 65 | 66 | // Calculate width based on the longest line 67 | val fontMetrics = FontMetrics2D(font, frc) 68 | 69 | for (line in lines) { 70 | val lineWidth = fontMetrics.stringWidth(line) 71 | maxLineWidth = maxOf(maxLineWidth, lineWidth) 72 | } 73 | 74 | // Calculate height based on number of lines and line height 75 | val lineHeight = (fontMetrics.ascent + fontMetrics.descent + fontMetrics.leading * 1.2).toInt() 76 | totalHeight = lines.size * lineHeight 77 | 78 | // Apply constraints: reasonable maximum width, no maximum height 79 | val finalWidth = minOf(maxLineWidth + 40, maxWidth).coerceAtLeast(250) // Add padding and min width 80 | val finalHeight = (totalHeight + 40).coerceAtLeast(100) // Add padding and min height 81 | 82 | return Dimension(finalWidth, finalHeight) 83 | } 84 | 85 | /** 86 | * Helper class to get string width from FontMetrics using FontRenderContext 87 | */ 88 | private class FontMetrics2D(val font: Font, val frc: FontRenderContext) { 89 | val ascent: Float = font.getLineMetrics("Ag", frc).ascent 90 | val descent: Float = font.getLineMetrics("Ag", frc).descent 91 | val leading: Float = font.getLineMetrics("Ag", frc).leading 92 | 93 | fun stringWidth(str: String): Int { 94 | return font.getStringBounds(str, frc).width.toInt() 95 | } 96 | } 97 | 98 | /** 99 | * Checks if a point is within the resize area of a component with the specified handle size 100 | * Uses an enlarged area for easier grabbing of the resize handle 101 | */ 102 | fun isInResizeArea(point: Point, componentWidth: Int, componentHeight: Int, handleSize: Int): Boolean { 103 | // Make hit area larger than visual handle for easier grabbing 104 | val enlargedHandleSize = handleSize + 4 105 | 106 | val resizeArea = Rectangle( 107 | componentWidth - enlargedHandleSize, 108 | componentHeight - enlargedHandleSize, 109 | enlargedHandleSize, 110 | enlargedHandleSize 111 | ) 112 | return resizeArea.contains(point) 113 | } 114 | 115 | /** 116 | * Draws a resize handle in the bottom-right corner of a component 117 | * Simple white diagonal lines that are clearly visible 118 | */ 119 | fun drawResizeHandle(g2d: Graphics2D, componentWidth: Int, componentHeight: Int, handleSize: Int, color: Color) { 120 | // Save original stroke 121 | val originalStroke = g2d.stroke 122 | 123 | // Always use white for maximum visibility in any theme 124 | g2d.color = Gray._200 125 | 126 | // Use very thick stroke for maximum visibility 127 | g2d.stroke = BasicStroke(1.5f) 128 | 129 | // Draw diagonal lines for resize handle 130 | val x = componentWidth - handleSize 131 | val y = componentHeight - handleSize 132 | 133 | // Draw 3 larger diagonal lines with wider spacing 134 | // for (i in 1..3) { 135 | for (i in 0..2) { 136 | val offset = i * 4 // Use wider spacing 137 | g2d.drawLine( 138 | x + offset, y + handleSize, 139 | x + handleSize, y + offset 140 | ) 141 | } 142 | 143 | // Restore original stroke 144 | g2d.stroke = originalStroke 145 | } 146 | 147 | 148 | /** 149 | * Utility class to throttle frequent events like mouse movements and drags 150 | * to improve performance by limiting how often an operation is executed. 151 | */ 152 | class EventThrottler(private val delayMs: Long = 16) { // ~60fps by default 153 | private var lastExecutionTime: Long = 0 154 | private var pendingActionScheduled = false 155 | private var pendingAction: (() -> Unit)? = null 156 | 157 | /** 158 | * Throttles the execution of the provided action 159 | * @param action The action to execute after throttling 160 | * @return true if action was executed immediately, false if throttled 161 | */ 162 | fun throttle(action: () -> Unit): Boolean { 163 | val currentTime = System.currentTimeMillis() 164 | 165 | // If enough time has passed since last execution, run immediately 166 | if (currentTime - lastExecutionTime >= delayMs) { 167 | action() 168 | lastExecutionTime = currentTime 169 | return true 170 | } 171 | 172 | // Store the latest action 173 | pendingAction = action 174 | 175 | // If we've already scheduled an action, don't schedule another one 176 | if (pendingActionScheduled) { 177 | return false 178 | } 179 | 180 | // Mark that we've scheduled an action 181 | pendingActionScheduled = true 182 | 183 | // Schedule for later execution - only one invokeLater per throttle window 184 | SwingUtilities.invokeLater { 185 | // Reset the scheduled flag 186 | pendingActionScheduled = false 187 | 188 | // Get the latest action and clear the reference 189 | pendingAction?.let { 190 | it() 191 | lastExecutionTime = System.currentTimeMillis() 192 | } 193 | 194 | // Clear the pending action 195 | pendingAction = null 196 | } 197 | 198 | return false 199 | } 200 | 201 | /** 202 | * Clear any pending throttled actions 203 | */ 204 | fun clear() { 205 | pendingAction = null 206 | // We don't cancel the scheduled invokeLater, but it will do nothing 207 | // since pendingAction will be null when it runs 208 | } 209 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | org.mwalker.bookmark-canvas 3 | Bookmark Canvas 4 | mwalker.org 5 | 8 | 9 | com.intellij.modules.platform 10 | com.intellij.modules.lang 11 | 12 | 13 | 18 | 23 | 25 | 26 | 27 | 28 | 32 | 33 | 34 | 35 | 36 | 40 | 41 | 42 | 43 | 44 | 48 | 49 | 50 | 51 | 52 | 56 | 57 | 58 | 59 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon_old.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/main/resources/icons/book-bookmark-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/resources/icons/book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwalkerr/BookmarkCanvas/f2c17a7a4af607a7c441354de514c7fd14b2dd7d/src/main/resources/icons/book.png -------------------------------------------------------------------------------- /src/main/resources/web/canvas.css: -------------------------------------------------------------------------------- 1 | /* Canvas Container */ 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | overflow: hidden; 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 7 | background-color: #2B2B2B; 8 | user-select: none; 9 | } 10 | 11 | #canvas-container { 12 | position: relative; 13 | width: 100vw; 14 | height: 100vh; 15 | overflow: hidden; 16 | background-color: #2B2B2B; 17 | } 18 | 19 | #canvas { 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | background-color: transparent; 24 | cursor: default; 25 | } 26 | 27 | #nodes-container { 28 | position: absolute; 29 | top: 0; 30 | left: 0; 31 | width: 100%; 32 | height: 100%; 33 | pointer-events: none; 34 | } 35 | 36 | /* Grid styles */ 37 | .grid-background { 38 | background-image: 39 | linear-gradient(to right, #3E3E3E 1px, transparent 1px), 40 | linear-gradient(to bottom, #3E3E3E 1px, transparent 1px); 41 | background-size: 20px 20px; 42 | } 43 | 44 | /* Node Styles */ 45 | .node { 46 | position: absolute; 47 | background-color: #3C3F41; 48 | border: 2px solid #5E6366; 49 | border-radius: 8px; 50 | padding: 8px; 51 | min-width: 120px; 52 | max-width: 200px; 53 | cursor: move; 54 | pointer-events: auto; 55 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 56 | transition: box-shadow 0.2s ease; 57 | } 58 | 59 | .node:hover { 60 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 61 | } 62 | 63 | .node.selected { 64 | border-color: #4A90E2; 65 | box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.3); 66 | } 67 | 68 | .node.connecting { 69 | border-color: #FFA500; 70 | box-shadow: 0 0 0 2px rgba(255, 165, 0, 0.3); 71 | } 72 | 73 | .node-title { 74 | color: #BBBBBB; 75 | font-size: 12px; 76 | font-weight: bold; 77 | margin-bottom: 4px; 78 | word-wrap: break-word; 79 | line-height: 1.2; 80 | } 81 | 82 | .node-content { 83 | color: #A0A0A0; 84 | font-size: 10px; 85 | line-height: 1.2; 86 | word-wrap: break-word; 87 | max-height: 60px; 88 | overflow: hidden; 89 | } 90 | 91 | .node-url { 92 | color: #6A9DDD; 93 | font-size: 9px; 94 | margin-top: 4px; 95 | text-decoration: none; 96 | word-break: break-all; 97 | } 98 | 99 | .node-resize-handle { 100 | position: absolute; 101 | bottom: 0; 102 | right: 0; 103 | width: 12px; 104 | height: 12px; 105 | background: linear-gradient(135deg, transparent 50%, #666 50%); 106 | cursor: se-resize; 107 | border-bottom-right-radius: 6px; 108 | } 109 | 110 | /* Selection Box */ 111 | .selection-box { 112 | position: absolute; 113 | border: 1px dashed #4A90E2; 114 | background-color: rgba(74, 144, 226, 0.1); 115 | pointer-events: none; 116 | z-index: 1000; 117 | } 118 | 119 | /* Temporary Connection Line */ 120 | .temp-connection { 121 | position: absolute; 122 | pointer-events: none; 123 | z-index: 500; 124 | } 125 | 126 | /* Context Menu */ 127 | .context-menu { 128 | position: absolute; 129 | background-color: #3C3F41; 130 | border: 1px solid #5E6366; 131 | border-radius: 4px; 132 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 133 | z-index: 2000; 134 | min-width: 120px; 135 | padding: 4px 0; 136 | } 137 | 138 | .menu-item { 139 | padding: 6px 12px; 140 | color: #BBBBBB; 141 | font-size: 12px; 142 | cursor: pointer; 143 | white-space: nowrap; 144 | } 145 | 146 | .menu-item:hover { 147 | background-color: #4A90E2; 148 | color: white; 149 | } 150 | 151 | .menu-separator { 152 | height: 1px; 153 | background-color: #5E6366; 154 | margin: 4px 0; 155 | } 156 | 157 | /* Connection Styles */ 158 | .connection-line { 159 | stroke: #6A9DDD; 160 | stroke-width: 2; 161 | fill: none; 162 | pointer-events: stroke; 163 | cursor: pointer; 164 | } 165 | 166 | .connection-line:hover { 167 | stroke: #4A90E2; 168 | stroke-width: 3; 169 | } 170 | 171 | .connection-line.selected { 172 | stroke: #FFA500; 173 | stroke-width: 3; 174 | } 175 | 176 | /* Scrollbars */ 177 | ::-webkit-scrollbar { 178 | display: none; 179 | } 180 | 181 | /* Animation for smooth interactions */ 182 | .node.dragging { 183 | transition: none; 184 | } 185 | 186 | .zoom-transition { 187 | transition: transform 0.2s ease-out; 188 | } 189 | 190 | /* File node styling */ 191 | .node.file-node { 192 | background-color: #4A5A3C; 193 | border-color: #6A7A5C; 194 | } 195 | 196 | .node.file-node .node-title { 197 | color: #C5D5B5; 198 | } 199 | 200 | .node.file-node .node-content { 201 | color: #A5B595; 202 | } 203 | 204 | /* Code snippet highlighting */ 205 | .code-snippet { 206 | background-color: #2B2B2B; 207 | border: 1px solid #404040; 208 | border-radius: 4px; 209 | padding: 4px; 210 | margin-top: 4px; 211 | font-family: 'JetBrains Mono', 'Monaco', 'Menlo', monospace; 212 | font-size: 9px; 213 | color: #A9B7C6; 214 | white-space: pre-wrap; 215 | overflow: hidden; 216 | max-height: 40px; 217 | } 218 | 219 | /* Zoom controls would go here if needed */ 220 | .zoom-controls { 221 | position: absolute; 222 | bottom: 20px; 223 | right: 20px; 224 | background-color: #3C3F41; 225 | border: 1px solid #5E6366; 226 | border-radius: 4px; 227 | padding: 8px; 228 | display: flex; 229 | gap: 8px; 230 | } 231 | 232 | .zoom-button { 233 | background-color: transparent; 234 | border: 1px solid #5E6366; 235 | color: #BBBBBB; 236 | padding: 4px 8px; 237 | border-radius: 2px; 238 | cursor: pointer; 239 | font-size: 12px; 240 | } 241 | 242 | .zoom-button:hover { 243 | background-color: #4A90E2; 244 | color: white; 245 | } -------------------------------------------------------------------------------- /src/main/resources/web/canvas.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Bookmark Canvas 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 |
16 | 17 | 18 | 25 | 26 | 27 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /targetStyle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GitHub Dark Code Nodes 7 | 8 | 84 | 85 | 86 |
87 |
88 | 89 |
90 |
Data Model Definition
91 |
92 |
class User {
 93 |   constructor(name, email) {
 94 |     this.name = name;
 95 |     this.email = email;
 96 |     this.createdAt = new Date();
 97 |   }
 98 | 
 99 |   isValid() {
100 |     return this.email && this.email.includes('@');
101 |   }
102 | }
103 |
104 |
105 |
106 | 107 | 108 | -------------------------------------------------------------------------------- /web/bookmark-canvas/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/bookmark-canvas/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: 13 | 14 | ```js 15 | export default tseslint.config({ 16 | extends: [ 17 | // Remove ...tseslint.configs.recommended and replace with this 18 | ...tseslint.configs.recommendedTypeChecked, 19 | // Alternatively, use this for stricter rules 20 | ...tseslint.configs.strictTypeChecked, 21 | // Optionally, add this for stylistic rules 22 | ...tseslint.configs.stylisticTypeChecked, 23 | ], 24 | languageOptions: { 25 | // other options... 26 | parserOptions: { 27 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 28 | tsconfigRootDir: import.meta.dirname, 29 | }, 30 | }, 31 | }) 32 | ``` 33 | 34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 35 | 36 | ```js 37 | // eslint.config.js 38 | import reactX from 'eslint-plugin-react-x' 39 | import reactDom from 'eslint-plugin-react-dom' 40 | 41 | export default tseslint.config({ 42 | plugins: { 43 | // Add the react-x and react-dom plugins 44 | 'react-x': reactX, 45 | 'react-dom': reactDom, 46 | }, 47 | rules: { 48 | // other rules... 49 | // Enable its recommended typescript rules 50 | ...reactX.configs['recommended-typescript'].rules, 51 | ...reactDom.configs.recommended.rules, 52 | }, 53 | }) 54 | ``` 55 | -------------------------------------------------------------------------------- /web/bookmark-canvas/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /web/bookmark-canvas/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/bookmark-canvas/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookmark-canvas", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "build:webview": "tsc -b && vite build", 10 | "lint": "eslint .", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@monaco-editor/react": "^4.7.0", 15 | "prismjs": "^1.30.0", 16 | "react": "^19.1.0", 17 | "react-dom": "^19.1.0", 18 | "react-rnd": "^10.5.2", 19 | "reactflow": "^11.11.4", 20 | "zustand": "^5.0.5" 21 | }, 22 | "devDependencies": { 23 | "@eslint/js": "^9.25.0", 24 | "@types/prismjs": "^1.26.5", 25 | "@types/react": "^19.1.2", 26 | "@types/react-dom": "^19.1.2", 27 | "@vitejs/plugin-react": "^4.4.1", 28 | "eslint": "^9.25.0", 29 | "eslint-plugin-react-hooks": "^5.2.0", 30 | "eslint-plugin-react-refresh": "^0.4.19", 31 | "globals": "^16.0.0", 32 | "typescript": "~5.8.3", 33 | "typescript-eslint": "^8.30.1", 34 | "vite": "^6.3.5", 35 | "vite-plugin-singlefile": "^2.2.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/bookmark-canvas/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/bookmark-canvas/src/App.css: -------------------------------------------------------------------------------- 1 | .app { 2 | height: 100vh; 3 | width: 100vw; 4 | background: #1a1a1a; 5 | color: #fff; 6 | } 7 | 8 | .canvas-container { 9 | width: 100%; 10 | height: 100%; 11 | } 12 | 13 | /* Bookmark Node Styles */ 14 | .bookmark-node { 15 | background: #2a2a2a; 16 | border: 1px solid #404040; 17 | border-radius: 8px; 18 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 19 | overflow: hidden; 20 | min-width: 300px; 21 | user-select: none; 22 | position: relative; 23 | } 24 | 25 | .bookmark-node.selected { 26 | border-color: #0078d4 !important; 27 | box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.3) !important; 28 | } 29 | 30 | .bookmark-header { 31 | padding: 0.75rem 1rem; 32 | background: #1a1a1a; 33 | border-bottom: 1px solid #404040; 34 | display: flex; 35 | flex-direction: column; 36 | gap: 0.25rem; 37 | } 38 | 39 | .bookmark-title { 40 | font-weight: 600; 41 | font-size: 0.9rem; 42 | color: #e1e1e1; 43 | } 44 | 45 | .bookmark-path { 46 | font-size: 0.75rem; 47 | color: #888; 48 | font-family: 'Consolas', 'Monaco', monospace; 49 | } 50 | 51 | .bookmark-content { 52 | background: #1e1e1e; 53 | display: flex; 54 | flex-direction: column; 55 | } 56 | 57 | .bookmark-content .monaco-editor, 58 | .bookmark-content .monaco-editor *, 59 | .bookmark-content .monaco-editor .view-lines, 60 | .bookmark-content .monaco-editor .view-line, 61 | .bookmark-content .monaco-editor .mtk1, 62 | .bookmark-content .monaco-editor .monaco-editor-background, 63 | .bookmark-content .monaco-editor .lines-content { 64 | user-select: none !important; 65 | -webkit-user-select: none !important; 66 | -moz-user-select: none !important; 67 | -ms-user-select: none !important; 68 | pointer-events: none !important; 69 | } 70 | 71 | .bookmark-content .monaco-editor .monaco-scrollable-element { 72 | pointer-events: auto !important; 73 | } 74 | 75 | /* Code Display (Prism) Styles */ 76 | .code-display-container, 77 | .code-display-container *, 78 | .code-display-container pre, 79 | .code-display-container code { 80 | user-select: none !important; 81 | -webkit-user-select: none !important; 82 | -moz-user-select: none !important; 83 | -ms-user-select: none !important; 84 | } 85 | 86 | .code-display-container pre { 87 | background: #1e1e1e !important; 88 | color: #d4d4d4 !important; 89 | border: none !important; 90 | margin: 0 !important; 91 | border-radius: 0 !important; 92 | } 93 | 94 | .code-display-container code { 95 | background: transparent !important; 96 | color: inherit !important; 97 | } 98 | 99 | /* React Flow customizations */ 100 | .react-flow__attribution { 101 | background: rgba(42, 42, 42, 0.8) !important; 102 | color: #888 !important; 103 | } 104 | 105 | .react-flow__controls { 106 | background: #2a2a2a !important; 107 | border: 1px solid #404040 !important; 108 | } 109 | 110 | .react-flow__controls button { 111 | background: #2a2a2a !important; 112 | color: #e1e1e1 !important; 113 | border-bottom: 1px solid #404040 !important; 114 | } 115 | 116 | .react-flow__controls button:hover { 117 | background: #404040 !important; 118 | } 119 | 120 | .react-flow__minimap { 121 | background: #1a1a1a !important; 122 | border: 1px solid #404040 !important; 123 | } 124 | 125 | .react-flow__edge-path { 126 | stroke: #666 !important; 127 | stroke-width: 2 !important; 128 | } 129 | 130 | /* Resize handle specific styles */ 131 | .resize-handle-corner { 132 | pointer-events: auto !important; 133 | z-index: 1000 !important; 134 | } 135 | 136 | .resize-handle-corner:hover { 137 | background: #3a3a3a !important; 138 | } 139 | 140 | /* Prevent React Flow from dragging this element */ 141 | .nodrag { 142 | pointer-events: auto !important; 143 | } 144 | 145 | /* Context Menu Styles */ 146 | .context-menu-overlay { 147 | position: fixed; 148 | top: 0; 149 | left: 0; 150 | right: 0; 151 | bottom: 0; 152 | z-index: 999; 153 | } 154 | 155 | .context-menu { 156 | background: #2a2a2a; 157 | border: 1px solid #404040; 158 | border-radius: 6px; 159 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 160 | padding: 4px 0; 161 | min-width: 160px; 162 | } 163 | 164 | .context-menu-item { 165 | padding: 8px 12px; 166 | cursor: pointer; 167 | color: #e1e1e1; 168 | font-size: 0.9rem; 169 | transition: background-color 0.1s; 170 | } 171 | 172 | .context-menu-item:hover { 173 | background: #404040; 174 | } 175 | 176 | .context-menu-item.danger { 177 | color: #ff6b6b; 178 | } 179 | 180 | .context-menu-item.danger:hover { 181 | background: #4a2626; 182 | } 183 | 184 | .context-menu-separator { 185 | height: 1px; 186 | background: #404040; 187 | margin: 4px 0; 188 | } 189 | 190 | /* Resize handle styles */ 191 | .react-rnd-resize-handle { 192 | background: rgba(0, 120, 212, 0.3) !important; 193 | border: 1px solid #0078d4 !important; 194 | } 195 | 196 | .react-rnd-resize-handle:hover { 197 | background: rgba(0, 120, 212, 0.6) !important; 198 | } 199 | -------------------------------------------------------------------------------- /web/bookmark-canvas/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Canvas } from './components/Canvas'; 3 | import { useCanvasStore } from './store/canvasStore'; 4 | import './App.css'; 5 | 6 | function App() { 7 | const { addBookmark, clearCanvas } = useCanvasStore(); 8 | 9 | // Add sample bookmarks for testing 10 | useEffect(() => { 11 | // Clear any existing nodes first, then add sample data 12 | clearCanvas(); 13 | addBookmark({ 14 | type: 'bookmark', 15 | position: { x: 100, y: 100 }, 16 | data: { 17 | id: '', 18 | title: 'CanvasEventHandler.kt', 19 | content: `LOG.info("Mouse released on canvas, \${e.point}, \${e.button}, clickCount: \${e.clickCount}") 20 | "isPopupTrigger: \${e.isPopupTrigger}, isLeft: \${SwingUtilities.isLeftMouseButton(e)}" 21 | "connectionStartNode is null: \${canvasPanel.connectionStartNode == null}" 22 | "component at point: \${canvasPanel.getComponentAt(e.point)}") 23 | 24 | // Clear any pending throttled actions 25 | pendingThrottledActions.clear()`, 26 | language: 'kotlin', 27 | filePath: 'CanvasEventHandler.kt:117', 28 | }, 29 | }); 30 | 31 | addBookmark({ 32 | type: 'bookmark', 33 | position: { x: 500, y: 200 }, 34 | data: { 35 | id: '', 36 | title: 'HomeAction.kt', 37 | content: `class HomeAction(private val canvasPanel: CanvasPanel) : AnAction("Go to Home", "Go to Home", null) { 38 | 39 | override fun actionPerformed(e: AnActionEvent) { 40 | // Find the top-left most node and recenter on it 41 | val topLeftNode = canvasPanel.children`, 42 | language: 'kotlin', 43 | filePath: 'HomeAction.kt:10', 44 | }, 45 | }); 46 | 47 | addBookmark({ 48 | type: 'bookmark', 49 | position: { x: 300, y: 400 }, 50 | data: { 51 | id: '', 52 | title: 'AddFileToCanvasWebAction.kt', 53 | content: `class AddFileToCanvasWebAction( 54 | private val project: Project, 55 | private val canvasPanel: CanvasInterface 56 | ) : AnAction("Add File to Canvas", "Add current file to canvas", null)`, 57 | language: 'kotlin', 58 | filePath: 'AddFileToCanvasWebAction.kt:11', 59 | }, 60 | }); 61 | }, [addBookmark, clearCanvas]); 62 | 63 | return ( 64 |
65 | 66 |
67 | ); 68 | } 69 | 70 | export default App 71 | -------------------------------------------------------------------------------- /web/bookmark-canvas/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/bookmark-canvas/src/components/CodeDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect, useRef } from 'react'; 2 | import Prism from 'prismjs'; 3 | import 'prismjs/themes/prism-dark.css'; 4 | import 'prismjs/components/prism-kotlin'; 5 | 6 | interface CodeDisplayProps { 7 | code: string; 8 | language: string; 9 | width: string; 10 | height: string; 11 | } 12 | 13 | export const CodeDisplay = memo(({ code, language, width, height }: CodeDisplayProps) => { 14 | const codeRef = useRef(null); 15 | 16 | useEffect(() => { 17 | if (codeRef.current) { 18 | Prism.highlightElement(codeRef.current); 19 | } 20 | }, [code, language]); 21 | 22 | return ( 23 |
34 |
35 |         
36 |           {code}
37 |         
38 |       
39 |
40 | ); 41 | }); 42 | 43 | CodeDisplay.displayName = 'CodeDisplay'; -------------------------------------------------------------------------------- /web/bookmark-canvas/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /web/bookmark-canvas/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /web/bookmark-canvas/src/store/canvasStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { subscribeWithSelector } from 'zustand/middleware'; 3 | 4 | type BookmarkData = { 5 | id: string; 6 | title: string; 7 | content: string; 8 | language: string; 9 | filePath?: string; 10 | }; 11 | 12 | type BookmarkNode = { 13 | id: string; 14 | type: 'bookmark'; 15 | position: { x: number; y: number }; 16 | data: BookmarkData; 17 | width?: number; 18 | height?: number; 19 | }; 20 | 21 | type BookmarkConnection = { 22 | id: string; 23 | source: string; 24 | target: string; 25 | type?: 'default' | 'straight' | 'step' | 'smoothstep'; 26 | sourceHandle?: string; 27 | targetHandle?: string; 28 | }; 29 | 30 | type CanvasState = { 31 | nodes: BookmarkNode[]; 32 | edges: BookmarkConnection[]; 33 | selectedNodes: string[]; 34 | isGridVisible: boolean; 35 | }; 36 | 37 | interface CanvasStore extends CanvasState { 38 | // Actions 39 | addBookmark: (bookmark: Omit) => void; 40 | updateBookmark: (id: string, updates: Partial) => void; 41 | removeBookmark: (id: string) => void; 42 | addConnection: (connection: Omit) => void; 43 | removeConnection: (id: string) => void; 44 | removeConnectionBetween: (sourceId: string, targetId: string) => void; 45 | recalculateEdges: (nodeId: string) => void; 46 | setSelectedNodes: (nodeIds: string[]) => void; 47 | toggleGrid: () => void; 48 | clearCanvas: () => void; 49 | } 50 | 51 | const generateId = () => Math.random().toString(36).substr(2, 9); 52 | 53 | // These defaults are used for calculating connection points. 54 | // If nodes have variable sizes stored, those should be used instead. 55 | const DEFAULT_NODE_WIDTH_FOR_HANDLES = 350; 56 | const DEFAULT_NODE_HEIGHT_FOR_HANDLES = 250; 57 | 58 | const calculateConnectionHandles = (sourceNode: BookmarkNode, targetNode: BookmarkNode) => { 59 | const sourceCenter = { 60 | x: sourceNode.position.x + (sourceNode.width || DEFAULT_NODE_WIDTH_FOR_HANDLES) / 2, 61 | y: sourceNode.position.y + (sourceNode.height || DEFAULT_NODE_HEIGHT_FOR_HANDLES) / 2 62 | }; 63 | 64 | const targetCenter = { 65 | x: targetNode.position.x + (targetNode.width || DEFAULT_NODE_WIDTH_FOR_HANDLES) / 2, 66 | y: targetNode.position.y + (targetNode.height || DEFAULT_NODE_HEIGHT_FOR_HANDLES) / 2 67 | }; 68 | 69 | // Determine connection points based on relative positions 70 | let sourceHandle = ''; 71 | let targetHandle = ''; 72 | 73 | const dx = targetCenter.x - sourceCenter.x; 74 | const dy = targetCenter.y - sourceCenter.y; 75 | 76 | if (Math.abs(dx) > Math.abs(dy)) { 77 | // Horizontal connection is stronger 78 | if (dx > 0) { 79 | sourceHandle = 'right'; 80 | targetHandle = 'left'; 81 | } else { 82 | sourceHandle = 'left'; 83 | targetHandle = 'right'; 84 | } 85 | } else { 86 | // Vertical connection is stronger 87 | if (dy > 0) { 88 | sourceHandle = 'bottom'; 89 | targetHandle = 'top'; 90 | } else { 91 | sourceHandle = 'top'; 92 | targetHandle = 'bottom'; 93 | } 94 | } 95 | 96 | return { sourceHandle, targetHandle }; 97 | }; 98 | 99 | export const useCanvasStore = create()( 100 | subscribeWithSelector((set, get) => ({ 101 | // Initial state 102 | nodes: [], 103 | edges: [], 104 | selectedNodes: [], 105 | isGridVisible: true, 106 | 107 | // Actions 108 | addBookmark: (bookmark) => { 109 | const newNode: BookmarkNode = { 110 | ...bookmark, 111 | id: generateId(), 112 | }; 113 | set((state) => ({ 114 | nodes: [...state.nodes, newNode], 115 | })); 116 | }, 117 | 118 | updateBookmark: (id, updates) => { 119 | set((state) => ({ 120 | nodes: state.nodes.map((node) => 121 | node.id === id ? { ...node, ...updates } : node 122 | ), 123 | })); 124 | }, 125 | 126 | removeBookmark: (id) => { 127 | set((state) => ({ 128 | nodes: state.nodes.filter((node) => node.id !== id), 129 | edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id), 130 | selectedNodes: state.selectedNodes.filter((nodeId) => nodeId !== id), 131 | })); 132 | }, 133 | 134 | addConnection: (connection) => { 135 | const state = get(); 136 | const sourceNode = state.nodes.find(n => n.id === connection.source); 137 | const targetNode = state.nodes.find(n => n.id === connection.target); 138 | 139 | if (!sourceNode || !targetNode) return; 140 | 141 | const { sourceHandle, targetHandle } = calculateConnectionHandles(sourceNode, targetNode); 142 | 143 | const newEdge: BookmarkConnection = { 144 | ...connection, 145 | id: generateId(), 146 | sourceHandle, 147 | targetHandle, 148 | }; 149 | 150 | set((state) => ({ 151 | edges: [...state.edges, newEdge], 152 | })); 153 | }, 154 | 155 | removeConnection: (id) => { 156 | set((state) => ({ 157 | edges: state.edges.filter((edge) => edge.id !== id), 158 | })); 159 | }, 160 | 161 | removeConnectionBetween: (sourceId, targetId) => { 162 | set((state) => ({ 163 | edges: state.edges.filter((edge) => 164 | !((edge.source === sourceId && edge.target === targetId) || 165 | (edge.source === targetId && edge.target === sourceId)) 166 | ), 167 | })); 168 | }, 169 | 170 | recalculateEdges: (nodeId) => { 171 | const state = get(); 172 | const affectedEdges = state.edges.filter(edge => 173 | edge.source === nodeId || edge.target === nodeId 174 | ); 175 | 176 | if (affectedEdges.length === 0) return; 177 | 178 | const updatedEdges = state.edges.map(edge => { 179 | if (edge.source === nodeId || edge.target === nodeId) { 180 | const sourceNode = state.nodes.find(n => n.id === edge.source); 181 | const targetNode = state.nodes.find(n => n.id === edge.target); 182 | 183 | if (sourceNode && targetNode) { 184 | const { sourceHandle, targetHandle } = calculateConnectionHandles(sourceNode, targetNode); 185 | return { 186 | ...edge, 187 | sourceHandle, 188 | targetHandle, 189 | }; 190 | } 191 | } 192 | return edge; 193 | }); 194 | 195 | set({ edges: updatedEdges }); 196 | }, 197 | 198 | setSelectedNodes: (nodeIds) => { 199 | set({ selectedNodes: nodeIds }); 200 | }, 201 | 202 | toggleGrid: () => { 203 | set((state) => ({ isGridVisible: !state.isGridVisible })); 204 | }, 205 | 206 | clearCanvas: () => { 207 | set({ 208 | nodes: [], 209 | edges: [], 210 | selectedNodes: [], 211 | }); 212 | }, 213 | })) 214 | ); 215 | -------------------------------------------------------------------------------- /web/bookmark-canvas/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/bookmark-canvas/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "erasableSyntaxOnly": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /web/bookmark-canvas/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /web/bookmark-canvas/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /web/bookmark-canvas/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import { viteSingleFile } from 'vite-plugin-singlefile' 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), viteSingleFile()], 8 | }) 9 | -------------------------------------------------------------------------------- /web/reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwalkerr/BookmarkCanvas/f2c17a7a4af607a7c441354de514c7fd14b2dd7d/web/reference.png --------------------------------------------------------------------------------