├── .gitignore ├── README.md ├── index.html ├── main.cljs ├── screenshot.png └── style.css /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .aider* 3 | .conventions 4 | .*.swp 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodeShow 2 | 3 | Simple code presentation tool for videos, screencasts, and live demos. 4 | 5 | ![CodeShow screenshot](./screenshot.png) 6 | 7 | Open `index.html` or [mccormick.cx/apps/codeshow](https://mccormick.cx/apps/codeshow) in your browser to use it. 8 | 9 | ## Features 10 | 11 | - Syntax highlighting (CodeMirror) 12 | - Adjustable color themes (CodeMirror) 13 | - Full-screen display 14 | - Filename display (optional) 15 | - Window dots decoration (optional) 16 | - Config is auto-saved to localStorage 17 | 18 | ## Usage 19 | 20 | - Click anywhere outside the code box to toggle the config interface. 21 | - Use the built-in browser zoom if you want bigger or smaller code. 22 | - Take a screenshot to get an image you can re-use. 23 | - To self-host, upload `index.html`, `style.css`, and `main.cljs` to your server. 24 | 25 | ## Technology 26 | 27 | - [Scittle ClojureScript](https://github.com/babashka/scittle/). 28 | - [Reagent](https://reagent-project.github.io/). 29 | - [CodeMirror](https://codemirror.net) (v5). 30 | 31 | ## License 32 | 33 | MIT 34 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CodeShow 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /main.cljs: -------------------------------------------------------------------------------- 1 | (ns main 2 | {:clj-kondo/config '{:lint-as {promesa.core/let clojure.core/let}}} 3 | (:require 4 | [reagent.core :as r] 5 | [reagent.dom :as rdom] 6 | [clojure.string :as str] 7 | [clojure.core :refer [read-string]] 8 | [promesa.core :as p])) 9 | 10 | ;*** data ***; 11 | 12 | (def cdn-root "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15") 13 | 14 | (def themes 15 | ["3024-day" "3024-night" "abbott" "abcdef" "ambiance-mobile" "ambiance" 16 | "ayu-dark" "ayu-mirage" "base16-dark" "base16-light" "bespin" "blackboard" 17 | "cobalt" "colorforth" "darcula" "dracula" "duotone-dark" "duotone-light" 18 | "eclipse" "elegant" "erlang-dark" "gruvbox-dark" "hopscotch" "icecoder" 19 | "idea" "isotope" "juejin" "lesser-dark" "liquibyte" "lucario" 20 | "material-darker" "material-ocean" "material-palenight" "material" "mbo" 21 | "mdn-like" "midnight" "monokai" "moxer" "neat" "neo" "night" "nord" 22 | "oceanic-next" "panda-syntax" "paraiso-dark" "paraiso-light" 23 | "pastel-on-dark" "railscasts" "rubyblue" "seti" "shadowfox" "solarized" 24 | "ssms" "the-matrix" "tomorrow-night-bright" "tomorrow-night-eighties" 25 | "ttcn" "twilight" "vibrant-ink" "xq-dark" "xq-light" "yeti" "yonce" 26 | "zenburn"]) 27 | 28 | (def modes 29 | ["apl" "asciiarmor" "asn.1" "asterisk" "brainfuck" "ceylon" "clike" "clojure" 30 | "cmake" "cobol" "coffeescript" "commonlisp" "crystal" "css" "cypher" "d" 31 | "dart" "diff" "django" "dockerfile" "dtd" "dylan" "ebnf" "ecl" "eiffel" 32 | "elixir" "elm" "erlang" "factor" "fcl" "forth" "fortran" "gas" "gherkin" "go" 33 | "groovy" "haml" "handlebars" "haskell" "haskell-literate" "haxe" 34 | "htmlembedded" "htmlmixed" "http" "idl" "javascript" "jinja2" "jsx" "julia" 35 | "livescript" "lua" "markdown" "mathematica" "mbox" "mirc" "mllike" "modelica" 36 | "mscgen" "mumps" "nginx" "nsis" "ntriples" "octave" "oz" "pascal" "pegjs" 37 | "perl" "php" "pig" "powershell" "properties" "protobuf" "pug" "puppet" 38 | "python" "q" "r" "rpm" "rst" "ruby" "rust" "sas" "sass" "scheme" "shell" 39 | "sieve" "slim" "smalltalk" "smarty" "solr" "soy" "sparql" "spreadsheet" "sql" 40 | "stex" "stylus" "swift" "tcl" "textile" "tiddlywiki" "tiki" "toml" "tornado" 41 | "troff" "ttcn" "ttcn-cfg" "turtle" "twig" "vb" "vbscript" "velocity" 42 | "verilog" "vhdl" "vue" "wast" "webidl" "xml" "xquery" "yacas" "yaml" 43 | "yaml-frontmatter" "z80"]) 44 | 45 | (def initial-ui 46 | {:dots true 47 | :filename "hello.py" 48 | :mode "python" 49 | :theme "hopscotch" 50 | :line-numbers false}) 51 | 52 | (defonce state 53 | (r/atom 54 | {:code (str "def hello(who):\n return \"Hello, \" + who + \"!\"\n\nhello(\"Billy\")") 55 | :show-config true 56 | :show-help false 57 | :readme-content nil 58 | :ui initial-ui})) 59 | 60 | (defonce cm-instances 61 | (atom {:code nil})) 62 | 63 | ;*** functions ***; 64 | 65 | (defn toggle-fullscreen [] 66 | (if (.-fullscreenElement js/document) 67 | (.exitFullscreen js/document) 68 | (.requestFullscreen (.-documentElement js/document)))) 69 | 70 | (defn save-state-to-storage [*state] 71 | (try 72 | (let [to-save (select-keys *state [:code :ui]) ; Save :code and :ui 73 | edn-str (pr-str to-save)] 74 | (.setItem js/localStorage "code-editor-state" edn-str)) 75 | (catch js/Error e 76 | (js/console.error "Failed to save state to localStorage:" e)))) 77 | 78 | (defn update-cm-options [cm ui] 79 | (when cm 80 | (.setOption cm "theme" (:theme ui)) 81 | (.setOption cm "mode" (:mode ui)) 82 | (.setOption cm "lineNumbers" (:line-numbers ui)) 83 | (.refresh cm))) 84 | 85 | (defn filter-readme-content [content] 86 | (when content 87 | (->> (str/split-lines content) 88 | (remove #(or 89 | ; h1 headers 90 | (re-find #"^# " %) 91 | ; images 92 | (re-find #"!\[.*\]\(.*\)" %))) 93 | (map (fn [line] 94 | (let [line-with-links (str/replace line #"\[(.*?)\]\((.*?)\)" "$1")] 95 | (cond 96 | ; Convert h2 headers to hiccup 97 | (re-find #"^## " line) 98 | (str "

" (str/replace line #"^## " "") "

") 99 | 100 | ; Convert list items (lines starting with dash) 101 | (re-find #"^- " line) 102 | (str line-with-links "
\n") 103 | 104 | ; Lines with links but not starting with dash 105 | (re-find #"\[.*?\]\(.*?\)" line) 106 | line-with-links 107 | 108 | :else 109 | (str "

" line "

"))))) 110 | (str/join "\n")))) 111 | 112 | (defn load-readme [] 113 | (p/let [response (js/fetch "README.md") 114 | text (when (.-ok response) (.text response))] 115 | (when text 116 | (swap! state assoc :readme-content (filter-readme-content text))))) 117 | 118 | ;*** components ***; 119 | 120 | (defn help-modal [state] 121 | (when (:show-help @state) 122 | [:div.modal-overlay 123 | {:on-click #(swap! state assoc :show-help false)} 124 | [:div.modal-content 125 | {:on-click (fn [e] (.stopPropagation e))} 126 | [:div.modal-header 127 | [:h2 "About CodeShow"] 128 | [:button.close-button 129 | {:on-click #(swap! state assoc :show-help false)} 130 | "×"]] 131 | [:div.modal-body 132 | (if-let [content (:readme-content @state)] 133 | [:div 134 | [:div {:dangerouslySetInnerHTML {:__html content}}] 135 | [:p 136 | [:a {:href "https://github.com/chr15m/codeshow" :target "_blank"} 137 | "Source code on GitHub"] "."] 138 | [:p 139 | "Made by " 140 | [:a {:href "https://mccormick.cx" :target "_blank"} 141 | "Chris McCormick"] "."]] 142 | [:p "Loading README..."])]]])) 143 | 144 | (defn config-strip [state] 145 | (let [ui (:ui @state)] 146 | (when (:show-config @state) 147 | [:div.config-strip 148 | [:button {:on-click #(swap! state update-in [:ui :dots] not)} 149 | (if (:dots ui) "✅ Dots" "🚫 Dots")] 150 | [:input {:type "text" 151 | :placeholder "filename" 152 | :value (:filename ui) 153 | :on-change #(swap! state assoc-in [:ui :filename] 154 | (-> % .-target .-value))}] 155 | [:button {:on-click #(swap! state update-in [:ui :line-numbers] not)} 156 | (if (:line-numbers ui) "✅ Line #" "🚫 Line #")] 157 | [:select {:value (:mode ui) 158 | :on-change #(swap! state assoc-in [:ui :mode] 159 | (-> % .-target .-value))} 160 | (for [mode-id modes] 161 | ^{:key mode-id} [:option {:value mode-id} mode-id])] 162 | [:select {:value (:theme ui) 163 | :on-change #(swap! state assoc-in [:ui :theme] 164 | (-> % .-target .-value))} 165 | (for [theme (sort themes)] 166 | ^{:key theme} [:option {:value theme} 167 | (str/replace theme #"\.css$" "")])] 168 | [:button {:on-click toggle-fullscreen} 169 | "Fullscreen"] 170 | [:button.help-button 171 | {:on-click #(swap! state assoc :show-help true)} 172 | "?"]]))) 173 | 174 | (defn codemirror-editor [state] 175 | (let [ui (:ui @state) 176 | has-filename (seq (:filename ui)) 177 | top-padding (if (or (:dots ui) has-filename) "2em" "1em") 178 | filename-display (if (empty? (:filename ui)) "none" "block")] 179 | [:div.CodeMirror-themed-filename.cm-comment 180 | {:class (when (:dots ui) "threedots") 181 | :style {"--filename" (str "\"" (:filename ui) "\"") 182 | "--top-padding" top-padding 183 | "--filename-display" filename-display}} 184 | [:div.editor-container 185 | {;:on-mouse-enter #(swap! state assoc :show-config false) 186 | ;:on-mouse-leave #(swap! state assoc :show-config true) 187 | :ref (fn [el] 188 | (when el 189 | (let [cm-options #js {:value (:code @state) 190 | :mode (:mode ui) 191 | :theme (:theme ui) 192 | :lineNumbers (:line-numbers ui) 193 | :matchBrackets true 194 | :autoCloseBrackets true 195 | :lineWrapping true 196 | ; Render all lines for auto height 197 | :viewportMargin js/Infinity}] 198 | (if-let [cm (:code @cm-instances)] 199 | (update-cm-options cm ui) 200 | (let [cm (js/CodeMirror el cm-options)] 201 | (.on cm "change" (fn [_ _] 202 | (let [new-value (.getValue cm)] 203 | (swap! state assoc :code new-value) 204 | (save-state-to-storage @state)))) 205 | (.refresh cm) 206 | (swap! cm-instances assoc :code cm))))))}]])) 207 | 208 | (defn app [state] 209 | [:div.app-container 210 | [config-strip state] 211 | [:div.screenshot-wrapper 212 | [codemirror-editor state]] 213 | [help-modal state]]) 214 | 215 | ;*** launch ***; 216 | 217 | (rdom/render [app state] (.getElementById js/document "app")) 218 | 219 | (defn update-dynamic-settings! [*state] 220 | (let [{:keys [mode theme]} (:ui *state) 221 | style-el (js/document.getElementById "theme") 222 | lang-el (js/document.getElementById "mode")] 223 | (aset style-el "href" (str cdn-root "/theme/" theme ".min.css")) 224 | (let [new-lang-el (.createElement js/document "script") 225 | parent-node (.-parentNode lang-el) 226 | new-src (str cdn-root "/mode/" mode "/" mode ".min.js")] 227 | (aset new-lang-el "id" "mode") 228 | (aset new-lang-el "src" new-src) 229 | (aset new-lang-el "onload" 230 | #(when-let [cm (:code @cm-instances)] 231 | (update-cm-options cm (:ui *state)))) 232 | (.replaceChild parent-node new-lang-el lang-el)))) 233 | 234 | (add-watch state :ui-watcher 235 | (fn [_ _ old-state new-state] 236 | (when (not= (:ui old-state) (:ui new-state)) 237 | (update-dynamic-settings! new-state) 238 | (save-state-to-storage new-state)))) 239 | 240 | (try 241 | (when-let [saved-state (.getItem js/localStorage "code-editor-state")] 242 | (swap! state merge (read-string saved-state)) 243 | (some-> (:code @cm-instances) .getDoc (.setValue (:code @state)))) 244 | (catch :default _e nil)) 245 | 246 | (defonce event-handlers 247 | (let [body (.-body js/document)] 248 | (.addEventListener body "mouseenter" 249 | #(swap! state assoc :show-config true)) 250 | (.addEventListener body "mouseleave" 251 | #(when (not= (aget js/document "activeElement" "tagName") 252 | "SELECT") 253 | (swap! state assoc :show-config false))) 254 | (.addEventListener body "click" 255 | #(when (identical? (.-target %) body) 256 | (swap! state update :show-config not))))) 257 | 258 | (load-readme) 259 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr15m/codeshow/cea60335f49094b4f87b821d5ea2568b7987b976/screenshot.png -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | margin: 0; 4 | padding: 0; 5 | box-sizing: border-box; 6 | background-color: #1C1B22; 7 | font-family: sans-serif; 8 | } 9 | 10 | body { 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | min-height: 100vh; 15 | padding: 0; 16 | box-sizing: border-box; 17 | } 18 | 19 | #app { 20 | display: flex; 21 | box-sizing: border-box; 22 | } 23 | 24 | .app-container { 25 | display: flex; 26 | flex-direction: column; 27 | align-items: center; 28 | width: 100%; 29 | overflow: visible; 30 | box-sizing: border-box; 31 | } 32 | 33 | .config-strip { 34 | box-sizing: border-box; 35 | display: flex; 36 | align-items: center; 37 | justify-content: center; 38 | gap: 10px; 39 | padding: 8px 12px; 40 | background-color: #151718; 41 | border-bottom: 1px solid #333; 42 | border-left: 1px solid #333; 43 | border-right: 1px solid #333; 44 | position: absolute; 45 | top: 0px; 46 | left: 50%; 47 | transform: translate(-50%, 0); 48 | overflow-x: auto; 49 | flex-wrap: wrap; 50 | z-index: 10; 51 | width: 100%; 52 | max-width: 100vw; 53 | } 54 | 55 | .config-strip button, 56 | .config-strip input, 57 | .config-strip select { 58 | padding: 5px 8px; 59 | font-size: 14px; 60 | border: 1px solid #444; 61 | background-color: #2a2d2e; 62 | color: #ccc; 63 | border-radius: 3px; 64 | } 65 | 66 | .config-strip button { 67 | cursor: pointer; 68 | text-wrap: nowrap; 69 | } 70 | 71 | .config-strip button:hover { 72 | background-color: #3a3d3e; 73 | } 74 | 75 | .config-strip input { 76 | flex-grow: 1; 77 | min-width: 10ch; 78 | max-width: 50ch; 79 | } 80 | 81 | .config-strip select { 82 | cursor: pointer; 83 | max-width: 150px; 84 | } 85 | 86 | .screenshot-wrapper { 87 | padding: 20px; 88 | background-color: #1C1B22; 89 | border-radius: 4px; 90 | position: relative; 91 | margin-top: 60px; 92 | max-width: 95vw; 93 | box-sizing: border-box; 94 | } 95 | 96 | .editor-container { 97 | overflow: visible; 98 | position: relative; 99 | height: auto; 100 | box-shadow: 0 0px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); 101 | border-radius: 4px; 102 | } 103 | 104 | .CodeMirror { 105 | font-family: 'Anka Coder', 'Source Code Pro', monospace; 106 | font-size: 40px; 107 | line-height: 1.4; 108 | height: auto !important; 109 | width: 100%; 110 | min-width: 600px; 111 | padding: 1em; 112 | position: relative; 113 | box-sizing: border-box; 114 | } 115 | 116 | /* Mobile responsive font size */ 117 | @media screen and (max-width: 768px) { 118 | .CodeMirror { 119 | font-size: 20px; 120 | } 121 | } 122 | 123 | .CodeMirror-scroll { 124 | max-height: 70vh; 125 | } 126 | 127 | .CodeMirror { 128 | padding-top: var(--top-padding, 1em); 129 | padding-left: 1em; 130 | padding-right: 1em; 131 | padding-bottom: 1em; 132 | } 133 | 134 | /* Traffic light dots */ 135 | .threedots .CodeMirror::before { 136 | content: ''; 137 | position: absolute; 138 | top: 0.75em; 139 | left: 1em; 140 | width: 24px; 141 | height: 24px; 142 | background-color: #ff5f56; 143 | border-radius: 50%; 144 | box-shadow: 145 | 40px 0 0 #ffbd2e, 146 | 80px 0 0 #27c93f; 147 | z-index: 1; 148 | } 149 | 150 | @media screen and (max-width: 768px) { 151 | .threedots .CodeMirror::before { 152 | width: 12px; 153 | height: 12px; 154 | box-shadow: 155 | 20px 0 0 #ffbd2e, 156 | 40px 0 0 #27c93f; 157 | } 158 | } 159 | 160 | .CodeMirror::after { 161 | content: var(--filename); 162 | position: absolute; 163 | top: 1em; 164 | left: var(--filename-left, 1em); 165 | font-size: 24px; 166 | font-family: 'Fira Code', 'Source Code Pro', monospace; 167 | display: var(--filename-display, 'block'); 168 | z-index: 1; 169 | white-space: nowrap; 170 | overflow: hidden; 171 | text-overflow: ellipsis; 172 | max-width: calc(100% - 4em); 173 | } 174 | 175 | @media screen and (max-width: 768px) { 176 | .CodeMirror::after { 177 | font-size: 12px; 178 | } 179 | } 180 | 181 | .threedots .CodeMirror::after { 182 | --filename-left: 180px; 183 | } 184 | 185 | @media screen and (max-width: 768px) { 186 | .threedots .CodeMirror::after { 187 | --filename-left: 90px; 188 | } 189 | } 190 | 191 | .CodeMirror-linenumber { 192 | padding-right: 0.5em; 193 | } 194 | 195 | /* Help button styling */ 196 | .help-button { 197 | font-weight: bold; 198 | min-width: 30px; 199 | height: 30px; 200 | border-radius: 3px; 201 | display: flex; 202 | align-items: center; 203 | justify-content: center; 204 | flex-shrink: 0; 205 | } 206 | 207 | /* Modal styles */ 208 | .modal-overlay { 209 | position: fixed; 210 | top: 0; 211 | left: 0; 212 | right: 0; 213 | bottom: 0; 214 | background-color: rgba(0, 0, 0, 0.3); 215 | display: flex; 216 | justify-content: center; 217 | align-items: center; 218 | z-index: 1000; 219 | } 220 | 221 | .modal-content { 222 | background-color: #151718; 223 | border-radius: 5px; 224 | width: 80%; 225 | max-width: 800px; 226 | max-height: 80vh; 227 | overflow: auto; 228 | color: #ccc; 229 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); 230 | } 231 | 232 | .modal-header { 233 | display: flex; 234 | justify-content: space-between; 235 | align-items: center; 236 | padding: 15px 20px; 237 | border-bottom: 1px solid #444; 238 | } 239 | 240 | .modal-header h2 { 241 | margin: 0; 242 | font-size: 1.5rem; 243 | } 244 | 245 | .close-button { 246 | background: none; 247 | border: none; 248 | font-size: 1.5rem; 249 | color: #ccc; 250 | cursor: pointer; 251 | } 252 | 253 | .modal-body { 254 | padding: 20px; 255 | overflow-y: auto; 256 | max-height: calc(80vh - 70px); 257 | } 258 | 259 | .modal-body pre { 260 | white-space: pre-wrap; 261 | word-break: break-word; 262 | font-family: monospace; 263 | line-height: 1.5; 264 | font-size: 1.25em; 265 | } 266 | 267 | .modal-body a { 268 | color: red; 269 | } 270 | --------------------------------------------------------------------------------