├── .eslintignore ├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── logo.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── src │ ├── main.rs │ └── lib.rs ├── capabilities │ └── default.json ├── Cargo.toml └── tauri.conf.json ├── assets ├── screen.png ├── favicon.ico ├── vertical.png ├── horizontal.png ├── fonts │ ├── CascadiaCodePL.woff2 │ └── CascadiaCodePLItalic.woff2 ├── html5.svg ├── typescript.svg ├── favicon.svg ├── js.svg └── css.svg ├── eslint.config.js ├── .prettierrc ├── vercel.json ├── src ├── components │ ├── codi-editor │ │ ├── codi-editor.js │ │ ├── extensions │ │ │ ├── register-themes.js │ │ │ ├── editor-hotkeys.js │ │ │ └── autocomplete-html-tag.js │ │ ├── CodiEditor.styles.js │ │ ├── CodiEditor.js │ │ └── themes.json │ └── layout-preview │ │ ├── layout-preview.jsx │ │ ├── LayoutPreview.js │ │ └── layout-preview.css ├── utils │ ├── index.js │ ├── debounce.js │ ├── string.js │ ├── createHtml.js │ ├── translator.js │ ├── dom.js │ ├── run-js.js │ ├── WindowPreviewer.js │ ├── js-execution-worker.js │ └── notification.js ├── theme.js ├── language.js ├── css │ ├── fonts.css │ ├── animations.css │ ├── drag-drop-area.css │ ├── notifications.css │ ├── settings.css │ ├── skypack.css │ ├── history.css │ ├── aside.css │ ├── console.css │ ├── base.css │ └── editors-layout.css ├── constants │ ├── button-actions.js │ ├── editor-grid-template.js │ ├── initial-settings.js │ ├── console-icons.js │ └── grid-templates.js ├── state.js ├── monaco-prettier │ └── configurePrettier.js ├── editor.js ├── scroll.js ├── settings.js ├── drag-file.js ├── download.js ├── language │ ├── en.js │ ├── pt.js │ └── es.js ├── aside.js ├── events-controller.js ├── style.css ├── grid.js ├── console-script.js ├── main.js ├── skypack.js ├── history.js └── console.js ├── vite.config.js ├── .gitignore ├── .github └── workflows │ ├── conflicts.yml │ ├── release.yml │ └── pipeline.yml ├── git-hooks └── pre-commit ├── package.json ├── CONTRIBUTING.md ├── README.md ├── LICENSE └── index.html /.eslintignore: -------------------------------------------------------------------------------- 1 | ./src-tauri/target/** -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /assets/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/assets/screen.png -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/assets/favicon.ico -------------------------------------------------------------------------------- /assets/vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/assets/vertical.png -------------------------------------------------------------------------------- /assets/horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/assets/horizontal.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/logo.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /assets/fonts/CascadiaCodePL.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/assets/fonts/CascadiaCodePL.woff2 -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import neostandard from 'neostandard' 2 | 3 | export default neostandard({ 4 | noStyle: true 5 | }) 6 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /gen/schemas 5 | -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /assets/fonts/CascadiaCodePLItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/codi.link/main/assets/fonts/CascadiaCodePLItalic.woff2 -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [ 3 | { "handle": "filesystem" }, 4 | { "src": "/(.*)", "dest": "/index.html" } 5 | ] 6 | } -------------------------------------------------------------------------------- /src/components/codi-editor/codi-editor.js: -------------------------------------------------------------------------------- 1 | import { CodiEditor } from './CodiEditor.js' 2 | 3 | window.customElements.define('codi-editor', CodiEditor) 4 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | app_lib::run(); 6 | } 7 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import preact from '@preact/preset-vite' 3 | import tailwindcss from '@tailwindcss/vite' 4 | 5 | export default defineConfig({ 6 | plugins: [preact(), tailwindcss()] 7 | }) 8 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { $$, $, setFormControlValue } from './dom' 2 | export { capitalize, searchByLine } from './string' 3 | export { default as debounce } from './debounce' 4 | export { default as WindowPreviewer } from './WindowPreviewer' 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | package-lock.json 7 | yarn.lock 8 | pnpm-lock.yaml 9 | 10 | # cypress 11 | cypress/plugins/ 12 | cypress/support/ 13 | cypress/screenshots/ 14 | cypress/videos/ 15 | 16 | # tauri 17 | src-tauri/target/ 18 | -------------------------------------------------------------------------------- /src/components/codi-editor/extensions/register-themes.js: -------------------------------------------------------------------------------- 1 | import themesJson from '../themes.json' 2 | 3 | export function registerThemes (monaco) { 4 | Object.entries(themesJson).forEach(([themeName, themeDef]) => { 5 | monaco.editor.defineTheme(themeName, themeDef) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "enables the default permissions", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/theme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Change the color theme used in the workbench. 3 | * @param {'vs-dark' | 'vs' | 'hc-black' | 'hc-light'} theme 4 | */ 5 | 6 | const setTheme = theme => { 7 | document.documentElement.setAttribute('data-theme', theme) 8 | } 9 | 10 | export default setTheme 11 | -------------------------------------------------------------------------------- /src/language.js: -------------------------------------------------------------------------------- 1 | import { translate } from './utils/translator.js' 2 | 3 | /** 4 | * Change the language used in the workbench. 5 | * @param {'en' | 'es' | 'pt'} language 6 | */ 7 | const setLanguage = language => { 8 | document.documentElement.setAttribute('data-language', language) 9 | translate(language) 10 | } 11 | 12 | export default setLanguage 13 | -------------------------------------------------------------------------------- /src/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Cascadia Code PL"; 3 | src: url("/assets/fonts/CascadiaCodePL.woff2") format("woff2"); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: "Cascadia Code PL"; 10 | src: url("/assets/fonts/CascadiaCodePLItalic.woff2") format("woff2"); 11 | font-weight: normal; 12 | font-style: italic; 13 | } 14 | -------------------------------------------------------------------------------- /assets/html5.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/typescript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/conflicts.yml: -------------------------------------------------------------------------------- 1 | name: Label Pull Requests with Conflicts 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | triage: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: mschilde/auto-label-merge-conflicts@master 12 | with: 13 | CONFLICT_LABEL_NAME: "has conflicts" 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | MAX_RETRIES: 5 16 | WAIT_MS: 5000 -------------------------------------------------------------------------------- /src/css/animations.css: -------------------------------------------------------------------------------- 1 | @keyframes spin { 2 | 0% { 3 | transform: rotate(-360deg); 4 | } 5 | } 6 | 7 | @keyframes fadeInSlideUp { 8 | 0% { 9 | opacity: 0; 10 | transform: translateY(20px); 11 | } 12 | 100% { 13 | opacity: 1; 14 | transform: translateY(0); 15 | } 16 | } 17 | 18 | @keyframes fadeOutSlideDown { 19 | 0% { 20 | opacity: 1; 21 | } 22 | 23 | 100% { 24 | opacity: 0; 25 | transform: translateY(20px); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/constants/button-actions.js: -------------------------------------------------------------------------------- 1 | export const BUTTON_ACTIONS = { 2 | downloadUserCode: 'download-user-code', 3 | openIframeTab: 'open-iframe-tab', 4 | copyToClipboard: 'copy-to-clipboard', 5 | closeAsideBar: 'close-aside-bar', 6 | showSkypackBar: 'show-skypack-bar', 7 | showSettingsBar: 'show-settings-bar', 8 | showConsoleBar: 'show-console-bar', 9 | showHistoryBar: 'show-history-bar', 10 | clearHistory: 'clear-history', 11 | openNewInstance: 'open-new-instance' 12 | } 13 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 2 | pub fn run() { 3 | tauri::Builder::default() 4 | .setup(|app| { 5 | if cfg!(debug_assertions) { 6 | app.handle().plugin( 7 | tauri_plugin_log::Builder::default() 8 | .level(log::LevelFilter::Info) 9 | .build(), 10 | )?; 11 | } 12 | Ok(()) 13 | }) 14 | .run(tauri::generate_context!()) 15 | .expect("error while running tauri application"); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/layout-preview/layout-preview.jsx: -------------------------------------------------------------------------------- 1 | import register from 'preact-custom-element' 2 | import './layout-preview.css' 3 | 4 | function LayoutPreview ({ active, layout }) { 5 | return ( 6 | <> 7 |
8 |
9 |
10 |
11 | 12 | ) 13 | } 14 | 15 | // Register the Preact component as a custom element 16 | register(LayoutPreview, 'layout-preview', ['active', 'layout'], { shadow: false }) 17 | -------------------------------------------------------------------------------- /src/state.js: -------------------------------------------------------------------------------- 1 | import { persist } from 'zustand/middleware' 2 | import { createStore } from 'zustand/vanilla' 3 | 4 | import { DEFAULT_INITIAL_SETTINGS } from './constants/initial-settings' 5 | 6 | const useStore = createStore( 7 | persist( 8 | (set, get) => ({ 9 | ...DEFAULT_INITIAL_SETTINGS, 10 | updateSettings: ({ key, value }) => { 11 | set({ [key]: value }) 12 | } 13 | }), 14 | { name: 'appInitialState' } 15 | ) 16 | ) 17 | 18 | export const { getState, setState, subscribe } = useStore 19 | -------------------------------------------------------------------------------- /src/utils/debounce.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Function to create a debounce function 5 | * @param {function} func Function to debounce 6 | * @param {number} msWait Number of milliseconds to wait before calling function 7 | * @returns {function} Debounce function 8 | */ 9 | export default function debounce (func, msWait) { 10 | let timeout 11 | return function (...args) { 12 | const context = this 13 | clearTimeout(timeout) 14 | timeout = setTimeout(() => func.apply(context, args), msWait) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/constants/editor-grid-template.js: -------------------------------------------------------------------------------- 1 | export const EDITOR_GRID_TEMPLATE = { 2 | vertical: 'grid-template-columns: 1fr 8px 1fr 8px 1fr 8px 1fr; grid-template-rows: 1fr', 3 | horizontal: 'grid-template-columns: 1fr; grid-template-rows: 1fr 8px 1fr 8px 1fr 8px 1fr', 4 | bottom: 'grid-template-columns: 1fr 8px 1fr 8px 1fr; grid-template-rows: 1fr 8px 1fr', 5 | tabs: 'grid-template-columns: 5fr 8px 3fr, grid-template-rows: 40px 1fr' 6 | } 7 | 8 | export const DEFAULT_GRID_TEMPLATE = 'grid-template-columns: 1fr 8px 1fr; grid-template-rows: 1fr 8px 1fr' 9 | -------------------------------------------------------------------------------- /git-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | RED="\033[1;31m" 4 | GREEN="\033[1;32m" 5 | NC="\033[0m" 6 | 7 | echo "Executing pre-commit" 8 | 9 | linter_exit_code=1 10 | staged_js_files=$(git diff --cached --diff-filter=d --name-only | grep -E '\.(js|jsx)$') 11 | bunx standard $staged_js_files --quiet --fix 12 | linter_exit_code=$? 13 | git add -f $staged_js_files 14 | 15 | if [ $linter_exit_code -ne 0 ] 16 | then 17 | echo "${RED} ❌ Linter errors have occurred ${NC}" 18 | exit 1 19 | else 20 | echo "${GREEN} ✔ Linter did not find any errors ${NC}" 21 | exit 0 22 | fi -------------------------------------------------------------------------------- /assets/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/monaco-prettier/configurePrettier.js: -------------------------------------------------------------------------------- 1 | export const configurePrettierHotkeys = editors => { 2 | window.onresize = function () { 3 | editors.forEach(e => e.layout()) 4 | } 5 | 6 | const alt = e => 7 | navigator.userAgent.toLowerCase().includes('mac') 8 | ? e.metaKey 9 | : e.ctrlKey 10 | 11 | const hotKeys = (e) => { 12 | editors.forEach(editor => { 13 | // Control/Command + P 14 | if (alt(e) && e.keyCode === 80) { 15 | e.preventDefault() 16 | editor.trigger('anyString', 'editor.action.quickCommand') 17 | } 18 | }) 19 | } 20 | 21 | window.addEventListener('keydown', hotKeys) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/layout-preview/LayoutPreview.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit' 2 | import { LayoutPreviewStyles } from './LayoutPreview.styles.js' 3 | 4 | export class LayoutPreview extends LitElement { 5 | static get styles () { 6 | return LayoutPreviewStyles 7 | } 8 | 9 | static get properties () { 10 | return { 11 | active: { 12 | type: Boolean 13 | }, 14 | layout: { 15 | type: String 16 | } 17 | } 18 | } 19 | 20 | render () { 21 | return html` 22 |
23 |
24 |
25 |
26 | ` 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /assets/js.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | license = "" 7 | repository = "" 8 | edition = "2021" 9 | rust-version = "1.77.2" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [lib] 14 | name = "app_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2.0.2", features = [] } 19 | 20 | [dependencies] 21 | serde_json = "1.0" 22 | serde = { version = "1.0", features = ["derive"] } 23 | log = "0.4" 24 | tauri = { version = "2.1.0", features = [] } 25 | tauri-plugin-log = "2.0.0-rc" 26 | -------------------------------------------------------------------------------- /src/components/codi-editor/CodiEditor.styles.js: -------------------------------------------------------------------------------- 1 | import { css } from 'lit' 2 | 3 | export const CodiEditorStyles = css` 4 | :host { 5 | position: relative; 6 | overflow: hidden; 7 | } 8 | 9 | img { 10 | position: absolute; 11 | right: 16px; 12 | bottom: 16px; 13 | z-index: 9; 14 | width: 32px; 15 | height: 32px; 16 | object-fit: contain; 17 | object-position: center; 18 | pointer-events: none; 19 | } 20 | 21 | slot:hover + img { 22 | opacity: 0.2; 23 | } 24 | 25 | slot:focus-within + img { 26 | opacity: 0.1; 27 | } 28 | 29 | 30 | @media (max-width: 650px) { 31 | :host::after { 32 | left: 16px; 33 | right: unset; 34 | } 35 | 36 | img { 37 | left: 16px; 38 | right: unset; 39 | } 40 | } 41 | ` 42 | -------------------------------------------------------------------------------- /src/constants/initial-settings.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_GRID_TEMPLATE } from './editor-grid-template' 2 | import { DEFAULT_LAYOUT } from './grid-templates' 3 | 4 | export const DEFAULT_INITIAL_SETTINGS = { 5 | fontFamily: "'Cascadia Code PL', 'Menlo', 'Monaco', 'Courier New', 'monospace'", 6 | fontLigatures: 'on', 7 | fontSize: 18, 8 | lineNumbers: 'off', 9 | tabSize: 2, 10 | maxExecutionTime: 200, 11 | minimap: false, 12 | preserveGrid: true, 13 | theme: 'vs-dark', 14 | language: 'es', 15 | sidebar: 'default', 16 | wordWrap: 'on', 17 | zipFileName: 'codi.link', 18 | zipInSingleFile: false, 19 | saveLocalstorage: true, 20 | layout: { 21 | gutters: DEFAULT_LAYOUT, 22 | style: DEFAULT_GRID_TEMPLATE, 23 | type: 'default' 24 | }, 25 | cursorBlinking: 'blink', 26 | cursorSmoothCaretAnimation: 'off' 27 | } 28 | -------------------------------------------------------------------------------- /src/editor.js: -------------------------------------------------------------------------------- 1 | import { getState } from './state.js' 2 | 3 | const { 4 | fontSize, 5 | lineNumbers, 6 | minimap, 7 | theme, 8 | wordWrap, 9 | fontLigatures, 10 | fontFamily, 11 | tabSize, 12 | cursorBlinking, 13 | cursorSmoothCaretAnimation 14 | } = getState() 15 | 16 | const COMMON_EDITOR_OPTIONS = { 17 | fontSize, 18 | lineNumbers, 19 | tabSize, 20 | minimap: { 21 | enabled: minimap 22 | }, 23 | wordWrap, 24 | theme, 25 | fontLigatures, 26 | fontFamily, 27 | cursorBlinking, 28 | cursorSmoothCaretAnimation, 29 | 30 | automaticLayout: true, 31 | fixedOverflowWidgets: true, 32 | scrollBeyondLastLine: false, 33 | roundedSelection: false, 34 | padding: { 35 | top: 16 36 | } 37 | } 38 | 39 | export const createEditor = (domElement) => domElement.createEditor({ ...COMMON_EDITOR_OPTIONS }) 40 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "productName": "codi.link", 4 | "version": "0.1.0", 5 | "identifier": "midudev.codi.link", 6 | "build": { 7 | "frontendDist": "../dist", 8 | "devUrl": "http://localhost:5173", 9 | "beforeDevCommand": "bun dev", 10 | "beforeBuildCommand": "bun run build" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "codi.link", 16 | "width": 800, 17 | "height": 600, 18 | "resizable": true, 19 | "fullscreen": false 20 | } 21 | ], 22 | "security": { 23 | "csp": null 24 | } 25 | }, 26 | "bundle": { 27 | "active": true, 28 | "targets": "all", 29 | "icon": [ 30 | "icons/32x32.png", 31 | "icons/128x128.png", 32 | "icons/128x128@2x.png", 33 | "icons/icon.icns", 34 | "icons/icon.ico" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/string.js: -------------------------------------------------------------------------------- 1 | import Notification from './notification.js' 2 | 3 | export const capitalize = (str) => { 4 | return str 5 | .split('-') 6 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 7 | .join('') 8 | } 9 | 10 | /** 11 | * Search for a string line in a string 12 | * @param {string} str Search text 13 | * @param {string} input Where to search 14 | * @param {number} lines In how many lines do we want to search 15 | * @returns {string} Line finded 16 | */ 17 | export const searchByLine = (str, input, lines = 10) => { 18 | const linesArr = str.split('\n') 19 | const parsedInput = input.endsWith(';') ? input.slice(0, -1) : input 20 | return linesArr.slice(0, lines).find((line) => line.includes(parsedInput)) 21 | } 22 | 23 | export const copyToClipboard = (str) => { 24 | Notification.show({ type: 'info', message: 'Shareable URL has been copied to clipboard.' }) 25 | return navigator.clipboard.writeText(str) 26 | } 27 | -------------------------------------------------------------------------------- /src/constants/console-icons.js: -------------------------------------------------------------------------------- 1 | export const CONSOLE_BADGES = { 2 | 'log:log': { label: 'LOG', color: '#9cdcfe' }, 3 | 'log:info': { label: 'INFO', color: '#4fc3f7' }, 4 | 'log:warn': { label: 'WARN', color: '#ffa726' }, 5 | 'log:error': { label: 'ERROR', color: '#ef5350' }, 6 | 'log:debug': { label: 'DEBUG', color: '#ab47bc' }, 7 | 'log:table': { label: 'TABLE', color: '#66bb6a' }, 8 | 'log:count': { label: 'COUNT', color: '#26c6da' }, 9 | 'log:trace': { label: 'TRACE', color: '#8d6e63' }, 10 | 'log:dir': { label: 'DIR', color: '#78909c' }, 11 | 'log:dirxml': { label: 'DIRXML', color: '#78909c' }, 12 | 'log:time': { label: 'TIME', color: '#ffd54f' }, 13 | 'log:assert': { label: 'ASSERT', color: '#ff7043' }, 14 | error: null 15 | } 16 | 17 | export const createConsoleBadge = (type) => { 18 | const badge = CONSOLE_BADGES[type] 19 | if (!badge) return '' 20 | return `${badge.label}` 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/createHtml.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { generateConsoleScript } from '../console-script' 4 | 5 | /** 6 | * Create an index.html content from provided data 7 | * @param {object} params - The parameters 8 | * @param {string} params.css - CSS 9 | * @param {string} params.html - HTML content 10 | * @param {string} params.js - JavaScript 11 | * @param {boolean} isEditor - Whether the code is being run in the editor or preview 12 | * @returns {string} 13 | */ 14 | export const createHtml = ({ css, html, js }, isEditor = false) => { 15 | return ` 16 | 17 | 18 | 19 | 20 | 23 | ${isEditor ? generateConsoleScript({ html, css }) : ''} 24 | 25 | 26 | ${html} 27 | 30 | 31 | ` 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/translator.js: -------------------------------------------------------------------------------- 1 | import en from '../language/en' 2 | import es from '../language/es' 3 | import pt from '../language/pt' 4 | 5 | const translations = { 6 | en, 7 | es, 8 | pt 9 | } 10 | 11 | function makeTranslation (key, language) { 12 | return translations[language][key] || key 13 | } 14 | 15 | function updateContent (language = 'en') { 16 | const elements = document.querySelectorAll('[data-translate]') 17 | elements.forEach(element => { 18 | const key = element.getAttribute('data-translate') 19 | element.innerText = makeTranslation(key, language) 20 | }) 21 | } 22 | 23 | function updatePlaceholders (language = 'en') { 24 | const elements = document.querySelectorAll('[data-translate-placeholder]') 25 | elements.forEach(element => { 26 | const key = element.getAttribute('data-translate-placeholder') 27 | element.placeholder = makeTranslation(key, language) 28 | }) 29 | } 30 | 31 | function translate (language) { 32 | updateContent(language) 33 | updatePlaceholders(language) 34 | } 35 | 36 | export { translate } 37 | -------------------------------------------------------------------------------- /src/css/drag-drop-area.css: -------------------------------------------------------------------------------- 1 | .open-file-dragging { 2 | width: 100%; 3 | & .zone-drag-drop { 4 | width: 100%; 5 | height: 100vh; 6 | background: #00000065; 7 | color: #fff; 8 | opacity: 0.5; 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | justify-content: center; 13 | position: relative; 14 | transition: 0.3s opacity ease; 15 | &.focus { 16 | opacity: 1; 17 | } 18 | & strong { 19 | position: absolute; 20 | margin-bottom: 48px; 21 | font-size: 1.2rem; 22 | } 23 | & input { 24 | width: 100%; 25 | height: 100%; 26 | opacity: 0; 27 | } 28 | & svg { 29 | height: 64px; 30 | width: 64px; 31 | position: absolute; 32 | margin-top: 52px; 33 | } 34 | } 35 | } 36 | 37 | .overlay-drag { 38 | width: 100%; 39 | height: 100vh; 40 | display: flex; 41 | align-items: center; 42 | justify-content: center; 43 | background: #0000007a; 44 | position: absolute; 45 | top: 0; 46 | z-index: 99999; 47 | 48 | &.hidden { 49 | display: none; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} selector 3 | * @param {ParentNode} context 4 | */ 5 | export const $ = (selector, context = document) => 6 | context.querySelector(selector) 7 | 8 | export const $$ = (selector, context = document) => 9 | context.querySelectorAll(selector) 10 | 11 | export const isNodeSelect = el => el.nodeName === 'SELECT' 12 | export const isNodeCheckbox = el => el.nodeName === 'INPUT' && el.type === 'checkbox' 13 | export const isNodeRadio = el => el.nodeName === 'INPUT' && el.type === 'radio' 14 | 15 | const updateSelectValue = (el, value) => { 16 | const optionToSelect = el.querySelector(`option[value="${value}"]`) 17 | if (!optionToSelect) return console.warn('Option to initialized not found') 18 | optionToSelect.setAttribute('selected', '') 19 | } 20 | 21 | export const setFormControlValue = (el, value) => { 22 | const isSelect = isNodeSelect(el) 23 | const isCheckbox = isNodeCheckbox(el) 24 | const isRadio = isNodeRadio(el) 25 | 26 | if (isSelect) updateSelectValue(el, value) 27 | else if (isCheckbox || isRadio) el.checked = value 28 | else el.value = value 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/run-js.js: -------------------------------------------------------------------------------- 1 | export default function runJs (code, timeout = 200) { 2 | const startTime = Date.now() 3 | 4 | return new Promise((resolve, reject) => { 5 | const worker = new window.Worker(new URL('./js-execution-worker.js', import.meta.url)) 6 | 7 | const logError = (message) => { 8 | window.parent.postMessage({ 9 | console: { 10 | type: 'loop', 11 | payload: { message } 12 | } 13 | }, document.location.origin) 14 | } 15 | 16 | const timeoutId = setTimeout(() => { 17 | worker.terminate() 18 | logError('Process terminated to avoid infinite loop') 19 | 20 | const executionTime = Date.now() - startTime 21 | reject(new Error(`Execution timed out after ${executionTime}ms`)) 22 | }, timeout) 23 | 24 | worker.onmessage = (e) => { 25 | clearTimeout(timeoutId) 26 | 27 | const data = e.data 28 | worker.terminate() 29 | 30 | if (data.error) { 31 | logError(data.error) 32 | // reject(data.error) 33 | } 34 | 35 | resolve(data.result) 36 | } 37 | 38 | worker.postMessage({ code }) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/codi-editor/extensions/editor-hotkeys.js: -------------------------------------------------------------------------------- 1 | import * as monaco from 'monaco-editor' 2 | import { $ } from '../../../utils/dom.js' 3 | import { copyToClipboard } from '../../../utils/string' 4 | 5 | export const initEditorHotKeys = (editor) => { 6 | // Shortcut: Open/Close Settings 7 | editor.addAction({ 8 | id: 'toggle-settings', 9 | label: 'Toggle Settings', 10 | keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Comma], 11 | contextMenuGroupId: 'navigation', 12 | contextMenuOrder: 1.5, 13 | // Method that will be executed when the action is triggered. 14 | // @param editor The editor instance is passed in as a convenience 15 | run: () => { 16 | const $settingsButton = $("button[data-action='show-settings-bar']") 17 | $settingsButton && $settingsButton.click() 18 | } 19 | }) 20 | 21 | // Shortcut: Copy URL 22 | editor.addCommand( 23 | monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyC, 24 | () => { 25 | const url = new URL(window.location.href) 26 | const urlToCopy = `https://codi.link${url.pathname}` 27 | copyToClipboard(urlToCopy) 28 | } 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/scroll.js: -------------------------------------------------------------------------------- 1 | import { $ } from './utils/dom.js' 2 | 3 | const $buttonUp = $('.button-up') 4 | const $buttonDown = $('.button-down') 5 | 6 | const previewersId = ['editor-preview', 'markup', 'script', 'style'] 7 | let curretPreviewer = 0 8 | 9 | const updateButtonsStatus = (curretPreviewer) => { 10 | $buttonUp.disabled = curretPreviewer === 0 11 | $buttonDown.disabled = curretPreviewer === previewersId.length - 1 12 | } 13 | 14 | const updatePreviewer = (curretPreviewer) => { 15 | previewersId.forEach((previewer, index) => { 16 | const element = $(`#${previewer}`) 17 | 18 | if (curretPreviewer === index) { 19 | element.classList.remove('previewer-hide') 20 | element.classList.add('previewer-active') 21 | } else { 22 | element.classList.add('previewer-hide') 23 | element.classList.remove('previewer-active') 24 | } 25 | }) 26 | } 27 | 28 | $buttonUp.addEventListener('click', (ev) => { 29 | curretPreviewer -= 1 30 | updateButtonsStatus(curretPreviewer) 31 | updatePreviewer(curretPreviewer) 32 | }) 33 | 34 | $buttonDown.addEventListener('click', () => { 35 | curretPreviewer += 1 36 | updateButtonsStatus(curretPreviewer) 37 | updatePreviewer(curretPreviewer) 38 | }) 39 | -------------------------------------------------------------------------------- /src/utils/WindowPreviewer.js: -------------------------------------------------------------------------------- 1 | import { createHtml } from './createHtml' 2 | 3 | let previewUrl = null 4 | let previewWindowRef = null 5 | 6 | export function getPreviewUrl () { 7 | return previewUrl 8 | } 9 | 10 | export function updatePreview ({ html, css, js }) { 11 | if (previewUrl) { 12 | URL.revokeObjectURL(previewUrl) 13 | } 14 | 15 | const htmlForPreview = createHtml({ html, css, js }, true) 16 | 17 | const blob = new window.Blob([htmlForPreview], { type: 'text/html' }) 18 | 19 | previewUrl = URL.createObjectURL(blob) 20 | 21 | if (previewWindowRef?.deref()) { 22 | previewWindowRef.deref().location = previewUrl 23 | } 24 | } 25 | 26 | export function clearPreview () { 27 | URL.revokeObjectURL(previewUrl) 28 | previewUrl = null 29 | } 30 | 31 | export function showPreviewerWindow () { 32 | const previewWindow = window.open(previewUrl, '_blank') 33 | 34 | // Use a WeafRef so when the user closes the window it could be garbage collected. 35 | // We need to hold a reference so we can update the location of the window when 36 | // the pewview changes. 37 | previewWindowRef = new window.WeakRef(previewWindow) 38 | const title = `${document.title} | Preview` 39 | previewWindow.document.title = title 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codi.link", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "private": true, 6 | "scripts": { 7 | "build": "vite build", 8 | "dev": "vite", 9 | "lint": "bunx standard", 10 | "tauri": "tauri", 11 | "phoenix": "rm -rf ./node_modules && bun install", 12 | "postinstall": "git config core.hooksPath ./git-hooks", 13 | "serve": "vite preview" 14 | }, 15 | "devDependencies": { 16 | "@preact/preset-vite": "2.10.2", 17 | "@tauri-apps/cli": "2.9.4", 18 | "autoprefixer": "10.4.22", 19 | "eslint": "9.39.1", 20 | "neostandard": "0.12.2", 21 | "postcss": "8.5.6", 22 | "postcss-nesting": "13.0.2", 23 | "prettier": "3.6.2", 24 | "tailwindcss": "4.1.17", 25 | "vite": "7.2.2" 26 | }, 27 | "dependencies": { 28 | "@tailwindcss/vite": "4.1.17", 29 | "client-zip": "2.5.0", 30 | "emmet-monaco-es": "5.6.1", 31 | "escape-html": "1.0.3", 32 | "js-base64": "3.7.8", 33 | "lit": "3.3.1", 34 | "monaco-editor": "0.54.0", 35 | "monacopilot": "1.2.7", 36 | "preact": "10.27.2", 37 | "preact-custom-element": "4.6.0", 38 | "split-grid": "1.0.11", 39 | "zustand": "5.0.8" 40 | }, 41 | "postcss": { 42 | "plugins": { 43 | "postcss-nesting": {} 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/constants/grid-templates.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_LAYOUT = { 2 | columnGutters: [{ 3 | track: 1, 4 | element: '.first-gutter' 5 | }, { 6 | track: 1, 7 | element: '.second-gutter' 8 | }], 9 | rowGutters: [{ 10 | track: 1, 11 | element: '.last-gutter' 12 | }, { 13 | track: 1, 14 | element: '.second-gutter' 15 | }] 16 | } 17 | 18 | export const VERTICAL_LAYOUT = { 19 | columnGutters: [{ 20 | track: 1, 21 | element: '.first-gutter' 22 | }, { 23 | track: 3, 24 | element: '.second-gutter' 25 | }, { 26 | track: 5, 27 | element: '.last-gutter' 28 | }] 29 | } 30 | 31 | export const HORIZONTAL_LAYOUT = { 32 | rowGutters: [{ 33 | track: 1, 34 | element: '.first-gutter' 35 | }, { 36 | track: 3, 37 | element: '.second-gutter' 38 | }, { 39 | track: 5, 40 | element: '.last-gutter' 41 | }] 42 | } 43 | 44 | export const BOTTOM_LAYOUT = { 45 | columnGutters: [{ 46 | track: 1, 47 | element: '.first-gutter' 48 | }, { 49 | track: 3, 50 | element: '.second-gutter' 51 | }], 52 | rowGutters: [{ 53 | track: 1, 54 | element: '.last-gutter' 55 | }] 56 | } 57 | 58 | export const TABS_LAYOUT = { 59 | columnGutters: [{ 60 | track: 1, 61 | element: '.first-gutter' 62 | }] 63 | } 64 | -------------------------------------------------------------------------------- /src/css/notifications.css: -------------------------------------------------------------------------------- 1 | #notifications { 2 | position: relative; 3 | z-index: 999999; 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | 8 | #notifications-wrapper { 9 | position: fixed; 10 | width: fit-content; 11 | height: fit-content; 12 | bottom: 10px; 13 | right: 10px; 14 | } 15 | 16 | .notification { 17 | display: flex; 18 | align-items: center; 19 | column-gap: 0.33rem; 20 | padding: 0.75rem; 21 | margin-bottom: 1rem; 22 | box-shadow: 0 0 15px 2px rgb(0 0 0 / 30%); 23 | background-color: #2f363d; 24 | color: #e1e4e8; 25 | transition: opacity cubic-bezier(0.215, 0.61, 0.455, 1); 26 | user-select: none; 27 | cursor: pointer; 28 | border-radius: 9999px; 29 | } 30 | 31 | .notification.animation-in { 32 | animation: fadeInSlideUp 0.4s ease; 33 | } 34 | 35 | .notification.animation-out { 36 | animation: fadeOutSlideDown 0.4s ease; 37 | } 38 | 39 | .icon-close { 40 | color: rgba(225, 228, 232, 0.822); 41 | margin-left: 0.5rem; 42 | } 43 | 44 | .icon-close:hover { 45 | color: rgb(225, 228, 232); 46 | } 47 | 48 | .notification__icon, 49 | .notification__message, 50 | .icon-close { 51 | display: flex; 52 | align-self: center; 53 | } 54 | 55 | .notification--info .notification__icon { 56 | color: #6199cb; 57 | } 58 | 59 | .notification--warning .notification__icon { 60 | color: #c3a001; 61 | } 62 | 63 | .notification--danger .notification__icon { 64 | color: #a34535; 65 | } -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | import { getState } from './state.js' 2 | import { $, setFormControlValue } from './utils/dom.js' 3 | 4 | const ELEMENT_TYPES = { 5 | INPUT: 'input', 6 | SELECT: 'select', 7 | CHECKBOX: 'checkbox', 8 | RADIO: 'radio' 9 | } 10 | 11 | /** 12 | * @type {HTMLFormElement} 13 | */ 14 | const $settingsForm = $('#settings') 15 | 16 | const { 17 | updateSettings, 18 | ...settings 19 | } = getState() 20 | 21 | $settingsForm.addEventListener('submit', e => e.preventDefault()) 22 | $settingsForm.addEventListener('input', updateSettingValue) 23 | $settingsForm.addEventListener('change', updateSettingValue) 24 | 25 | Array.from($settingsForm.elements).forEach((el) => { 26 | const { name: settingKey, value } = el 27 | 28 | if (!settingKey) return 29 | 30 | let actualSettingValue = settings[settingKey] 31 | 32 | if (settingKey === 'layout') { 33 | if (value === actualSettingValue.type) { 34 | actualSettingValue = true 35 | } else { return } 36 | } 37 | 38 | // Reflect the initial configuration in the settings section. 39 | setFormControlValue(el, actualSettingValue) 40 | }) 41 | 42 | function updateSettingValue ({ target }) { 43 | const { value, checked, name: settingKey } = target 44 | 45 | const isCheckbox = target.type === ELEMENT_TYPES.CHECKBOX 46 | const isRadio = target.type === ELEMENT_TYPES.RADIO 47 | 48 | const settingValue = isCheckbox ? checked : value 49 | 50 | if (isRadio) { 51 | if (!checked) { return } 52 | } 53 | 54 | updateSettings({ key: settingKey, value: settingValue }) 55 | } 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to codi.link 2 | 3 | First and foremost, thank you! We appreciate that you want to contribute to codi.link, your time is valuable, and your contributions mean a lot to us. 4 | 5 | ## Important! 6 | By contributing to this project, you: 7 | 8 | * Agree that you have authored 100% of the content 9 | * Agree that you have the necessary rights to the content 10 | * Agree that you have received the necessary permissions from your employer to make the contributions (if applicable) 11 | * Agree that the content you contribute may be provided under the Project license(s) 12 | * Agree that, if you did not author 100% of the content, the appropriate licenses and copyrights have been added along with any other necessary attribution. 13 | 14 | ## Getting Started 15 | 16 | **What does "contributing" mean?** 17 | 18 | Creating an issue is the simplest form of contributing to a project. But there are many ways to contribute, including the following: 19 | 20 | - Feature requests 21 | - Bug reports 22 | 23 | If you'd like to learn more about contributing in general, the [Guide to Idiomatic Contributing](https://github.com/jonschlinkert/idiomatic-contributing) has a lot of useful information. 24 | 25 | **Showing support for codi.link** 26 | 27 | Please keep in mind that open source software is built by people like you, who spend their free time creating things the rest the community can use. 28 | 29 | Don't have time to contribute? Don't worry, here are some other ways to show your support to codi.link: 30 | 31 | - ⭐ Star the [project](https://github.com/midudev/codi.link) 32 | - 🐦 Tweet your support for codi.link 33 | 34 | -------------------------------------------------------------------------------- /assets/css.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/drag-file.js: -------------------------------------------------------------------------------- 1 | import { $ } from './utils/dom' 2 | import { eventBus, EVENTS } from './events-controller' 3 | 4 | const $inputFileDrop = $('#input-file-drop') 5 | const $overlayDrag = $('.overlay-drag') 6 | const $dragAndDropZone = $('.zone-drag-drop') 7 | 8 | $inputFileDrop.addEventListener('drop', (e) => { 9 | readFiles(e) 10 | $overlayDrag.classList.add('hidden') 11 | }) 12 | 13 | $inputFileDrop.addEventListener('dragenter', () => { 14 | $dragAndDropZone.classList.add('focus') 15 | }) 16 | 17 | $inputFileDrop.addEventListener('dragleave', () => { 18 | $dragAndDropZone.classList.remove('focus') 19 | }) 20 | 21 | window.addEventListener('dragenter', (e) => { 22 | if (e.clientY >= 0 || e.clientX >= 0) { 23 | $overlayDrag.classList.remove('hidden') 24 | } 25 | }) 26 | 27 | window.addEventListener('dragleave', ({ clientX, clientY }) => { 28 | if (clientY <= 0 || clientX <= 0 || 29 | (clientX >= window.innerWidth || clientY >= window.innerHeight)) { 30 | $overlayDrag.classList.add('hidden') 31 | } 32 | }) 33 | 34 | window.addEventListener('visibilitychange', () => { 35 | if (document.hidden) { 36 | $overlayDrag.classList.add('hidden') 37 | } 38 | }) 39 | 40 | function readFiles (e) { 41 | const { files } = e.dataTransfer 42 | Object.values(files).forEach(file => { 43 | const { type: typeFile } = file 44 | const reader = new window.FileReader() 45 | reader.onload = ({ target }) => { 46 | printContent(target.result, typeFile) 47 | } 48 | reader.readAsBinaryString(file) 49 | }) 50 | } 51 | 52 | function printContent (content, typeFile) { 53 | eventBus.emit(EVENTS.DRAG_FILE, { content, typeFile }) 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/js-execution-worker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | 3 | function evalUserCode (code) { 4 | const consolePattern = /console\.\w+\(([^)]|\n)*\);?/g 5 | const whileTruePattern = /(? { 30 | return `/* ${match.trim()} */\n` 31 | }) 32 | 33 | try { 34 | // eslint-disable-next-line no-new-func 35 | const func = new Function(parsedCode) 36 | const result = func() 37 | return { success: true, result } 38 | } catch (error) { 39 | return { success: false, error: error.message } 40 | } 41 | } 42 | 43 | self.onmessage = function (e) { 44 | const userCode = e.data.code 45 | const { success, result, error } = evalUserCode(userCode) 46 | 47 | if (success) { 48 | self.postMessage({ result }) 49 | } else { 50 | self.postMessage({ error }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/download.js: -------------------------------------------------------------------------------- 1 | import { createHtml } from './utils/createHtml.js' 2 | 3 | const getZip = () => 4 | import('client-zip').then(({ downloadZip }) => downloadZip) 5 | 6 | const DEFAULT_ZIP_FILE_NAME = 'codi.link' 7 | 8 | export async function downloadUserCode ({ 9 | htmlContent, 10 | cssContent, 11 | jsContent, 12 | zipFileName = DEFAULT_ZIP_FILE_NAME, 13 | zipInSingleFile = false 14 | }) { 15 | zipFileName = zipFileName === '' ? DEFAULT_ZIP_FILE_NAME : zipFileName 16 | 17 | const createZip = zipInSingleFile 18 | ? createZipWithSingleFile 19 | : createZipWithMultipleFiles 20 | 21 | const zipBlob = await createZip({ htmlContent, cssContent, jsContent }) 22 | return generateZip({ zipBlob, zipFileName }) 23 | } 24 | 25 | async function createZipWithSingleFile ({ htmlContent, cssContent, jsContent }) { 26 | const zip = await getZip() 27 | const indexHTML = createHtml({ css: cssContent, html: htmlContent, js: jsContent }) 28 | return await zip({ name: 'index.html', input: indexHTML }).blob() 29 | } 30 | 31 | async function createZipWithMultipleFiles ({ htmlContent, cssContent, jsContent }) { 32 | const zip = await getZip() 33 | 34 | const indexHtml = ` 35 | 36 | 37 | 38 | 39 | 40 | ${htmlContent} 41 | 42 | 43 | ` 44 | 45 | return await zip([ 46 | { name: 'style.css', input: cssContent }, 47 | { name: 'script.js', input: jsContent }, 48 | { name: 'index.html', input: indexHtml } 49 | ]).blob() 50 | } 51 | 52 | function generateZip ({ zipBlob, zipFileName }) { 53 | console.log({ zipBlob, zipFileName }) 54 | const element = window.document.createElement('a') 55 | element.href = window.URL.createObjectURL(zipBlob) 56 | element.download = `${zipFileName}.zip` 57 | element.click() 58 | element.remove() 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish new executables' 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | publish-tauri: 9 | permissions: 10 | contents: write 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - platform: 'macos-latest' # for Arm based macs (M1 and above). 16 | args: '--target aarch64-apple-darwin' 17 | - platform: 'macos-latest' # for Intel based macs. 18 | args: '--target x86_64-apple-darwin' 19 | - platform: 'ubuntu-22.04' 20 | args: '' 21 | - platform: 'windows-latest' 22 | args: '' 23 | 24 | runs-on: ${{ matrix.platform }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Setup Node 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: lts/* 32 | 33 | - name: Install Bun 34 | uses: oven-sh/setup-bun@v1 35 | 36 | - name: Install Rust stable 37 | uses: dtolnay/rust-toolchain@stable 38 | with: 39 | # These targets are only used on macOS runners, so it's conditional to speed up other builds 40 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 41 | 42 | - name: Install dependencies (Ubuntu only) 43 | if: matrix.platform == 'ubuntu-22.04' 44 | run: | 45 | sudo apt-get update 46 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 47 | 48 | - name: Install frontend dependencies 49 | run: bun install 50 | 51 | - uses: tauri-apps/tauri-action@v0 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | with: 55 | tagName: app-v__VERSION__ # the action automatically replaces __VERSION__ with the app version 56 | releaseName: 'App v__VERSION__' 57 | releaseBody: 'See the assets to download this version and install.' 58 | releaseDraft: true 59 | prerelease: false 60 | args: ${{ matrix.args }} 61 | -------------------------------------------------------------------------------- /src/components/codi-editor/extensions/autocomplete-html-tag.js: -------------------------------------------------------------------------------- 1 | const HTML_LANGUAGE_ID = 'html' 2 | 3 | // https://developer.mozilla.org/en-US/docs/Glossary/Empty_element 4 | const EMPTY_HTML_TAGS = [ 5 | 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' 6 | ] 7 | 8 | export const registerAutoCompleteHTMLTag = (monaco) => { 9 | monaco.languages.registerCompletionItemProvider(HTML_LANGUAGE_ID, completionProvider(monaco)) 10 | } 11 | 12 | const completionProvider = (monaco) => { 13 | return { 14 | triggerCharacters: ['>'], 15 | provideCompletionItems: (model, position) => { 16 | const tagName = getMatchingTagName({ model, position }) 17 | const isEmptyTag = checkIsEmptyTag(tagName) 18 | 19 | if (!tagName || isEmptyTag) { 20 | return 21 | } 22 | 23 | return buildCompletionList({ tagName, position }, monaco) 24 | } 25 | } 26 | } 27 | 28 | const getMatchingTagName = ({ model, position: { lineNumber, column } }) => { 29 | const textFromCurrentLineUntilPosition = model.getValueInRange({ 30 | startLineNumber: lineNumber, 31 | endLineNumber: lineNumber, 32 | startColumn: 1, 33 | endColumn: column 34 | }) 35 | 36 | return textFromCurrentLineUntilPosition.match(/.*<(\w+).*>$/)?.[1] 37 | } 38 | 39 | const checkIsEmptyTag = (tagName) => EMPTY_HTML_TAGS.includes(tagName) 40 | 41 | const buildCompletionList = ({ tagName, position: { lineNumber, column } }, monaco) => { 42 | const closingTag = `` 43 | const insertTextSnippet = `$0${closingTag}` 44 | const rangeInCurrentPosition = { 45 | startLineNumber: lineNumber, 46 | endLineNumber: lineNumber, 47 | startColumn: column, 48 | endColumn: column 49 | } 50 | 51 | return { 52 | suggestions: [ 53 | { 54 | label: closingTag, 55 | kind: monaco.languages.CompletionItemKind.EnumMember, 56 | insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, 57 | insertText: insertTextSnippet, 58 | range: rangeInCurrentPosition 59 | } 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | types: [opened, synchronize] 7 | push: 8 | branches: [main] 9 | 10 | jobs: 11 | cancel_previous: 12 | name: Cancel previous redundant builds 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - uses: styfle/cancel-workflow-action@0.9.1 16 | with: 17 | access_token: ${{ github.token }} 18 | 19 | build: 20 | needs: cancel_previous 21 | name: Build app 22 | runs-on: ubuntu-22.04 23 | steps: 24 | - name: Checkout git repository 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Install Bun 30 | uses: oven-sh/setup-bun@v1 31 | 32 | - name: Cache Bun dependencies 33 | uses: actions/cache@v4 34 | with: 35 | path: ~/.bun/install/cache 36 | key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} 37 | 38 | - name: Install dependencies and build 39 | run: | 40 | bun install 41 | bun run build 42 | 43 | tauri: 44 | if: github.event_name == 'pull_request' 45 | needs: build 46 | name: Test Tauri build (compile only) 47 | runs-on: ubuntu-22.04 48 | steps: 49 | - name: Checkout repository 50 | uses: actions/checkout@v4 51 | 52 | - name: Install Bun 53 | uses: oven-sh/setup-bun@v1 54 | 55 | - name: Cache Cargo registry and target 56 | uses: actions/cache@v4 57 | with: 58 | path: | 59 | ~/.cargo/registry 60 | ~/.cargo/git 61 | src-tauri/target 62 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 63 | 64 | - name: Install Rust stable 65 | uses: dtolnay/rust-toolchain@stable 66 | 67 | - name: Install minimal system deps (Ubuntu only) 68 | run: | 69 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev 70 | 71 | - name: Install frontend deps 72 | run: bun install 73 | 74 | - name: Compile Tauri crate (no bundling) 75 | run: cargo build --manifest-path src-tauri/Cargo.toml -------------------------------------------------------------------------------- /src/language/en.js: -------------------------------------------------------------------------------- 1 | const en = { 2 | language: 'Language', 3 | english: 'English', 4 | spanish: 'Spanish', 5 | portuguese: 'Portuguese', 6 | editor: 'Editor', 7 | addDependency: 'Add dependency', 8 | addDependencyDescription: 9 | 'An import statement will be added to the top of the JavaScript editor for the package.', 10 | lineNumbers: 'Line numbers', 11 | on: 'On', 12 | off: 'Off', 13 | relative: 'Relative', 14 | interval: 'Interval', 15 | layout: 'Layout', 16 | console: 'Console', 17 | consoleDescription: 'Shows the result of the code execution.', 18 | dependencies: 'Dependencies', 19 | download: 'Download', 20 | history: 'History', 21 | preview: 'Preview', 22 | copyClipboard: 'Copy to clipboard', 23 | settings: 'Settings', 24 | historyDescription: 'Manages the sandboxes history.', 25 | clear: 'Clear', 26 | new: 'New', 27 | wordWrap: 'Word wrap', 28 | wordWrapColumn: 'wordWrapColumn', 29 | bounded: 'Bounded', 30 | fontSize: 'Font size', 31 | tabSize: 'Tab size', 32 | editorMinimap: 'Editor', 33 | showMinimap: 'Show minimap', 34 | fontLigatures: 'Font ligatures', 35 | enableFontLigatures: 'Enable font ligatures', 36 | maxExecutionTime: 'Max. execution time', 37 | maxExecutionTimeDesc: 38 | 'The maximum execution time in milliseconds to prevent the editor from freezing.', 39 | cursorBlinking: 'Cursor blinking', 40 | blink: 'Blink', 41 | smooth: 'Smooth', 42 | phase: 'Phase', 43 | expand: 'Expand', 44 | solid: 'Solid', 45 | cursorSmoothCaretAnimation: 'Cursor Smooth Caret Animation', 46 | explicit: 'Explicit', 47 | colorTheme: 'Color theme', 48 | dark: 'Dark', 49 | light: 'Light', 50 | highContrastDark: 'High Contrast Dark', 51 | highContrastLight: 'High Contrast Light', 52 | default: 'Default', 53 | workbench: 'Workbench', 54 | preserveGrid: 'Preserve Grid', 55 | preserveGridLayout: 'Preserve Grid Layout', 56 | featuresDownload: 'Features › Download', 57 | fileName: 'File name', 58 | Content: 'Content', 59 | exportOneZip: 'Export one single zipped file', 60 | featuresAutosave: 'Features › Autosave', 61 | localStorage: 'Local storage', 62 | automaticallySaveUrl: 63 | 'Automatically save URL to local storage for fast content loading', 64 | searchDependency: 'Search and add a package...' 65 | } 66 | 67 | export default en 68 | -------------------------------------------------------------------------------- /src/components/layout-preview/layout-preview.css: -------------------------------------------------------------------------------- 1 | layout-preview { 2 | display: grid; 3 | height: var(--layout-preview-size, 40px); 4 | width: var(--layout-preview-size, 40px); 5 | padding: var(--layout-preview-padding, 5px); 6 | cursor: pointer; 7 | grid-template-columns: 1fr 1fr; 8 | grid-template-rows: 1fr 1fr; 9 | grid-template-areas: 'html js' 'css result'; 10 | gap: var(--layout-preview-gap, 2px); 11 | } 12 | 13 | layout-preview[active] { 14 | background-color: var(--layout-preview-background-color, #797b80); 15 | border-radius: var(--layout-preview-border-radius, 5px); 16 | } 17 | 18 | layout-preview[layout=layout-2] { 19 | grid-template-areas: 'html css' 'js result'; 20 | } 21 | 22 | layout-preview[layout=vertical] { 23 | grid-template-columns: repeat(4, 1fr); 24 | grid-template-rows: 1fr; 25 | grid-template-areas: 'html css js result'; 26 | } 27 | 28 | layout-preview[layout=horizontal] { 29 | grid-template-rows: repeat(4, 1fr); 30 | grid-template-columns: 1fr; 31 | grid-template-areas: 'html' 'css' 'js' 'result'; 32 | } 33 | 34 | layout-preview[layout=bottom] { 35 | grid-template-columns: repeat(3, 1fr); 36 | grid-template-rows: repeat(2, 1fr); 37 | grid-template-areas: 'result result result' 'html js css'; 38 | } 39 | 40 | layout-preview[layout=tabs]{ 41 | grid-template-columns: repeat(3, 1fr); 42 | grid-template-rows: repeat(2, 1fr); 43 | grid-template-areas: 'html html result' 'html html result'; 44 | } 45 | 46 | layout-preview[layout=tabs] .css, 47 | layout-preview[layout=tabs] .js { 48 | position: absolute; 49 | width: 5px; 50 | height: 5px; 51 | top: 5px; 52 | border-left: 1px solid black; 53 | border-bottom: 1px solid black; 54 | } 55 | 56 | layout-preview[layout=tabs] .css { 57 | left: calc(33.33% - 3px); 58 | } 59 | 60 | layout-preview[layout=tabs] .js { 61 | left: calc(33.33% + 4px); 62 | } 63 | 64 | layout-preview .html { 65 | grid-area: html; 66 | background-color: var(--layout-preview-background-color-html, #e34f26); 67 | } 68 | 69 | layout-preview .css { 70 | grid-area: css; 71 | background-color: var(--layout-preview-background-color-css, #30a9dc); 72 | } 73 | 74 | layout-preview .js { 75 | grid-area: js; 76 | background-color: var(--layout-preview-background-color-js, #f7df1e); 77 | } 78 | 79 | layout-preview .result { 80 | grid-area: result; 81 | background-color: var(--layout-preview-background-color-result, #ffffff); 82 | } 83 | -------------------------------------------------------------------------------- /src/language/pt.js: -------------------------------------------------------------------------------- 1 | const pt = { 2 | language: 'Idioma', 3 | english: 'Inglês', 4 | spanish: 'Espanhol', 5 | portuguese: 'Português', 6 | editor: 'Editor', 7 | addDependency: 'Adicionar dependência', 8 | addDependencyDescription: 9 | 'Uma declaração de importação será adicionada ao topo do editor JavaScript para o pacote.', 10 | lineNumbers: 'Números de linha', 11 | on: 'Ligado', 12 | off: 'Desligado', 13 | relative: 'Relativo', 14 | interval: 'Intervalo', 15 | layout: 'Layout', 16 | console: 'Console', 17 | consoleDescription: 'Mostra o resultado da execução do código.', 18 | dependencies: 'Dependências', 19 | download: 'Baixar', 20 | history: 'Histórico', 21 | preview: 'Pré-visualização', 22 | copyClipboard: 'Copiar para a área de transferência', 23 | settings: 'Configurações', 24 | historyDescription: 'Gerencia o histórico das sandboxes.', 25 | clear: 'Limpar', 26 | new: 'Novo', 27 | wordWrap: 'Quebra de linha', 28 | wordWrapColumn: 'Coluna de quebra de linha', 29 | bounded: 'Limitado', 30 | fontSize: 'Tamanho da fonte', 31 | tabSize: 'Tamanho da tabulação', 32 | editorMinimap: 'Editor', 33 | showMinimap: 'Mostrar minimapa', 34 | fontLigatures: 'Ligações de fonte', 35 | enableFontLigatures: 'Habilitar ligações de fonte', 36 | maxExecutionTime: 'Tempo máximo de execução', 37 | maxExecutionTimeDesc: 38 | 'O tempo máximo de execução em milissegundos para evitar que o editor trave.', 39 | cursorBlinking: 'Piscamento do cursor', 40 | blink: 'Piscada', 41 | smooth: 'Suave', 42 | phase: 'Fase', 43 | expand: 'Expandir', 44 | solid: 'Sólido', 45 | cursorSmoothCaretAnimation: 'Animação suave do cursor', 46 | explicit: 'Explícito', 47 | colorTheme: 'Tema de cor', 48 | dark: 'Escuro', 49 | light: 'Claro', 50 | highContrastDark: 'Alto contraste escuro', 51 | highContrastLight: 'Alto contraste claro', 52 | default: 'Padrão', 53 | workbench: 'Área de trabalho', 54 | preserveGrid: 'Preservar grade', 55 | preserveGridLayout: 'Preservar layout da grade', 56 | featuresDownload: 'Recursos › Baixar', 57 | fileName: 'Nome do arquivo', 58 | Content: 'Conteúdo', 59 | exportOneZip: 'Exportar um único arquivo zipado', 60 | featuresAutosave: 'Recursos › Autossalvar', 61 | localStorage: 'Armazenamento local', 62 | automaticallySaveUrl: 63 | 'Salvar automaticamente a URL no armazenamento local para carregamento rápido de conteúdo', 64 | searchDependency: 'Pesquisar e adicionar um pacote...' 65 | } 66 | 67 | export default pt 68 | -------------------------------------------------------------------------------- /src/components/codi-editor/CodiEditor.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit' 2 | import * as monaco from 'monaco-editor' 3 | import { emmetHTML } from 'emmet-monaco-es' 4 | import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' 5 | import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker' 6 | import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker' 7 | import JsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' 8 | import { registerAutoCompleteHTMLTag } from './extensions/autocomplete-html-tag.js' 9 | import { initEditorHotKeys } from './extensions/editor-hotkeys.js' 10 | import { CodiEditorStyles } from './CodiEditor.styles.js' 11 | import { registerThemes } from './extensions/register-themes.js' 12 | 13 | const iconUrls = { 14 | css: new URL('../../../assets/css.svg', import.meta.url), 15 | html: new URL('../../../assets/html5.svg', import.meta.url), 16 | javascript: new URL('../../../assets/js.svg', import.meta.url) 17 | } 18 | 19 | export class CodiEditor extends LitElement { 20 | static get styles () { 21 | return CodiEditorStyles 22 | } 23 | 24 | static get properties () { 25 | return { 26 | language: { 27 | type: String, 28 | reflects: true 29 | }, 30 | value: { 31 | type: String 32 | }, 33 | class: { 34 | type: String 35 | } 36 | } 37 | } 38 | 39 | render () { 40 | const iconUrl = iconUrls[this.language] 41 | return html`${this.language}` 42 | } 43 | 44 | constructor () { 45 | super() 46 | this.constructor.initEditor() 47 | } 48 | 49 | createEditor (options) { 50 | this.editor = monaco.editor.create(this, { 51 | value: this.value, 52 | language: this.language, 53 | ...options 54 | }) 55 | initEditorHotKeys(this.editor) 56 | return this.editor 57 | } 58 | 59 | static initEditor () { 60 | if (!this.editorInitialized) { 61 | window.MonacoEnvironment = { 62 | getWorker (_, label) { 63 | switch (label) { 64 | case 'html': return new HtmlWorker() 65 | case 'javascript': return new JsWorker() 66 | case 'css': return new CssWorker() 67 | default: return new EditorWorker() 68 | } 69 | } 70 | } 71 | 72 | registerThemes(monaco) 73 | emmetHTML(monaco) 74 | registerAutoCompleteHTMLTag(monaco) 75 | this.editorInitialized = true 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/css/settings.css: -------------------------------------------------------------------------------- 1 | .settings { 2 | & .settings-content { 3 | display: grid; 4 | place-content: center; 5 | height: 100%; 6 | } 7 | 8 | & .settings-type { 9 | opacity: 0.6; 10 | } 11 | 12 | & .settings-item { 13 | display: flex; 14 | flex-direction: column; 15 | width: 100%; 16 | max-width: 500px; 17 | padding: 1em; 18 | 19 | & .layout-preview-container { 20 | display: flex; 21 | justify-content: space-between; 22 | padding: 10px 0; 23 | 24 | & label { 25 | border-radius: 5px; 26 | position: relative; 27 | transition: 0.3s ease opacity, 0.3s ease background-color; 28 | } 29 | 30 | & label:hover { 31 | background-color: #000; 32 | opacity: 0.9; 33 | } 34 | 35 | & input[type='radio'] { 36 | cursor: pointer; 37 | height: 100%; 38 | left: 0; 39 | margin: 0; 40 | opacity: 0; 41 | padding: 0; 42 | position: absolute; 43 | top: 0; 44 | width: 100%; 45 | } 46 | } 47 | } 48 | 49 | & .checkbox { 50 | display: flex; 51 | align-items: center; 52 | } 53 | 54 | & .checkbox input { 55 | border: 0; 56 | clip: rect(0, 0, 0, 0); 57 | height: 1px; 58 | width: 1px; 59 | margin: -1px; 60 | padding: 0; 61 | position: absolute; 62 | overflow: hidden; 63 | white-space: nowrap; 64 | } 65 | 66 | & .checkbox span { 67 | display: flex; 68 | align-items: center; 69 | } 70 | 71 | & .checkbox input:focus + span::before { 72 | content: ''; 73 | outline: 1px solid #fff; 74 | } 75 | 76 | & .checkbox input:checked + span::before { 77 | content: '✓'; 78 | } 79 | 80 | & .checkbox span::before { 81 | align-items: center; 82 | background: var(--input-background); 83 | border-radius: 3px; 84 | border: 1px solid var(--input-border); 85 | color: var(--input-foreground); 86 | content: ''; 87 | display: flex; 88 | height: 18px; 89 | justify-content: center; 90 | margin-right: 9px; 91 | min-width: 18px; 92 | width: 18px; 93 | } 94 | 95 | & .input { 96 | background-color: var(--input-background); 97 | color: var(--input-foreground); 98 | border: 1px solid var(--input-border); 99 | padding: 0.3em; 100 | } 101 | 102 | & .setting-description { 103 | opacity: 0.6; 104 | margin: 0; 105 | padding-top: 0.5em; 106 | font-size: 0.9em; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/language/es.js: -------------------------------------------------------------------------------- 1 | const es = { 2 | language: 'Idioma', 3 | english: 'Inglés', 4 | spanish: 'Español', 5 | portuguese: 'Portugués', 6 | editor: 'Editor', 7 | addDependency: 'Añadir dependencia', 8 | addDependencyDescription: 9 | 'Se agregará una declaración de importación en la parte superior del editor de JavaScript para el paquete.', 10 | lineNumbers: 'Números de línea', 11 | on: 'Activado', 12 | off: 'Desactivado', 13 | relative: 'Relativo', 14 | interval: 'Intervalo', 15 | layout: 'Diseño', 16 | console: 'Consola', 17 | consoleDescription: 'Muestra el resultado de la ejecución del código.', 18 | dependencies: 'Dependencias', 19 | download: 'Descargar', 20 | history: 'Historial', 21 | preview: 'Vista previa', 22 | copyClipboard: 'Copiar al portapapeles', 23 | settings: 'Ajustes', 24 | historyDescription: 'Administra el historial de los entornos de pruebas.', 25 | clear: 'Limpiar', 26 | new: 'Nuevo', 27 | wordWrap: 'Ajuste de línea', 28 | wordWrapColumn: 'wordWrapColumn', 29 | bounded: 'Limitado', 30 | fontSize: 'Tamaño de fuente', 31 | tabSize: 'Tamaño de tabulación', 32 | editorMinimap: 'Editor', 33 | showMinimap: 'Mostrar minimapa', 34 | fontLigatures: 'Ligaduras de fuente', 35 | enableFontLigatures: 'Habilitar ligaduras de fuente', 36 | maxExecutionTime: 'Tiempo máx. de ejecución', 37 | maxExecutionTimeDesc: 38 | 'El tiempo máximo de ejecución en milisegundos para evitar que el editor se congele.', 39 | cursorBlinking: 'Parpadeo del cursor', 40 | blink: 'Parpadeo', 41 | smooth: 'Suave', 42 | phase: 'Fase', 43 | expand: 'Expandir', 44 | solid: 'Sólido', 45 | cursorSmoothCaretAnimation: 'Animación suave del cursor', 46 | explicit: 'Explícito', 47 | colorTheme: 'Tema de color', 48 | dark: 'Oscuro', 49 | light: 'Claro', 50 | highContrastDark: 'Alto contraste oscuro', 51 | highContrastLight: 'Alto contraste claro', 52 | default: 'Predeterminado', 53 | workbench: 'Área de trabajo', 54 | preserveGrid: 'Preservar cuadrícula', 55 | preserveGridLayout: 'Preservar diseño de cuadrícula', 56 | featuresDownload: 'Características › Descargar', 57 | fileName: 'Nombre del archivo', 58 | Content: 'Contenido', 59 | exportOneZip: 'Exportar un solo archivo comprimido', 60 | featuresAutosave: 'Características › Guardado automático', 61 | localStorage: 'Almacenamiento local', 62 | automaticallySaveUrl: 63 | 'Guardar automáticamente la URL en el almacenamiento local para una carga rápida del contenido', 64 | searchDependency: 'Buscar y agregar un paquete...' 65 | } 66 | 67 | export default es 68 | -------------------------------------------------------------------------------- /src/aside.js: -------------------------------------------------------------------------------- 1 | import { eventBus, EVENTS } from './events-controller.js' 2 | import { $, $$ } from './utils/dom.js' 3 | import * as Preview from './utils/WindowPreviewer' 4 | import { BUTTON_ACTIONS } from './constants/button-actions.js' 5 | import { copyToClipboard } from './utils/string.js' 6 | import { resetConsoleBadge } from './console.js' 7 | 8 | const $aside = $('aside') 9 | const $asideBar = $('.aside-bar') 10 | const $buttons = $$('button', $aside) 11 | const $editorAsideButton = $('#editor-aside-button') 12 | 13 | const toggleAsideBar = isHidden => { 14 | $asideBar.toggleAttribute('hidden', isHidden) 15 | } 16 | 17 | const SIMPLE_CLICK_ACTIONS = { 18 | [BUTTON_ACTIONS.downloadUserCode]: () => { 19 | eventBus.emit(EVENTS.DOWNLOAD_USER_CODE) 20 | }, 21 | 22 | [BUTTON_ACTIONS.openIframeTab]: () => { 23 | Preview.showPreviewerWindow() 24 | }, 25 | 26 | [BUTTON_ACTIONS.copyToClipboard]: async () => { 27 | const url = new URL(window.location.href) 28 | const urlToCopy = `https://codi.link${url.pathname}` 29 | copyToClipboard(urlToCopy) 30 | }, 31 | 32 | [BUTTON_ACTIONS.clearHistory]: () => { 33 | eventBus.emit(EVENTS.CLEAR_HISTORY) 34 | }, 35 | 36 | [BUTTON_ACTIONS.openNewInstance]: () => { 37 | eventBus.emit(EVENTS.OPEN_NEW_INSTANCE) 38 | } 39 | } 40 | 41 | const NON_SIMPLE_CLICK_ACTIONS = { 42 | [BUTTON_ACTIONS.closeAsideBar]: () => { 43 | toggleAsideBar(true) 44 | $('.scroll-buttons-container').removeAttribute('hidden') 45 | }, 46 | 47 | [BUTTON_ACTIONS.showSkypackBar]: () => { 48 | showAsideBar('#skypack') 49 | $('#skypack-search-input').focus() 50 | $('.scroll-buttons-container').setAttribute('hidden', '') 51 | }, 52 | 53 | [BUTTON_ACTIONS.showSettingsBar]: () => { 54 | showAsideBar('#settings') 55 | $('.scroll-buttons-container').setAttribute('hidden', '') 56 | }, 57 | [BUTTON_ACTIONS.showConsoleBar]: () => { 58 | showAsideBar('#console') 59 | $('.scroll-buttons-container').setAttribute('hidden', '') 60 | resetConsoleBadge() 61 | }, 62 | 63 | [BUTTON_ACTIONS.showHistoryBar]: () => { 64 | showAsideBar('#history') 65 | $('.scroll-buttons-container').setAttribute('hidden', '') 66 | } 67 | } 68 | 69 | const showAsideBar = selector => { 70 | $asideBar.removeAttribute('hidden') 71 | $$('.bar-content').forEach(el => el.setAttribute('hidden', '')) 72 | $(selector).removeAttribute('hidden') 73 | } 74 | 75 | const ACTIONS = { 76 | ...SIMPLE_CLICK_ACTIONS, 77 | ...NON_SIMPLE_CLICK_ACTIONS 78 | } 79 | 80 | $buttons.forEach(button => { 81 | button.addEventListener('click', ({ currentTarget }) => { 82 | let action = button.getAttribute('data-action') 83 | const isSimpleClickAction = 84 | button.getAttribute('data-is-simple-click-action') === 'true' 85 | 86 | if (isSimpleClickAction) return ACTIONS[action]() 87 | 88 | const alreadyActive = currentTarget.classList.contains('is-active') 89 | $('.is-active').classList.remove('is-active') 90 | 91 | const buttonToActive = alreadyActive ? $editorAsideButton : currentTarget 92 | buttonToActive.classList.add('is-active') 93 | 94 | action = alreadyActive ? 'close-aside-bar' : action 95 | 96 | ACTIONS[action]() 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/utils/notification.js: -------------------------------------------------------------------------------- 1 | import { $ } from './dom' 2 | 3 | const STATE_ICONS = { 4 | info: '', 5 | warning: '', 6 | danger: '' 7 | } 8 | 9 | const TRANSITION_DURATION = 400 // ms 10 | const NOTIFICATION_DURATION = 300000 // ms 11 | 12 | export default { 13 | /** 14 | * Display a notification 15 | * @param {Object} options - The options object 16 | * @param {string} options.type - Notification type: info, warning, danger 17 | * @param {string} options.message - Message to display 18 | */ 19 | show: ({ type, message }) => { 20 | const notifications = $('#notifications') 21 | const notification = document.createElement('div') 22 | 23 | notification.className = `notification notification--${type}` 24 | notification.innerHTML = ` 25 |
26 | ${STATE_ICONS[type]} 27 |
28 |
29 | ${message} 30 |
31 |
32 | 33 |
34 | ` 35 | 36 | let wrapper = $('#notifications-wrapper') 37 | 38 | if (!wrapper) { 39 | wrapper = document.createElement('div') 40 | wrapper.setAttribute('id', 'notifications-wrapper') 41 | notifications.appendChild(wrapper) 42 | } 43 | 44 | notification.classList.add('animation-in') 45 | 46 | // Accesibility attributes 47 | notification.setAttribute('role', 'alert') 48 | notification.setAttribute('aria-live', 'assertive') 49 | notification.setAttribute('aria-atomic', 'true') 50 | 51 | setTimeout(() => { 52 | notification.classList.remove('animation-in') 53 | notification.classList.add('animation-out') 54 | }, NOTIFICATION_DURATION - TRANSITION_DURATION / 2) 55 | 56 | // Remove notification after NOTIFICATION_DURATION 57 | setTimeout(() => { 58 | notification.remove() 59 | }, NOTIFICATION_DURATION) 60 | 61 | notification.querySelector('.icon-close').addEventListener('click', () => { 62 | notification.classList.add('bounce-leave') 63 | setTimeout(() => { 64 | notification.remove() 65 | }, TRANSITION_DURATION / 2) 66 | }) 67 | 68 | wrapper.insertAdjacentElement('beforeend', notification) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/css/skypack.css: -------------------------------------------------------------------------------- 1 | .skypack { 2 | & .skypack-header { 3 | position: sticky; 4 | top: 0; 5 | z-index: 1; 6 | padding-top: 1em; 7 | } 8 | 9 | & .skypack-type { 10 | padding-bottom: 8px; 11 | opacity: 0.6; 12 | } 13 | 14 | & .skypack-item { 15 | color: #fff; 16 | display: flex; 17 | flex-direction: column; 18 | width: 100%; 19 | padding: 0 1em 1em; 20 | } 21 | 22 | & .skypack-item strong { 23 | display: block; 24 | overflow: hidden; 25 | text-overflow: ellipsis; 26 | } 27 | 28 | & .input { 29 | display: flex; 30 | background-color: var(--input-background); 31 | border: 1px solid var(--input-border); 32 | color: var(--input-foreground); 33 | padding: 0.5em; 34 | margin-top: 16px; 35 | font-size: 1rem; 36 | } 37 | 38 | & .input .input-icon { 39 | fill: rgba(128, 128, 128, 0.5); 40 | } 41 | 42 | & .input input { 43 | width: 100%; 44 | padding: 0.05em; 45 | padding-left: 8px; 46 | border: none; 47 | background-color: transparent; 48 | outline: none; 49 | color: var(--input-foreground); 50 | } 51 | 52 | & .search-results-message { 53 | font-weight: 500; 54 | font-size: 14px; 55 | margin: 4px 0 0; 56 | padding-bottom: 8px; 57 | } 58 | 59 | & .search-results.hidden { 60 | display: none; 61 | } 62 | 63 | & .search-results .extensions ul { 64 | list-style: none; 65 | margin: 0.5em 0; 66 | padding: 0; 67 | } 68 | 69 | & .search-results .extensions ul li { 70 | margin-bottom: 0.5em; 71 | padding: 0.5em; 72 | cursor: pointer; 73 | text-overflow: ellipsis; 74 | overflow: hidden; 75 | white-space: nowrap; 76 | display: flex; 77 | flex-direction: column; 78 | 79 | & header { 80 | display: flex; 81 | align-items: center; 82 | 83 | & .skypack-result-badges { 84 | display: flex; 85 | align-items: center; 86 | padding-left: 0.25rem; 87 | 88 | & .skypack-badge { 89 | margin-left: 0.25rem; 90 | margin-right: 0.25rem; 91 | border: 0px; 92 | border-radius: 0.3em; 93 | font-size: 0.7em; 94 | padding: 0.125em 0.375em 0.12em; 95 | 96 | &.popular { 97 | background-color: #80b918; 98 | color: #000; 99 | } 100 | 101 | &.deprecated { 102 | background-color: #c81d25; 103 | color: #fff; 104 | } 105 | 106 | &.exact-match { 107 | background-color: #ffc300; 108 | color: #000; 109 | } 110 | 111 | &.typescript { 112 | width: 18px; 113 | height: 18px; 114 | background: url(/assets/typescript.svg) no-repeat 50% 50%; 115 | } 116 | } 117 | } 118 | } 119 | 120 | & .skypack-description { 121 | font-size: 0.9em; 122 | text-overflow: ellipsis; 123 | overflow: hidden; 124 | } 125 | 126 | & .skypack-updated { 127 | font-size: 0.8em; 128 | opacity: 0.5; 129 | } 130 | 131 | & footer { 132 | display: flex; 133 | justify-content: space-between; 134 | align-items: flex-end; 135 | } 136 | 137 | & .skypack-open { 138 | font-size: 0.7em; 139 | opacity: 0; 140 | text-decoration: none; 141 | 142 | &:hover { 143 | text-decoration: underline; 144 | } 145 | } 146 | } 147 | 148 | & .search-results ul li:focus, 149 | & .search-results ul li:hover { 150 | background-color: rgba(128, 128, 128, 0.14); 151 | 152 | & .skypack-open { 153 | opacity: 1; 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/css/history.css: -------------------------------------------------------------------------------- 1 | .history { 2 | & .history-header { 3 | position: sticky; 4 | top: 0; 5 | z-index: 1; 6 | padding-top: 1em; 7 | padding-left: 0.5em; 8 | } 9 | 10 | & .history-item { 11 | color: #fff; 12 | display: flex; 13 | flex-direction: column; 14 | width: 100%; 15 | padding: 0 0 1em 0.5em; 16 | } 17 | 18 | & .history-item strong { 19 | display: block; 20 | overflow: hidden; 21 | text-overflow: ellipsis; 22 | } 23 | 24 | & .history-actions { 25 | display: flex; 26 | align-items: center; 27 | justify-content: flex-end; 28 | gap: 6px; 29 | margin-top: 1em; 30 | 31 | & button { 32 | background: rgba(255, 255, 255, 0.05); 33 | display: flex; 34 | align-items: center; 35 | gap: 6px; 36 | border: 1px solid rgba(255, 255, 255, 0.05); 37 | color: #fff; 38 | cursor: pointer; 39 | padding: 6px 8px 6px 6px; 40 | transition: 0.2s ease background-color, 0.2s ease scale; 41 | border-radius: 4px; 42 | 43 | &:hover { 44 | background-color: rgba(255, 255, 255, 0.1); 45 | } 46 | 47 | &:active { 48 | background-color: rgba(255, 255, 255, 0.2); 49 | scale: 0.95; 50 | } 51 | 52 | & svg { 53 | width: 16px; 54 | height: 16px; 55 | } 56 | } 57 | } 58 | 59 | & .history-list { 60 | list-style: none; 61 | padding: 0; 62 | display: grid; 63 | 64 | & .group { 65 | margin-bottom: 8px; 66 | } 67 | 68 | & .group h4 { 69 | margin: 0; 70 | font-size: 12px; 71 | opacity: 0.5; 72 | font-weight: 600; 73 | margin: 0; 74 | padding: 0.5em 0 0.5em 0.5em; 75 | } 76 | 77 | & li { 78 | transition: 0.2s ease background-color; 79 | display: flex; 80 | align-items: center; 81 | width: 100%; 82 | gap: 6px; 83 | cursor: pointer; 84 | border-radius: 4px; 85 | 86 | &:hover { 87 | background-color: rgba(255, 255, 255, 0.05); 88 | 89 | & .actions { 90 | opacity: 1; 91 | } 92 | } 93 | 94 | &.is-active { 95 | background-color: rgba(255, 255, 255, 0.1); 96 | } 97 | 98 | & button { 99 | color: #fff; 100 | border: none; 101 | background: none; 102 | text-decoration: none; 103 | word-break: break-all; 104 | flex-grow: 1; 105 | padding: 0.8em 0 0.8em 0.5em; 106 | white-space: nowrap; 107 | overflow: hidden; 108 | text-overflow: ellipsis; 109 | text-align: start; 110 | cursor: pointer; 111 | } 112 | 113 | & input { 114 | background: transparent; 115 | border: none; 116 | color: #fff; 117 | flex-grow: 1; 118 | padding: 0.8em 0 0.8em 0.5em; 119 | outline: none; 120 | } 121 | 122 | & .actions { 123 | transition: 0.1s ease opacity; 124 | display: inline-flex; 125 | align-items: center; 126 | flex-shrink: 0; 127 | padding-right: 0.5em; 128 | opacity: 0; 129 | gap: 6px; 130 | 131 | & button { 132 | transition: 0.2s ease opacity, 0.2s ease scale; 133 | border: none; 134 | background: none; 135 | color: #fff; 136 | cursor: pointer; 137 | line-height: 0; 138 | padding: 3px; 139 | 140 | &:hover { 141 | opacity: 0.7; 142 | } 143 | 144 | &:active { 145 | scale: 0.9; 146 | } 147 | 148 | & svg { 149 | width: 18px; 150 | height: 18px; 151 | } 152 | 153 | } 154 | } 155 | } 156 | 157 | } 158 | } -------------------------------------------------------------------------------- /src/events-controller.js: -------------------------------------------------------------------------------- 1 | import { decode } from 'js-base64' 2 | import { capitalize, searchByLine } from './utils/string.js' 3 | import { downloadUserCode } from './download.js' 4 | import { getState } from './state.js' 5 | import { getHistoryState } from './history.js' 6 | 7 | class EventBus extends window.EventTarget { 8 | on (type, listener) { 9 | this.addEventListener(type, listener) 10 | } 11 | 12 | off (type, listener) { 13 | this.removeEventListener(type, listener) 14 | } 15 | 16 | emit (type, detail) { 17 | const event = new window.CustomEvent(type, { detail, cancelable: true }) 18 | 19 | this.dispatchEvent(event) 20 | } 21 | } 22 | 23 | export const eventBus = new EventBus() 24 | 25 | let jsEditor 26 | let htmlEditor 27 | let cssEditor 28 | 29 | export const initializeEventsController = ({ 30 | jsEditor: _jsEditor, 31 | htmlEditor: _htmlEditor, 32 | cssEditor: _cssEditor 33 | }) => { 34 | jsEditor = _jsEditor 35 | htmlEditor = _htmlEditor 36 | cssEditor = _cssEditor 37 | } 38 | 39 | export const EVENTS = { 40 | ADD_SKYPACK_PACKAGE: 'ADD_SKYPACK_PACKAGE', 41 | DOWNLOAD_USER_CODE: 'DOWNLOAD_USER_CODE', 42 | DRAG_FILE: 'DRAG_FILE', 43 | OPEN_EXISTING_INSTANCE: 'OPEN_EXISTING_INSTANCE', 44 | OPEN_NEW_INSTANCE: 'OPEN_NEW_INSTANCE', 45 | CLEAR_HISTORY: 'CLEAR_HISTORY' 46 | } 47 | 48 | eventBus.on( 49 | EVENTS.ADD_SKYPACK_PACKAGE, 50 | ({ detail: { skypackPackage, url } }) => { 51 | const importStatement = `import ${capitalize(skypackPackage).replaceAll('.', '_')} from '${url}';` 52 | const existPackage = searchByLine(jsEditor.getValue(), url) 53 | if (!existPackage) { 54 | jsEditor.setValue(`${importStatement}\n${jsEditor.getValue()}`) 55 | } 56 | } 57 | ) 58 | 59 | eventBus.on(EVENTS.DOWNLOAD_USER_CODE, () => { 60 | const { zipInSingleFile, zipFileName } = getState() 61 | 62 | downloadUserCode({ 63 | zipFileName, 64 | zipInSingleFile, 65 | htmlContent: htmlEditor.getValue(), 66 | cssContent: cssEditor.getValue(), 67 | jsContent: jsEditor.getValue() 68 | }) 69 | }) 70 | 71 | eventBus.on(EVENTS.DRAG_FILE, ({ detail: { content, typeFile } }) => { 72 | const file = typeFile 73 | 74 | switch (file) { 75 | case 'text/javascript': 76 | jsEditor.setValue(content) 77 | break 78 | case 'text/css': 79 | cssEditor.setValue(content) 80 | break 81 | case 'text/html': 82 | htmlEditor.setValue(content) 83 | break 84 | default: 85 | break 86 | } 87 | }) 88 | 89 | eventBus.on(EVENTS.OPEN_NEW_INSTANCE, () => { 90 | const htmlContent = htmlEditor.getValue() 91 | const cssContent = cssEditor.getValue() 92 | const jsContent = jsEditor.getValue() 93 | 94 | const isEmpty = !htmlContent && !cssContent && !jsContent 95 | if (isEmpty) return 96 | 97 | const { updateHistory } = getHistoryState() 98 | updateHistory({ key: 'current', value: null }) 99 | }) 100 | 101 | eventBus.on(EVENTS.OPEN_EXISTING_INSTANCE, ({ detail: { id, value } }) => { 102 | const { updateHistory } = getHistoryState() 103 | let { pathname } = window.location 104 | window.history.replaceState(null, null, `/${value}`) 105 | pathname = window.location.pathname 106 | 107 | const [rawHtml, rawCss, rawJs] = pathname.slice(1).split('%7C') 108 | 109 | const VALUES = { 110 | html: rawHtml ? decode(rawHtml) : '', 111 | css: rawCss ? decode(rawCss) : '', 112 | javascript: rawJs ? decode(rawJs) : '' 113 | } 114 | 115 | htmlEditor.setValue(VALUES.html) 116 | cssEditor.setValue(VALUES.css) 117 | jsEditor.setValue(VALUES.javascript) 118 | updateHistory({ key: 'current', value: id }) 119 | }) 120 | 121 | eventBus.on(EVENTS.CLEAR_HISTORY, () => { 122 | const { clearHistory } = getHistoryState() 123 | clearHistory() 124 | }) 125 | -------------------------------------------------------------------------------- /src/css/aside.css: -------------------------------------------------------------------------------- 1 | aside { 2 | display: flex; 3 | height: 100dvh; 4 | } 5 | 6 | aside button { 7 | position: relative; 8 | } 9 | 10 | .console-badge-count { 11 | position: absolute; 12 | top: 6px; 13 | right: 6px; 14 | background: #ef5350; 15 | color: #fff; 16 | border-radius: 50%; 17 | min-width: 16px; 18 | height: 16px; 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | font-size: 9px; 23 | font-weight: 700; 24 | padding: 0 4px; 25 | pointer-events: none; 26 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); 27 | } 28 | 29 | .console-badge-count[hidden] { 30 | display: none; 31 | } 32 | 33 | aside button .button-title { 34 | background: var(--button-title-background); 35 | border-radius: 3px; 36 | border: 1px solid var(--button-title-border); 37 | color: var(--button-title-foreground); 38 | display: none; 39 | font-size: 1.1em; 40 | padding: 0.5em 0.75em; 41 | position: absolute; 42 | right: -10px; 43 | transform: translateX(100%); 44 | z-index: 1000; 45 | 46 | &:after { 47 | content: ""; 48 | position: absolute; 49 | transform: translate(-100%, -50%); 50 | top: 50%; 51 | left: 1px; 52 | right: 0; 53 | width: 0px; 54 | height: 0px; 55 | border-top: 0.5em solid transparent; 56 | border-bottom: 0.5em solid transparent; 57 | border-right: 0.5em solid var(--button-title-background); 58 | } 59 | 60 | &:before { 61 | content: ""; 62 | position: absolute; 63 | transform: translate(-100%, -50%); 64 | top: 50%; 65 | left: 0px; 66 | right: 0; 67 | width: 0px; 68 | height: 0px; 69 | border-top: 0.5em solid transparent; 70 | border-bottom: 0.5em solid transparent; 71 | border-right: 0.5em solid var(--button-title-border); 72 | } 73 | } 74 | 75 | aside button.is-active { 76 | opacity: 1; 77 | &::after { 78 | content: ''; 79 | inset: 0; 80 | margin: auto; 81 | background: #000; 82 | border-radius: 4px; 83 | } 84 | } 85 | 86 | @media (hover: hover) and (pointer: fine) { 87 | aside button:hover { 88 | opacity: 1; 89 | & .button-title { 90 | display: block; 91 | } 92 | } 93 | } 94 | 95 | .aside-bar { 96 | background: var(--aside-bar-background); 97 | color: var(--aside-bar-foreground); 98 | border-right: 1px solid var(--aside-bar-border); 99 | width: 310px; 100 | height: 100dvh; 101 | overflow-y: auto; 102 | scrollbar-gutter: stable; 103 | 104 | &::-webkit-scrollbar { 105 | width: 0.85em; 106 | } 107 | 108 | &::-webkit-scrollbar-thumb { 109 | background-color: hsla(0, 0%, 50%, 0); 110 | } 111 | 112 | &:hover::-webkit-scrollbar-thumb { 113 | background-color: hsla(0, 0%, 50%, 0.4); 114 | } 115 | 116 | &::-webkit-scrollbar-thumb:hover { 117 | background-color: hsla(0, 0%, 50%, 0.6); 118 | } 119 | 120 | &::-webkit-scrollbar-thumb:active { 121 | background-color: hsla(0, 0%, 50%, 1); 122 | } 123 | } 124 | 125 | .aside-sections { 126 | background: var(--aside-sections-background); 127 | border-right: 1px solid var(--aside-sections-border); 128 | display: flex; 129 | flex-direction: column; 130 | height: 100%; 131 | justify-content: space-between; 132 | width: 52px; 133 | } 134 | 135 | .aside-sections button { 136 | background: transparent; 137 | border: 0; 138 | cursor: pointer; 139 | width: 100%; 140 | color: var(--button-foreground); 141 | transition: opacity 0.1s ease; 142 | padding: 10px 0; 143 | } 144 | 145 | .aside-sections button.is-active { 146 | border-radius: 4px; 147 | color: var(--button-foreground-active); 148 | } 149 | 150 | .aside-sections button:not(:disabled):hover { 151 | color: var(--button-foreground-active); 152 | } 153 | 154 | .aside-sections svg { 155 | width: 25px; 156 | height: 25px; 157 | } 158 | 159 | .aside-sections header, 160 | .aside-sections footer { 161 | display: flex; 162 | flex-direction: column; 163 | justify-content: center; 164 | } -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @import url('./css/base.css'); 2 | @import url('./css/fonts.css'); 3 | @import url('./css/animations.css'); 4 | 5 | @import url('./css/editors-layout.css'); 6 | @import url('./css/aside.css'); 7 | @import url('./css/notifications.css'); 8 | @import url('./css/skypack.css'); 9 | @import url('./css/settings.css'); 10 | @import url('./css/drag-drop-area.css'); 11 | @import url('./css/console.css'); 12 | @import url('./css/history.css'); 13 | 14 | 15 | .scroll-buttons-container { 16 | display: none; 17 | } 18 | 19 | /* Magic Menu Styles */ 20 | .preview { 21 | position: relative; 22 | } 23 | 24 | .iframe-container{ 25 | min-height: 0; 26 | } 27 | body{ 28 | height: 100dvh; 29 | } 30 | 31 | @media (max-width: 650px) { 32 | #app { 33 | grid-template-columns: 1fr; 34 | } 35 | 36 | aside { 37 | display: flex; 38 | flex-direction: column; 39 | width: 100%; 40 | position: absolute; 41 | top: 0; 42 | z-index: 10; 43 | transition: top 0.2s; 44 | } 45 | 46 | .aside-bar { 47 | width: 100%; 48 | max-height: calc(100dvh - 49px); 49 | } 50 | 51 | .aside-sections { 52 | flex-direction: row; 53 | width: 100%; 54 | padding-inline-start: 10px; 55 | padding-inline-end: 10px; 56 | height: fit-content; 57 | } 58 | 59 | .aside-sections > header, 60 | .aside-sections > footer { 61 | flex-direction: row; 62 | gap: 0 5px; 63 | } 64 | 65 | .bar-button { 66 | transform: scale(0.8); 67 | } 68 | 69 | .aside-sections button.is-active, 70 | aside button.is-active { 71 | border-left: none; 72 | } 73 | 74 | aside button:hover .button-title { 75 | display: none; 76 | } 77 | 78 | .search-results { 79 | max-width: 90vw; 80 | } 81 | 82 | #editor { 83 | scroll-snap-type: y mandatory; 84 | overflow-y: scroll; 85 | 86 | display: flex; 87 | flex-direction: column; 88 | margin: 75px 0 0; 89 | } 90 | 91 | .editor{ 92 | min-height: calc(100vh - 75px); 93 | scroll-snap-align: start; 94 | } 95 | 96 | .iframe-container { 97 | scroll-snap-align: start; 98 | min-height: 100dvh; 99 | } 100 | 101 | iframe body { 102 | padding: 0 0 5em; 103 | } 104 | 105 | .vertical-gutter, 106 | .horizontal-gutter { 107 | display: none; 108 | } 109 | 110 | .margin { 111 | max-width: 10px; 112 | } 113 | 114 | #html .margin { 115 | background: #F33E15; 116 | } 117 | #css .margin { 118 | background: #158BF3; 119 | } 120 | #js .margin { 121 | background: #E99F1E; 122 | } 123 | 124 | #html::after, 125 | #css::after, 126 | #js::after { 127 | display: none; 128 | } 129 | 130 | .iPadShowKeyboard { 131 | display: none; 132 | } 133 | 134 | .scroll-buttons-container { 135 | display: block; 136 | position: absolute; 137 | bottom: 16px; 138 | right: 16px; 139 | 140 | display: flex; 141 | justify-content: center; 142 | align-items: center; 143 | gap: 0 1em; 144 | z-index: 10; 145 | } 146 | 147 | .scroll-button { 148 | width: 4em; 149 | aspect-ratio: 1; 150 | border: none; 151 | border-radius: 50%; 152 | display: flex; 153 | justify-content: center; 154 | align-items: center; 155 | transition: 0.2s background-color ease; 156 | 157 | background: #158BF3; 158 | color: #fff; 159 | cursor: pointer; 160 | -webkit-tap-highlight-color: transparent; 161 | } 162 | 163 | .scroll-button:hover { 164 | background: #3ba1fa; 165 | } 166 | 167 | .scroll-button > svg { 168 | width: 24px; 169 | } 170 | 171 | .previewer-active { 172 | height: calc(100dvh - 75px); 173 | display: block; 174 | } 175 | .previewer-hide { 176 | display: none; 177 | } 178 | } 179 | 180 | 181 | button { 182 | &:disabled, &:hover:disabled { 183 | opacity: 0.2; 184 | cursor: not-allowed; 185 | } 186 | } 187 | 188 | .spinner { 189 | border: 3px solid rgba(0, 0, 0, 0.1); 190 | width: 26px; 191 | height: 26px; 192 | border-radius: 50%; 193 | border-left-color: #c5c5c5; 194 | margin: auto; 195 | 196 | animation: spin 1s linear infinite; 197 | } 198 | -------------------------------------------------------------------------------- /src/css/console.css: -------------------------------------------------------------------------------- 1 | .console { 2 | & .console-header { 3 | position: sticky; 4 | top: 0; 5 | z-index: 1; 6 | padding-top: 1em; 7 | } 8 | 9 | & .console-type { 10 | padding-bottom: 8px; 11 | opacity: 0.6; 12 | } 13 | 14 | & .console-item { 15 | color: #fff; 16 | display: flex; 17 | flex-direction: column; 18 | width: 100%; 19 | padding: 0 0 0 1em; 20 | } 21 | 22 | & .console-item strong { 23 | display: block; 24 | overflow: hidden; 25 | text-overflow: ellipsis; 26 | } 27 | 28 | & .console-list { 29 | list-style: none; 30 | padding: 0; 31 | display: grid; 32 | gap: 0.5em; 33 | 34 | & li { 35 | display: flex; 36 | align-items: start; 37 | gap: 0.75em; 38 | 39 | &:not(:last-child) { 40 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 41 | } 42 | } 43 | 44 | & .error { 45 | background: rgb(235, 189, 189); 46 | border: 2px solid rgb(204, 0, 0); 47 | color: black; 48 | padding-top: 0.5em 49 | } 50 | 51 | & .error::before { 52 | content: '❗'; 53 | margin-right: 4px; 54 | width: 1em; 55 | } 56 | 57 | & .log-log { 58 | color: var(--log-log) 59 | } 60 | 61 | & .log-info { 62 | color: var(--log-info) 63 | } 64 | 65 | & .log-error { 66 | color: var(--log-error) 67 | } 68 | 69 | & .log-warn { 70 | color: var(--log-warn) 71 | } 72 | 73 | & .log-debug { 74 | color: var(--log-debug, #ab47bc) 75 | } 76 | 77 | & .log-table { 78 | color: var(--log-table, #66bb6a) 79 | } 80 | 81 | & .log-count { 82 | color: var(--log-count, #26c6da) 83 | } 84 | 85 | & .log-trace { 86 | color: var(--log-trace, #8d6e63) 87 | } 88 | 89 | & .log-dir { 90 | color: var(--log-dir, #78909c) 91 | } 92 | 93 | & .log-dirxml { 94 | color: var(--log-dirxml, #78909c) 95 | } 96 | 97 | & .log-time { 98 | color: var(--log-time, #ffd54f) 99 | } 100 | 101 | & .log-assert { 102 | color: var(--log-assert, #ff7043) 103 | } 104 | } 105 | 106 | /* aca tocaria ajustar los colores dependiendo del tema que este puesto pero por defecto es el mismo que el resto de la pagina */ 107 | .console-string { 108 | color: #ce9178; 109 | } 110 | 111 | .console-number { 112 | color: #b5cea8; 113 | } 114 | 115 | .console-boolean { 116 | color: #569cd6; 117 | } 118 | 119 | .console-null { 120 | color: #d16969; 121 | } 122 | 123 | .console-undefined { 124 | color: #d16969; 125 | } 126 | 127 | .console-key { 128 | color: #9cdcfe; 129 | } 130 | 131 | .console-regexp { 132 | color: #b46695; 133 | } 134 | 135 | .console-date { 136 | color: #9cdcfe; 137 | } 138 | 139 | .console-fn::before { 140 | content: "ƒ "; 141 | font-style: italic; 142 | color: #ce9178; 143 | } 144 | 145 | .console-async-fn::before { 146 | content: "async ƒ "; 147 | } 148 | 149 | .console-generator-fn::before { 150 | content: "ƒ* "; 151 | } 152 | 153 | .console-badge { 154 | display: inline-block; 155 | padding: 2px 6px; 156 | border-radius: 3px; 157 | font-size: 8px; 158 | font-weight: 600; 159 | letter-spacing: 0.5px; 160 | color: #000; 161 | flex-shrink: 0; 162 | margin-top: 2px; 163 | } 164 | 165 | .console-table { 166 | margin-top: 0.5em; 167 | overflow-x: auto; 168 | 169 | & table { 170 | border-collapse: collapse; 171 | font-size: 12px; 172 | min-width: 100%; 173 | background: rgba(255, 255, 255, 0.05); 174 | } 175 | 176 | & th { 177 | background: rgba(255, 255, 255, 0.1); 178 | padding: 6px 12px; 179 | text-align: left; 180 | font-weight: 600; 181 | border: 1px solid rgba(255, 255, 255, 0.2); 182 | color: #9cdcfe; 183 | } 184 | 185 | & td { 186 | padding: 6px 12px; 187 | border: 1px solid rgba(255, 255, 255, 0.1); 188 | vertical-align: top; 189 | } 190 | 191 | & tr:hover { 192 | background: rgba(255, 255, 255, 0.05); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ### [Codi.link](https://codi.link) 4 | 5 | ***Your HTML5/CSS3/JavaScript Playground Editor*** 6 |
7 | 8 |
9 | 10 | ![](https://img.shields.io/badge/Contributions-Welcome-brightgreen.svg) 11 | ![](https://img.shields.io/badge/Maintained%3F-Yes-brightgreen.svg) 12 | 13 |
14 | 15 | 16 | 17 |
18 | Table of Contents 19 |
    20 |
  1. 21 | About The Project 22 | Getting Started 23 | 26 |
  2. 27 |
  3. Contributing
  4. 28 |
  5. License
  6. 29 |
  7. Contact
  8. 30 |
  9. Acknowledgments
  10. 31 |
32 |
33 | 34 | ## About The Project 35 | 36 | ![codi.link screenshot](https://user-images.githubusercontent.com/1561955/136448123-471b6332-8e0c-402e-956b-80adf2585168.png) 37 | 38 | codi.link is a live editor for HTML, CSS and JS. It allows you to edit your code in real-time, and see the result instantly. [Check a demo](https://codi.link/PGRpdj4KICA8YnV0dG9uPvCfpbMgQ2xpY2sgbWUgWUFZITwvYnV0dG9uPgo8L2Rpdj4=%7CYnV0dG9uIHsKICBmb250LXNpemU6IDQ4cHg7CiAgYm9yZGVyOiAxcHggc29saWQgIzA5ZjsKICBiYWNrZ3JvdW5kOiAjZmZmOwogIGNvbG9yOiAjMzMzOwogIHBhZGRpbmc6IDRweCAxNnB4OwogIGN1cnNvcjogcG9pbnRlcjsKICBib3JkZXItcmFkaXVzOiA5OTk5cHg7Cn0KCmJvZHkgewogIGRpc3BsYXk6IGdyaWQ7CiAgcGxhY2UtY29udGVudDogY2VudGVyOwogIGhlaWdodDogMTAwdmg7Cn0=%7CaW1wb3J0IENhbnZhc0NvbmZldHRpIGZyb20gJ2h0dHBzOi8vY2RuLnNreXBhY2suZGV2L2NhbnZhcy1jb25mZXR0aSc7Cgpkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCdidXR0b24nKS5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsICgpID0+IHsKICBDYW52YXNDb25mZXR0aSgpCn0p) 39 | 40 |

Back to top 🔼

41 | 42 | ## Getting Started 43 | 44 | Install the dependencies: 45 | 46 | ```sh 47 | $ npm install 48 | // or 49 | $ yarn 50 | ``` 51 | 52 | Run in dev mode: 53 | 54 | ```sh 55 | $ npm run dev 56 | // or 57 | $ yarn dev 58 | ``` 59 | 60 | Run using Docker (by the Community): 61 | 62 | ```sh 63 | docker run -d --rm -p 5173:5173 --name codilink ferning98/codi.link 64 | ``` 65 | You can get more details and examples on how to run this on Docker [here](https://hub.docker.com/r/ferning98/codi.link) 66 | 67 | ### Built With 68 | 69 | - JavaScript 70 | - [Lit](https://lit.dev) 71 | - [Vite](https://vitejs.dev) 72 | - [Zustand](https://zustand.surge.sh) 73 | 74 |

Back to top 🔼

75 | 76 | ## Contributing 77 | 78 |
79 | 80 | ![Alt](https://repobeats.axiom.co/api/embed/909ddb19f56a1b9243b52b5994db4b0b8021b616.svg "Repobeats analytics image") 81 | 82 |
83 | 84 | Contributions are what make the Open Source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 85 | 86 | If you have a suggestion that would make this better, please fork the repo and create a Pull Request. You can also simply [open an issue](https://github.com/midudev/codi.link/issues) with the tag *enhancement*. 87 | 88 | Don't forget to **give the project a star ⭐!** Thanks again! 89 | 90 | 1. Fork the project 91 | 92 | 2. Clone the repository 93 | 94 | ```bash 95 | git clone https://github.com/@username/codi.link 96 | ``` 97 | 98 | 3. Create your Feature Branch 99 | 100 | ```bash 101 | git checkout -b feature/AmazingFeature 102 | ``` 103 | 104 | 4. Push to the Branch 105 | 106 | ```bash 107 | git push origin feature/AmazingFeature 108 | ``` 109 | 110 | 5. Open a Pull Request 111 | 112 |

Back to top 🔼

113 | 114 | ## License 115 | 116 | Distributed under the MIT License. See `LICENSE` for more information. 117 | 118 |

Back to top 🔼

119 | 120 | ## Contact 📭 121 | 122 | **Miguel Ángel Durán @midudev** 123 | [@midudev](https://twitter.com/midudev) - miduga@gmail.com 124 | 125 |

Back to top 🔼

126 | -------------------------------------------------------------------------------- /src/grid.js: -------------------------------------------------------------------------------- 1 | import Split from 'split-grid' 2 | import { 3 | DEFAULT_GRID_TEMPLATE, 4 | EDITOR_GRID_TEMPLATE 5 | } from './constants/editor-grid-template' 6 | import { 7 | BOTTOM_LAYOUT, 8 | DEFAULT_LAYOUT, 9 | HORIZONTAL_LAYOUT, 10 | TABS_LAYOUT, 11 | VERTICAL_LAYOUT 12 | } from './constants/grid-templates' 13 | import { getState } from './state' 14 | import { $, $$ } from './utils/dom' 15 | 16 | const $editor = $('#editor') 17 | const rootElement = document.documentElement 18 | const $$layoutSelector = $$('layout-preview') 19 | const $$editors = $$('#editor codi-editor') 20 | const $tabsContainer = $('#tabs') 21 | const $$tabs = $$('#tabs label') 22 | let splitInstance 23 | 24 | const selectTab = event => { 25 | $$editors.forEach($editor => ($editor.style.display = 'none')) 26 | const $targetEditor = $(`#${event.target.getAttribute('for')}`) 27 | $targetEditor.style.display = 'block' 28 | $$tabs.forEach($t => $t.classList.remove('active')) 29 | event.target.classList.add('active') 30 | } 31 | 32 | $$tabs.forEach($tab => { 33 | $tab.addEventListener('click', selectTab) 34 | }) 35 | 36 | const formatGutters = gutter => ({ 37 | ...gutter, 38 | element: $(gutter.element) 39 | }) 40 | 41 | // Metodo de preservasión de grid 42 | const saveGridTemplate = () => { 43 | const { preserveGrid } = getState() 44 | if (!preserveGrid) return 45 | 46 | const gridStyles = $('.grid').style 47 | 48 | const gridTemplate = JSON.stringify({ 49 | 'grid-template-columns': gridStyles['grid-template-columns'], 50 | 'grid-template-rows': gridStyles['grid-template-rows'] 51 | }) 52 | 53 | window.localStorage.setItem('gridTemplate', gridTemplate) 54 | } 55 | 56 | const getInitialGridStyle = () => { 57 | const { preserveGrid } = getState() 58 | if (!preserveGrid) return window.localStorage.removeItem('gridTemplate') 59 | 60 | const gridTemplate = JSON.parse(window.localStorage.getItem('gridTemplate')) 61 | 62 | return ( 63 | gridTemplate && 64 | `grid-template-columns: ${gridTemplate['grid-template-columns']}; grid-template-rows: ${gridTemplate['grid-template-rows']}` 65 | ) 66 | } 67 | 68 | const configLayoutTabsElements = type => { 69 | if (type === 'tabs') { 70 | $tabsContainer.removeAttribute('hidden') 71 | $tabsContainer.style.display = 'grid' 72 | $tabsContainer.querySelector('label').classList.add('active') 73 | $('.second-gutter').style.display = 'none' 74 | $('.last-gutter').style.display = 'none' 75 | $$editors.forEach(($editor, index) => { 76 | $editor.style.display = 'none' 77 | $editor.style.gridArea = 'editors' 78 | 79 | if (index === 0) { 80 | $editor.style.display = 'block' 81 | } 82 | }) 83 | } else { 84 | $tabsContainer.setAttribute('hidden', 'hidde') 85 | $tabsContainer.style.display = 'none' 86 | $('.second-gutter').style.display = 'block' 87 | $('.last-gutter').style.display = 'block' 88 | $$editors.forEach(($editor, i) => { 89 | $editor.style.display = 'block' 90 | $editor.style.gridArea = $editor.getAttribute('data-grid-area') 91 | }) 92 | } 93 | } 94 | 95 | const setGridLayout = (type = '') => { 96 | const style = EDITOR_GRID_TEMPLATE[type] || DEFAULT_GRID_TEMPLATE 97 | 98 | const gutters = 99 | { 100 | vertical: VERTICAL_LAYOUT, 101 | horizontal: HORIZONTAL_LAYOUT, 102 | bottom: BOTTOM_LAYOUT, 103 | tabs: TABS_LAYOUT 104 | }[type] ?? DEFAULT_LAYOUT 105 | 106 | configLayoutTabsElements(type) 107 | 108 | const initialStyle = !splitInstance && getInitialGridStyle() 109 | 110 | rootElement.setAttribute('data-layout', type) 111 | $editor.setAttribute('style', initialStyle || style) 112 | 113 | $$layoutSelector.forEach(layoutEl => { 114 | if (type === layoutEl.getAttribute('layout')) { 115 | layoutEl.setAttribute('active', 'true') 116 | } else { 117 | layoutEl.removeAttribute('active') 118 | } 119 | }) 120 | 121 | saveGridTemplate() 122 | 123 | const splitConfig = { 124 | ...gutters, 125 | ...(gutters.columnGutters && { 126 | columnGutters: gutters.columnGutters.map(formatGutters) 127 | }), 128 | ...(gutters.rowGutters && { 129 | rowGutters: gutters.rowGutters.map(formatGutters) 130 | }), 131 | ...(type === 'tabs' && { columnMinSizes: { 0: 300 } }), 132 | minSize: 1, 133 | onDragEnd: saveGridTemplate 134 | } 135 | 136 | if (splitInstance) { 137 | splitInstance.destroy(true) 138 | } 139 | 140 | splitInstance = Split(splitConfig) 141 | } 142 | 143 | export default setGridLayout 144 | -------------------------------------------------------------------------------- /src/components/codi-editor/themes.json: -------------------------------------------------------------------------------- 1 | { 2 | "one-dark-pro": { 3 | "label": "One Dark Pro", 4 | "base": "vs-dark", 5 | "inherit": true, 6 | "semanticHighlighting": true, 7 | "rules": [ 8 | { 9 | "token": "comment", 10 | "foreground": "#7f8590", 11 | "fontStyle": "italic" 12 | }, 13 | { 14 | "token": "keyword", 15 | "foreground": "#c779dc" 16 | }, 17 | { 18 | "token": "number", 19 | "foreground": "#d19a66" 20 | }, 21 | { 22 | "token": "string", 23 | "foreground": "#99c27a" 24 | }, 25 | { 26 | "token": "storage", 27 | "foreground": "#ffad66" 28 | }, 29 | { 30 | "token": "identifier", 31 | "foreground": "#62afef" 32 | }, 33 | { 34 | "token": "tag", 35 | "foreground": "#e06c75" 36 | }, 37 | { 38 | "token": "attribute.value", 39 | "foreground": "#99c27a" 40 | }, 41 | { 42 | "token": "attribute.name", 43 | "foreground": "#d19a66" 44 | }, 45 | { 46 | "token": "selector.class", 47 | "foreground": "#d19a66" 48 | } 49 | ], 50 | "colors": { 51 | "editor.background": "#282b34", 52 | "editor.lineHighlightBackground": "#2e313d" 53 | } 54 | }, 55 | "dracula": { 56 | "base": "vs-dark", 57 | "inherit": true, 58 | "label": "Dracula", 59 | "rules": [ 60 | { 61 | "token": "comment", 62 | "foreground": "#5b72a4", 63 | "fontStyle": "italic" 64 | }, 65 | { 66 | "token": "keyword", 67 | "foreground": "#fe7bc8" 68 | }, 69 | { 70 | "token": "number", 71 | "foreground": "#bf95f9" 72 | }, 73 | { 74 | "token": "string", 75 | "foreground": "#e5fb8e" 76 | }, 77 | { 78 | "token": "storage", 79 | "foreground": "#ffad66" 80 | }, 81 | { 82 | "token": "identifier", 83 | "foreground": "#bf95f9" 84 | }, 85 | { 86 | "token": "tag", 87 | "foreground": "#fe7bc8" 88 | }, 89 | { 90 | "token": "attribute.value", 91 | "foreground": "#e5fb8e" 92 | }, 93 | { 94 | "token": "attribute.name", 95 | "foreground": "#50fb7b" 96 | }, 97 | { 98 | "token": "selector.class", 99 | "foreground": "#d19a66" 100 | } 101 | ], 102 | "colors": { 103 | "editor.background": "#2a2c37", 104 | "editor.lineHighlightBackground": "#2a2c37" 105 | } 106 | }, 107 | "mosqueta-dark": { 108 | "base": "vs-dark", 109 | "inherit": true, 110 | "label": "Mosqueta Dark", 111 | "rules": [ 112 | { 113 | "token": "comment", 114 | "foreground": "#6272a4", 115 | "fontStyle": "italic" 116 | }, 117 | { 118 | "token": "keyword", 119 | "foreground": "#e67cbe" 120 | }, 121 | { 122 | "token": "number", 123 | "foreground": "#ffec80" 124 | }, 125 | { 126 | "token": "string", 127 | "foreground": "#f8ff97" 128 | }, 129 | { 130 | "token": "storage", 131 | "foreground": "#ffad66" 132 | }, 133 | { 134 | "token": "identifier", 135 | "foreground": "#deff8a" 136 | }, 137 | { 138 | "token": "tag", 139 | "foreground": "#ffb3d9" 140 | }, 141 | { 142 | "token": "attribute.value", 143 | "foreground": "#f9fe95" 144 | }, 145 | { 146 | "token": "attribute.name", 147 | "foreground": "#fdd686" 148 | }, 149 | { 150 | "token": "selector.class", 151 | "foreground": "#d19a66" 152 | } 153 | ], 154 | "colors": { 155 | "editor.background": "#232935", 156 | "editor.lineHighlightBackground": "#1a1d28" 157 | } 158 | } 159 | } -------------------------------------------------------------------------------- /src/console-script.js: -------------------------------------------------------------------------------- 1 | export const generateConsoleScript = ({ html, css }) => { 2 | return `` 163 | } 164 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { encode, decode } from 'js-base64' 2 | import { $, $$ } from './utils/dom.js' 3 | import { createEditor } from './editor.js' 4 | import debounce from './utils/debounce.js' 5 | import runJs from './utils/run-js.js' 6 | import { initializeEventsController, eventBus, EVENTS } from './events-controller.js' 7 | import { getState, subscribe } from './state.js' 8 | import * as Preview from './utils/WindowPreviewer.js' 9 | import setGridLayout from './grid.js' 10 | import setTheme from './theme.js' 11 | import setLanguage from './language.js' 12 | import { configurePrettierHotkeys } from './monaco-prettier/configurePrettier' 13 | import { getHistoryState, subscribeHistory, setHistory } from './history.js' 14 | 15 | import './aside.js' 16 | import './skypack.js' 17 | import './settings.js' 18 | import './scroll.js' 19 | import './drag-file.js' 20 | import './console.js' 21 | 22 | import { BUTTON_ACTIONS } from './constants/button-actions.js' 23 | 24 | import './components/layout-preview/layout-preview.jsx' 25 | import './components/codi-editor/codi-editor.js' 26 | 27 | const { layout: currentLayout, theme, language, saveLocalstorage } = getState() 28 | const { history, updateHistoryItem } = getHistoryState() 29 | 30 | setGridLayout(currentLayout) 31 | setTheme(theme) 32 | setLanguage(language) 33 | 34 | const iframe = $('iframe') 35 | 36 | const editorElements = $$('codi-editor') 37 | 38 | let { pathname } = window.location 39 | 40 | if (pathname === '/' && saveLocalstorage === true && history.current) { 41 | const hashedCode = history.items.find(item => item.id === history.current).value 42 | window.history.replaceState(null, null, `/${hashedCode}`) 43 | pathname = window.location.pathname 44 | } 45 | 46 | const [rawHtml, rawCss, rawJs] = pathname.slice(1).split(pathname.includes('%7C') ? '%7C' : '|') 47 | 48 | const VALUES = { 49 | html: rawHtml ? decode(rawHtml) : '', 50 | css: rawCss ? decode(rawCss) : '', 51 | javascript: rawJs ? decode(rawJs) : '' 52 | } 53 | 54 | const EDITORS = Array.from(editorElements).reduce((acc, domElement) => { 55 | const { language } = domElement 56 | domElement.value = VALUES[language] 57 | acc[language] = createEditor(domElement) 58 | return acc 59 | }, {}) 60 | 61 | subscribe(state => { 62 | const newOptions = { ...state, minimap: { enabled: state.minimap } } 63 | 64 | Object.values(EDITORS).forEach(editor => { 65 | editor.updateOptions({ 66 | ...editor.getRawOptions(), 67 | ...newOptions 68 | }) 69 | }) 70 | setGridLayout(state.layout) 71 | setTheme(state.theme) 72 | setLanguage(state.language) 73 | }) 74 | 75 | const MS_UPDATE_DEBOUNCED_TIME = 200 76 | const MS_UPDATE_HASH_DEBOUNCED_TIME = 1000 77 | const debouncedUpdate = debounce(update, MS_UPDATE_DEBOUNCED_TIME) 78 | const debouncedUpdateHash = debounce( 79 | updateHashedCode, 80 | MS_UPDATE_HASH_DEBOUNCED_TIME 81 | ) 82 | 83 | const { html: htmlEditor, css: cssEditor, javascript: jsEditor } = EDITORS 84 | 85 | if (saveLocalstorage) { 86 | setHistory(history) 87 | 88 | subscribeHistory(store => { 89 | if (!store.history.current) { 90 | jsEditor.setValue('') 91 | cssEditor.setValue('') 92 | htmlEditor.setValue('') 93 | } 94 | setHistory(store.history) 95 | }) 96 | } 97 | 98 | htmlEditor.focus() 99 | Object.values(EDITORS).forEach(editor => { 100 | editor.onDidChangeModelContent(() => 101 | debouncedUpdate({ notReload: editor === cssEditor }) 102 | ) 103 | }) 104 | initializeEventsController({ htmlEditor, cssEditor, jsEditor }) 105 | 106 | configurePrettierHotkeys([htmlEditor, cssEditor, jsEditor]) 107 | 108 | update() 109 | 110 | function update ({ notReload } = {}) { 111 | const values = { 112 | html: htmlEditor.getValue(), 113 | css: cssEditor.getValue(), 114 | js: jsEditor.getValue() 115 | } 116 | 117 | Preview.updatePreview(values) 118 | 119 | if (!notReload) { 120 | const { maxExecutionTime } = getState() 121 | runJs(values.js, parseInt(maxExecutionTime)) 122 | .then(() => { 123 | iframe.setAttribute('src', Preview.getPreviewUrl()) 124 | }) 125 | .catch(error => { 126 | console.error('Execution error:', error) 127 | }) 128 | } 129 | 130 | updateCss() 131 | 132 | debouncedUpdateHash(values) 133 | if (saveLocalstorage) { 134 | updateHistory(values) 135 | } 136 | updateButtonAvailabilityIfContent(values) 137 | } 138 | 139 | function updateCss () { 140 | const iframeStyleEl = iframe.contentDocument.querySelector('#preview-style') 141 | 142 | if (iframeStyleEl) { 143 | iframeStyleEl.textContent = cssEditor.getValue() 144 | } 145 | } 146 | 147 | function updateHashedCode ({ html, css, js }) { 148 | const hashedCode = `${encode(html)}|${encode(css)}|${encode(js)}` 149 | window.history.replaceState(null, null, `/${hashedCode}`) 150 | } 151 | 152 | function updateHistory ({ html, css, js }) { 153 | const { history } = getHistoryState() 154 | const hashedCode = `${encode(html)}|${encode(css)}|${encode(js)}` 155 | const isEmpty = !html.replace(/\n/g, '').trim() && !css.replace(/\n/g, '').trim() && !js.replace(/\n/g, '').trim() 156 | 157 | if (isEmpty && !history.current) { 158 | return 159 | } 160 | 161 | updateHistoryItem({ value: hashedCode }) 162 | } 163 | 164 | function updateButtonAvailabilityIfContent ({ html, css, js }) { 165 | const buttonActions = [ 166 | BUTTON_ACTIONS.downloadUserCode, 167 | BUTTON_ACTIONS.openIframeTab, 168 | BUTTON_ACTIONS.copyToClipboard 169 | ] 170 | 171 | const hasContent = html || css || js 172 | buttonActions.forEach(action => { 173 | const button = $(`button[data-action='${action}']`) 174 | button.disabled = !hasContent 175 | }) 176 | } 177 | 178 | // Keyboard shortcut: Cmd+S (Mac) or Ctrl+S (Windows/Linux) to download 179 | window.addEventListener('keydown', (event) => { 180 | const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key === 's' 181 | 182 | if (isSaveShortcut) { 183 | event.preventDefault() 184 | 185 | const downloadButton = $(`button[data-action='${BUTTON_ACTIONS.downloadUserCode}']`) 186 | 187 | // Only trigger download if button is not disabled (has content) 188 | if (!downloadButton.disabled) { 189 | eventBus.emit(EVENTS.DOWNLOAD_USER_CODE) 190 | } 191 | } 192 | }) 193 | -------------------------------------------------------------------------------- /src/css/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --app-background: rgb(30, 30, 30); 3 | --app-foreground: rgb(204, 204, 204); 4 | 5 | --aside-sections-background: rgb(51, 51, 51); 6 | --aside-sections-border: transparent; 7 | 8 | --aside-bar-background: rgb(37, 37, 38); 9 | --aside-bar-foreground: rgb(204, 204, 204); 10 | --aside-bar-border: transparent; 11 | 12 | --grid-background: rgb(37, 37, 38); 13 | 14 | --button-foreground: rgba(255, 255, 255, 0.4); 15 | --button-foreground-active: rgb(255, 255, 255); 16 | 17 | --button-title-background: rgb(51, 51, 51); 18 | --button-title-foreground: rgb(225, 228, 232); 19 | --button-title-border: rgb(51, 51, 51); 20 | 21 | --input-background: rgb(51, 51, 51); 22 | --input-foreground: rgb(225, 228, 232); 23 | --input-border: rgb(51, 51, 51); 24 | 25 | --log-log: rgb(205, 238, 181); 26 | --log-info: rgb(181, 181, 238); 27 | --log-error: rgb(227, 189, 189); 28 | --log-warn: rgb(247, 231, 161); 29 | } 30 | 31 | [data-theme='vs'] { 32 | --app-background: rgb(255, 255, 254); 33 | --app-foreground: rgb(0, 0, 0); 34 | 35 | --aside-sections-background: rgb(44, 44, 44); 36 | 37 | --aside-bar-background: rgb(243, 243, 243); 38 | --aside-bar-foreground: rgb(97, 97, 97); 39 | 40 | --grid-background: rgb(243, 243, 243); 41 | 42 | --gutter-background: rgb(243, 243, 243); 43 | 44 | --input-background: rgb(243, 243, 243); 45 | --input-foreground: rgb(97, 97, 97); 46 | --input-border: rgb(97, 97, 97); 47 | 48 | --log-log: rgb(14, 197, 8); 49 | --log-info: rgb(31, 76, 226); 50 | --log-error: rgb(219, 14, 14); 51 | --log-warn: rgb(203, 168, 8); 52 | } 53 | 54 | [data-theme='hc-black'] { 55 | --app-background: rgb(0, 0, 0); 56 | --app-foreground: rgb(255, 255, 255); 57 | 58 | --aside-sections-background: rgb(0, 0, 0); 59 | --aside-sections-border: rgb(255, 255, 255); 60 | 61 | --aside-bar-background: rgb(0, 0, 0); 62 | --aside-bar-foreground: rgb(255, 255, 255); 63 | --aside-bar-border: rgb(255, 255, 255); 64 | 65 | --grid-background: rgb(255, 255, 255); 66 | 67 | --button-foreground: rgb(255, 255, 255); 68 | 69 | --button-title-background: rgb(255, 255, 255); 70 | --button-title-foreground: rgb(0, 0, 0); 71 | --button-title-border: rgb(0, 0, 0); 72 | 73 | --input-background: rgb(0, 0, 0); 74 | --input-foreground: rgb(255, 255, 255); 75 | --input-border: rgb(255, 255, 255); 76 | } 77 | 78 | [data-theme='hc-light'] { 79 | --app-background: rgb(255, 255, 255); 80 | --app-foreground: rgb(0, 0, 0); 81 | 82 | --aside-sections-background: rgb(255, 255, 255); 83 | --aside-sections-border: rgb(15, 74, 133); 84 | 85 | --aside-bar-background: rgb(255, 255, 255); 86 | --aside-bar-foreground: rgb(0, 0, 0); 87 | --aside-bar-border: rgb(15, 74, 133); 88 | 89 | --grid-background: rgb(15, 74, 133); 90 | 91 | --gutter-background: rgb(255, 255, 255); 92 | 93 | --button-foreground: rgb(0, 0, 0); 94 | --button-foreground-active: rgb(0, 0, 0); 95 | 96 | --button-title-background: rgb(0, 0, 0); 97 | --button-title-foreground: rgb(255, 255, 255); 98 | --button-title-border: rgb(255, 255, 255); 99 | 100 | --input-background: rgb(255, 255, 255); 101 | --input-foreground: rgb(0, 0, 0); 102 | --input-border: rgb(0, 0, 0); 103 | 104 | --log-log: rgb(7, 154, 2); 105 | --log-info: rgb(6, 53, 210); 106 | --log-error: rgb(223, 0, 0); 107 | --log-warn: rgb(178, 145, 0); 108 | } 109 | 110 | [data-theme='mosqueta-dark'] { 111 | --app-background: #171a25; 112 | --app-foreground: rgb(51, 51, 51); 113 | 114 | --aside-sections-background: #171a25; 115 | --aside-sections-border: transparent; 116 | 117 | --aside-bar-background: #1e2530; 118 | --aside-bar-foreground: rgb(204, 204, 204); 119 | --aside-bar-border: transparent; 120 | 121 | --grid-background: #171a25; 122 | 123 | --button-foreground: rgba(255, 255, 255, 0.4); 124 | --button-foreground-active: #ff89ef; 125 | --button-border-active: #40142c; 126 | --button-background-active: #40142c; 127 | 128 | --button-title-background: #171a25; 129 | --button-title-foreground: rgb(225, 228, 232); 130 | --button-title-border: rgb(51, 51, 51); 131 | 132 | --input-background: #171a25; 133 | --input-foreground: #ff89ef; 134 | --input-border: #171a25; 135 | } 136 | 137 | [data-theme='dracula'] { 138 | --app-background: #171a25; 139 | --app-foreground: rgb(51, 51, 51); 140 | 141 | --aside-sections-background: #353646; 142 | --aside-sections-border: transparent; 143 | 144 | --aside-bar-background: #20232c; 145 | --aside-bar-foreground: #ffffff; 146 | --aside-bar-border: transparent; 147 | 148 | --grid-background: #171a25; 149 | 150 | --button-foreground: #6473a6; 151 | --button-foreground-active: #ffffff; 152 | --button-border-active: #40142c; 153 | --button-background-active: #40142c; 154 | 155 | --button-title-background: #171a25; 156 | --button-title-foreground: rgb(225, 228, 232); 157 | --button-title-border: rgb(51, 51, 51); 158 | 159 | --input-background: #454659; 160 | --input-foreground: #ffffff; 161 | --input-border: #171a25; 162 | } 163 | 164 | [data-theme='one-dark-pro'] { 165 | --app-background: #171a25; 166 | --app-foreground: rgb(51, 51, 51); 167 | 168 | --aside-sections-background: #282b34; 169 | --aside-sections-border: transparent; 170 | 171 | --aside-bar-background: #21262c; 172 | --aside-bar-foreground: #ffffff; 173 | --aside-bar-border: transparent; 174 | 175 | --grid-background: #171a25; 176 | 177 | --button-foreground: #6e7077; 178 | --button-foreground-active: #ffffff; 179 | --button-border-active: #40142c; 180 | --button-background-active: #40142c; 181 | 182 | --button-title-background: #171a25; 183 | --button-title-foreground: rgb(225, 228, 232); 184 | --button-title-border: rgb(51, 51, 51); 185 | 186 | --input-background: #404554; 187 | --input-foreground: #ffffff; 188 | --input-border: #171a25; 189 | } 190 | 191 | body { 192 | background-color: var(--app-background); 193 | color: var(--app-foreground); 194 | font-size: 16px; 195 | height: 100dvh; 196 | line-height: 1.42857143; 197 | margin: 0; 198 | overflow: hidden; 199 | padding: 0; 200 | position: fixed; 201 | width: 100vw; 202 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 203 | Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 204 | -webkit-font-smoothing: antialiased; 205 | -moz-osx-font-smoothing: grayscale; 206 | } 207 | 208 | * { 209 | box-sizing: border-box; 210 | } 211 | 212 | *::before, 213 | *::after { 214 | box-sizing: inherit; 215 | } 216 | 217 | a { 218 | color: #41a0ff; 219 | text-decoration: none; 220 | } 221 | 222 | a:hover { 223 | text-decoration: underline; 224 | } 225 | 226 | fieldset { 227 | margin: 0; 228 | padding: 0; 229 | border: 0; 230 | } 231 | 232 | select { 233 | background-color: var(--input-background); 234 | color: var(--input-foreground); 235 | border: 1px solid var(--input-border); 236 | height: 26px; 237 | margin-top: 8px; 238 | padding: 2px 8px; 239 | width: 100%; 240 | } 241 | 242 | [hidden] { 243 | display: none !important; 244 | } 245 | 246 | #app { 247 | display: grid; 248 | grid-template-columns: auto 1fr; 249 | height: 100dvh; 250 | 251 | #editor { 252 | height: 100dvh; 253 | } 254 | } 255 | 256 | iframe { 257 | background: #fff; 258 | border: 0; 259 | height: 100%; 260 | width: 100%; 261 | } -------------------------------------------------------------------------------- /src/css/editors-layout.css: -------------------------------------------------------------------------------- 1 | .grid { 2 | display: grid; 3 | grid-template-areas: 4 | 'editor-html . editor-js' 5 | '. . .' 6 | 'editor-css . editor-preview'; 7 | grid-template-columns: 1fr 8px 1fr; 8 | grid-template-rows: 1fr 8px 1fr; 9 | height: 100vh; 10 | 11 | [data-layout='default'] & { 12 | & .first-gutter { 13 | position: relative; 14 | box-shadow: -1px 0 0 0 var(--grid-background), 15 | 1px 0 0 0 var(--grid-background); 16 | } 17 | & .second-gutter { 18 | background: transparent; 19 | } 20 | & .last-gutter { 21 | position: relative; 22 | box-shadow: 0 -1px 0 0 var(--grid-background), 23 | 0 1px 0 0 var(--grid-background); 24 | } 25 | } 26 | [data-layout='layout-2'] & { 27 | grid-template-areas: 28 | 'editor-html . editor-css' 29 | '. . .' 30 | 'editor-js . editor-preview'; 31 | 32 | & .first-gutter { 33 | position: relative; 34 | box-shadow: -1px 0 0 0 var(--grid-background), 35 | 1px 0 0 0 var(--grid-background); 36 | } 37 | & .second-gutter { 38 | background: transparent; 39 | } 40 | & .last-gutter { 41 | position: relative; 42 | box-shadow: 0 -1px 0 0 var(--grid-background), 43 | 0 1px 0 0 var(--grid-background); 44 | } 45 | } 46 | [data-layout='vertical'] & { 47 | grid-template-areas: 'editor-html . editor-css . editor-js . editor-preview'; 48 | & .first-gutter { 49 | cursor: col-resize; 50 | grid-area: 1 / 2 / 1 / 2; 51 | position: relative; 52 | box-shadow: -1px 0 0 0 var(--grid-background), 53 | 1px 0 0 0 var(--grid-background); 54 | } 55 | & .second-gutter { 56 | cursor: col-resize; 57 | grid-area: 1 / 4 / 1 / 4; 58 | background-image: url(/assets/vertical.png); 59 | position: relative; 60 | box-shadow: -1px 0 0 0 var(--grid-background), 61 | 1px 0 0 0 var(--grid-background); 62 | } 63 | & .last-gutter { 64 | cursor: col-resize; 65 | grid-area: 1 / 6 / 1 / 6; 66 | background-image: url(/assets/vertical.png); 67 | position: relative; 68 | box-shadow: -1px 0 0 0 var(--grid-background), 69 | 1px 0 0 0 var(--grid-background); 70 | } 71 | } 72 | [data-layout='horizontal'] & { 73 | grid-template-areas: 74 | 'editor-html' 75 | '.' 76 | 'editor-css' 77 | '.' 78 | 'editor-js' 79 | '.' 80 | 'editor-preview'; 81 | & .first-gutter { 82 | grid-area: 2 / 1 / 2 / 1; 83 | cursor: row-resize; 84 | background-image: url(/assets/horizontal.png); 85 | position: relative; 86 | box-shadow: 0 -1px 0 0 var(--grid-background), 87 | 0 1px 0 0 var(--grid-background); 88 | } 89 | & .second-gutter { 90 | grid-area: 4 / 1 / 4 / 1; 91 | cursor: row-resize; 92 | background-image: url(/assets/horizontal.png); 93 | position: relative; 94 | box-shadow: 0 -1px 0 0 var(--grid-background), 95 | 0 1px 0 0 var(--grid-background); 96 | } 97 | & .last-gutter { 98 | grid-area: 6 / 1 / 6 / 1; 99 | cursor: row-resize; 100 | position: relative; 101 | box-shadow: 0 -1px 0 0 var(--grid-background), 102 | 0 1px 0 0 var(--grid-background); 103 | } 104 | } 105 | [data-layout='bottom'] & { 106 | grid-template-columns: 1fr 8px 1fr 8px 1fr; 107 | grid-template-rows: 1fr 8px 1fr; 108 | grid-template-areas: 109 | 'editor-preview editor-preview editor-preview editor-preview editor-preview' 110 | '. . . . .' 111 | 'editor-html . editor-js . editor-css'; 112 | & .first-gutter { 113 | cursor: col-resize; 114 | grid-area: 2 / 2 / 4 / 3; 115 | position: relative; 116 | box-shadow: -1px 0 0 0 var(--grid-background), 117 | 1px 0 0 0 var(--grid-background); 118 | } 119 | & .second-gutter { 120 | cursor: col-resize; 121 | grid-area: 2 / 4 / 4 / 5; 122 | background-image: url(/assets/vertical.png); 123 | position: relative; 124 | box-shadow: -1px 0 0 0 var(--grid-background), 125 | 1px 0 0 0 var(--grid-background); 126 | } 127 | & .last-gutter { 128 | cursor: row-resize; 129 | grid-area: 2 / 1 / 2 / 6; 130 | position: relative; 131 | box-shadow: 0 -1px 0 0 var(--grid-background), 132 | 0 1px 0 0 var(--grid-background); 133 | } 134 | } 135 | [data-layout='tabs'] & { 136 | grid-template-columns: 5fr 8px 3fr; 137 | grid-template-rows: 40px 1fr; 138 | grid-template-areas: 'tabs . editor-preview' 'editors . editor-preview'; 139 | 140 | & #tabs { 141 | display: grid; 142 | } 143 | 144 | & .first-gutter { 145 | cursor: col-resize; 146 | background-image: url(/assets/vertical.png); 147 | grid-area: 1 / 2 / 3 / 2; 148 | position: relative; 149 | box-shadow: -1px 0 0 0 var(--grid-background), 150 | 1px 0 0 0 var(--grid-background); 151 | } 152 | & .second-gutter, 153 | & .last-gutter { 154 | display: none; 155 | } 156 | } 157 | 158 | & #markup { 159 | grid-area: editor-html; 160 | } 161 | 162 | & #style { 163 | grid-area: editor-css; 164 | } 165 | 166 | & #script { 167 | grid-area: editor-js; 168 | } 169 | 170 | & #editor-preview { 171 | grid-area: editor-preview; 172 | } 173 | } 174 | 175 | #tabs { 176 | grid-area: tabs; 177 | grid-template-columns: repeat(3, 1fr); 178 | 179 | & label { 180 | display: flex !important; 181 | align-items: center; 182 | padding-left: 8px; 183 | border-bottom: 2px solid rgb(72, 72, 72); 184 | opacity: .2; 185 | transition: opacity 0.2s ease; 186 | 187 | &.active { 188 | border-bottom: 2px solid; 189 | opacity: 1 !important; 190 | 191 | &[for="markup"] { 192 | border-bottom-color: #e34f26; 193 | } 194 | &[for="style"] { 195 | border-bottom-color: rebeccapurple; 196 | } 197 | &[for="script"] { 198 | border-bottom-color: rgb(255, 193, 7); 199 | } 200 | } 201 | 202 | &:hover { 203 | cursor: pointer; 204 | opacity: .7; 205 | } 206 | 207 | & + label { 208 | border-left: 1px solid rgb(41, 41, 41); 209 | } 210 | 211 | &::before { 212 | content: ''; 213 | display: inline-block; 214 | width: 16px; 215 | height: 16px; 216 | margin-right: 0.5em; 217 | background-repeat: no-repeat; 218 | background-position: center; 219 | background-size: contain; 220 | } 221 | 222 | &[for="markup"]::before { 223 | background-image: url(/assets/html5.svg); 224 | } 225 | &[for="style"]::before { 226 | background-image: url(/assets/css.svg); 227 | } 228 | &[for="script"]::before { 229 | background-image: url(/assets/js.svg); 230 | } 231 | } 232 | } 233 | 234 | .first-gutter { 235 | cursor: col-resize; 236 | grid-area: 1 / 2 / 4 / 2; 237 | background: var(--grid-background); 238 | background-repeat: no-repeat; 239 | background-position: 50% center; 240 | background-image: url(/assets/vertical.png); 241 | } 242 | 243 | .second-gutter { 244 | cursor: all-scroll; 245 | z-index: 1; 246 | grid-area: 2 / 2 / 2 / 2; 247 | background: var(--grid-background); 248 | background-repeat: no-repeat; 249 | background-position: 50% center; 250 | } 251 | 252 | .last-gutter { 253 | cursor: row-resize; 254 | grid-area: 2 / 1 / 2 / 4; 255 | background: var(--grid-background); 256 | background-repeat: no-repeat; 257 | background-position: 50% center; 258 | background-image: url(/assets/horizontal.png); 259 | } 260 | -------------------------------------------------------------------------------- /src/skypack.js: -------------------------------------------------------------------------------- 1 | import { eventBus, EVENTS } from './events-controller.js' 2 | import debounce from './utils/debounce.js' 3 | import { $ } from './utils/dom.js' 4 | import escapeHTML from 'escape-html' 5 | 6 | const CDN_URL = 'https://cdn.skypack.dev' 7 | const PACKAGE_VIEW_URL = 'https://www.skypack.dev/view' 8 | 9 | const $asideBar = $('.aside-bar') 10 | const $searchResults = $('#skypack .search-results') 11 | const $searchResultsList = $searchResults.querySelector('ul') 12 | const $searchResultsMessage = $('#skypack .search-results-message') 13 | const $skypackSearch = $('#skypack input[type="search"]') 14 | const $spinner = $searchResults.querySelector('.spinner') 15 | 16 | $skypackSearch.addEventListener('input', debounce(handleSearchInput, 200)) 17 | 18 | let lastSearchInput = '' 19 | let currentPage = 1 20 | let totalPages = 1 21 | let lastFetchAbortController 22 | 23 | async function handleSearchInput () { 24 | const $searchInput = $skypackSearch 25 | 26 | const searchTerm = $searchInput.value.toLowerCase().trim() 27 | 28 | if (searchTerm === lastSearchInput) return 29 | 30 | lastSearchInput = searchTerm 31 | 32 | if (!searchTerm) return clearSearch() 33 | 34 | await startSearch() 35 | } 36 | 37 | function clearSearch () { 38 | lastFetchAbortController?.abort() 39 | hideSpinner() 40 | $searchResultsMessage.innerHTML = '' 41 | $searchResults.classList.add('hidden') 42 | } 43 | 44 | async function startSearch () { 45 | $searchResults.classList.remove('hidden') 46 | $searchResultsList.innerHTML = '' 47 | 48 | $searchResultsMessage.innerHTML = 'Searching...' 49 | 50 | showSpinner() 51 | await fetchPackagesAndDisplayResults({ page: 1 }) 52 | 53 | $searchResults.classList.remove('hidden') 54 | } 55 | 56 | function finishSearch () { 57 | hideSpinner() 58 | } 59 | 60 | async function fetchPackagesAndDisplayResults ({ page = 1 }) { 61 | lastFetchAbortController?.abort() 62 | lastFetchAbortController = new window.AbortController() 63 | 64 | const [error, fetchResult] = await fetchPackages({ 65 | page, 66 | abortController: lastFetchAbortController, 67 | packageName: lastSearchInput 68 | }) 69 | 70 | // the last aborted fetch enters here 71 | if (error) return 72 | 73 | const { 74 | page: resultPage, 75 | hits: results, 76 | nbPages, 77 | nbHits: totalCount 78 | } = fetchResult 79 | currentPage = resultPage + 1 80 | totalPages = nbPages 81 | 82 | // microbenchmark: only displays total count at first time 83 | currentPage === 1 && displayTotalCount(totalCount) 84 | 85 | if (results.length === 0) return finishSearch() 86 | 87 | displayResults({ results, lastSearchInput }) 88 | 89 | const $loadMoreResultsSentinel = 90 | $searchResultsList.querySelector('li:last-child') 91 | createLoadMoreResultsSentinelObserver($loadMoreResultsSentinel) 92 | } 93 | 94 | function displayTotalCount (totalCount) { 95 | const moreResultsSymbol = totalCount === 10_000 ? '+' : '' 96 | const formattedTotalCount = 97 | Intl.NumberFormat('es').format(totalCount) + moreResultsSymbol 98 | $searchResultsMessage.innerHTML = `${formattedTotalCount} results for "${escapeHTML( 99 | lastSearchInput 100 | )}"` 101 | } 102 | 103 | async function fetchNextPagePackagesAndDisplayResults () { 104 | const nextPage = currentPage + 1 105 | if (nextPage > totalPages) return finishSearch() 106 | 107 | await fetchPackagesAndDisplayResults({ page: nextPage }) 108 | } 109 | 110 | async function fetchPackages ({ abortController, packageName, page = 1 }) { 111 | try { 112 | // Restamos 1 porque la API de Algolia usa índice 0 para la primera página 113 | const pageIndex = page - 1 114 | const resultFetch = await window.fetch( 115 | 'https://ofcncog2cu-dsn.algolia.net/1/indexes/npm-search/query?x-algolia-agent=Algolia%20for%20JavaScript%20(3.35.1)%3B%20Browser%20(lite)&x-algolia-application-id=OFCNCOG2CU&x-algolia-api-key=f54e21fa3a2a0160595bb058179bfb1e', 116 | { 117 | headers: { 118 | accept: 'application/json', 119 | 'accept-language': 'es-ES,es;q=0.6', 120 | 'content-type': 'application/x-www-form-urlencoded', 121 | 'sec-fetch-dest': 'empty', 122 | 'sec-fetch-mode': 'cors', 123 | 'sec-fetch-site': 'cross-site', 124 | 'sec-gpc': '1' 125 | }, 126 | referrer: 'https://www.jsdelivr.com/', 127 | referrerPolicy: 'strict-origin-when-cross-origin', 128 | body: `{"params": "query=${packageName}&page=${pageIndex}&hitsPerPage=10&attributesToRetrieve=%5B%22deprecated%22%2C%22description%22%2C%22githubRepo%22%2C%22homepage%22%2C%22keywords%22%2C%22license%22%2C%22name%22%2C%22owner%22%2C%22version%22%2C%22popular%22%2C%22moduleTypes%22%2C%22styleTypes%22%2C%22jsDelivrHits%22%5D&analyticsTags=%5B%22jsdelivr%22%5D&facetFilters=moduleTypes%3Aesm"}`, 129 | method: 'POST', 130 | mode: 'cors', 131 | credentials: 'omit', 132 | signal: abortController.signal 133 | } 134 | ) 135 | 136 | const resultFetchJson = await resultFetch.json() 137 | return [null, resultFetchJson] 138 | } catch (error) { 139 | return [error, {}] 140 | } 141 | } 142 | 143 | function displayResults ({ results, searchTerm }) { 144 | results.forEach((result) => { 145 | // console.log(result) 146 | const $li = document.createElement('li') 147 | $li.title = result.description 148 | $li.innerHTML = getResultHTML({ result, searchTerm }) 149 | $li.tabIndex = 0 150 | 151 | $li.addEventListener('click', async (e) => { 152 | const url = `https://cdn.jsdelivr.net/npm/${result.name}@${result.version}/+esm` 153 | 154 | if (e.target.className === 'skypack-open') { 155 | if (e.target.hasAttribute('data-copy')) { 156 | e.preventDefault() 157 | navigator.clipboard.writeText(url) 158 | } 159 | return 160 | } 161 | 162 | handlePackageSelected(result.name, url) 163 | }) 164 | 165 | $li.addEventListener('keydown', (e) => { 166 | if (e.keyCode === 13) handlePackageSelected(result.name) 167 | }) 168 | 169 | $searchResultsList.appendChild($li) 170 | }) 171 | } 172 | 173 | function getResultHTML ({ result, searchTerm }) { 174 | const resultBadgesHTML = getResultBadgesHTML({ result, searchTerm }) 175 | 176 | return ` 177 |
178 | ${escapeHTML(result.name)} 179 | ${resultBadgesHTML} 180 |
181 |
${escapeHTML( 182 | result.description 183 | )}
184 |
185 |
version: ${result.version}
186 |
187 | copy 190 | details 193 |
194 |
` 195 | } 196 | 197 | function getResultBadgesHTML ({ result, searchTerm }) { 198 | const isPopular = result.popularityScore >= 0.8 199 | const popularHtml = isPopular 200 | ? '' 201 | : '' 202 | const typescriptHtml = result.hasTypes 203 | ? '
' 204 | : '' 205 | const deprecatedHtml = result.isDeprecated 206 | ? '
deprecated
' 207 | : '' 208 | const exactMatchHtml = 209 | result.name === searchTerm 210 | ? '
exact match
' 211 | : '' 212 | return ` 213 |
214 | ${typescriptHtml} 215 | ${popularHtml} 216 | ${deprecatedHtml} 217 | ${exactMatchHtml} 218 |
` 219 | } 220 | 221 | function handlePackageSelected (packageName, packageUrl) { 222 | let parsedName = packageName.split('/').join('-') 223 | if (parsedName.startsWith('@')) parsedName = parsedName.substr(1) 224 | eventBus.emit(EVENTS.ADD_SKYPACK_PACKAGE, { 225 | skypackPackage: parsedName, 226 | url: packageUrl 227 | }) 228 | } 229 | 230 | function createLoadMoreResultsSentinelObserver ($sentinelEl) { 231 | const handleIntersect = async (entries, observer) => { 232 | const [entry] = entries 233 | if (!entry.isIntersecting) return 234 | 235 | observer.disconnect() 236 | await fetchNextPagePackagesAndDisplayResults() 237 | } 238 | 239 | const observerOptions = { 240 | root: $asideBar, 241 | rootMargin: '100%' 242 | } 243 | 244 | const observer = new window.IntersectionObserver( 245 | handleIntersect, 246 | observerOptions 247 | ) 248 | observer.observe($sentinelEl) 249 | } 250 | 251 | function showSpinner () { 252 | $spinner.removeAttribute('hidden') 253 | } 254 | 255 | function hideSpinner () { 256 | $spinner.setAttribute('hidden', '') 257 | } 258 | -------------------------------------------------------------------------------- /src/history.js: -------------------------------------------------------------------------------- 1 | import { persist } from 'zustand/middleware' 2 | import { createStore } from 'zustand/vanilla' 3 | import { $ } from './utils/dom.js' 4 | import { EVENTS, eventBus } from './events-controller.js' 5 | 6 | const genId = () => { 7 | return Date.now() 8 | } 9 | 10 | const useHistoryStore = createStore( 11 | persist( 12 | (set, get) => ({ 13 | history: { 14 | current: null, 15 | items: [] 16 | }, 17 | updateHistory: ({ key, value }) => { 18 | set({ history: { ...get().history, [key]: value } }) 19 | }, 20 | updateHistoryItem: ({ value }) => { 21 | const id = get().history.current || genId() 22 | const currentHistory = get().history.items 23 | const item = currentHistory.find(item => item.id === id) 24 | const alreadyExists = !!item 25 | const timestamp = new Date().getTime() / 1000 26 | 27 | if (alreadyExists) { 28 | if (value === item.value) return 29 | 30 | set({ 31 | history: { 32 | current: id, 33 | items: currentHistory.map(item => 34 | item.id === id 35 | ? { ...item, value, timestamp } 36 | : item 37 | ) 38 | } 39 | }) 40 | } else { 41 | const instanceName = 'Untitled' 42 | const regex = new RegExp(`^${instanceName}(\\(\\d+\\))?$`) 43 | const newNameItems = currentHistory.filter(item => regex.test(item.name)) 44 | const itemName = newNameItems.length > 0 ? `${instanceName}(${newNameItems.length})` : instanceName 45 | set({ history: { current: id, items: [...currentHistory, { id, name: itemName, value, timestamp }] } }) 46 | } 47 | }, 48 | removeHistoryItem: ({ id }) => { 49 | const { current, items } = get().history 50 | set({ 51 | history: 52 | { 53 | current: current === id 54 | ? null 55 | : current, 56 | items: items.filter(item => item.id !== id) 57 | } 58 | }) 59 | }, 60 | updateHistoryItemName: ({ id, prevName, newName }) => { 61 | const prevNameLower = prevName.toLocaleLowerCase() 62 | const newNameLower = newName.toLocaleLowerCase() 63 | 64 | if (!newName || prevNameLower === newNameLower) return 65 | 66 | const { items, ...history } = get().history 67 | const regex = new RegExp(`^${newNameLower}(\\(\\d+\\))?$`) 68 | const newNameItems = items.filter(item => item.id !== id && regex.test(item.name.toLocaleLowerCase())) 69 | const alreadyExists = newNameItems.length > 0 70 | 71 | let name 72 | if (alreadyExists) { 73 | const existingNumbers = newNameItems 74 | .map(item => { 75 | const match = item.name.toLocaleLowerCase().match(/\((\d+)\)$/) 76 | return match ? parseInt(match[1], 10) : 0 77 | }) 78 | .sort((a, b) => a - b) 79 | const highestNumber = existingNumbers.length > 0 ? existingNumbers[existingNumbers.length - 1] : 0 80 | name = `${newName}(${highestNumber + 1})` 81 | } else { 82 | name = newName 83 | } 84 | 85 | set({ 86 | history: { 87 | ...history, 88 | items: items.map(item => 89 | item.id === id 90 | ? { ...item, name } 91 | : item) 92 | } 93 | }) 94 | }, 95 | clearHistory: () => set({ history: { current: null, items: [] } }) 96 | }), 97 | { name: 'history', getHistory: () => window.localStorage.getItem('history') } 98 | ) 99 | ) 100 | 101 | export const { 102 | getState: getHistoryState, 103 | setState: setHistoryState, 104 | subscribe: subscribeHistory 105 | } = useHistoryStore 106 | 107 | const $historyList = $('#history .history-list') 108 | 109 | const HISTORY_ICONS = { 110 | remove: ` 111 | 112 | `, 113 | edit: ` 114 | 115 | ` 116 | } 117 | 118 | const { updateHistoryItemName, removeHistoryItem } = getHistoryState() 119 | 120 | const removeButton = ({ id, name, isActive }) => { 121 | const $removeButton = document.createElement('button') 122 | $removeButton.innerHTML = HISTORY_ICONS.remove 123 | $removeButton.ariaLabel = `Remove ${name}` 124 | 125 | $removeButton.addEventListener('click', (e) => { 126 | e.preventDefault() 127 | 128 | removeHistoryItem({ id }) 129 | if (isActive) { 130 | eventBus.emit(EVENTS.OPEN_NEW_INSTANCE) 131 | } 132 | }) 133 | 134 | return $removeButton 135 | } 136 | 137 | const editButton = ({ id, name }) => { 138 | const $editButton = document.createElement('button') 139 | $editButton.innerHTML = HISTORY_ICONS.edit 140 | $editButton.ariaLabel = `Edit ${name}` 141 | 142 | $editButton.addEventListener('click', (e) => { 143 | e.preventDefault() 144 | 145 | const $button = $historyList.querySelector(`#history-item-${id} button`) 146 | const $input = document.createElement('input') 147 | $input.value = $button.textContent 148 | $button.replaceWith($input) 149 | $input.focus() 150 | $input.select() 151 | 152 | const updateName = () => { 153 | const value = $input.value 154 | $button.textContent = value 155 | updateHistoryItemName({ id, prevName: name, newName: value }) 156 | $input.replaceWith($button) 157 | } 158 | 159 | $input.addEventListener('keypress', (e) => { 160 | if (e.key === 'Enter') { 161 | $input.blur() 162 | } 163 | }) 164 | $input.addEventListener('blur', () => updateName()) 165 | }) 166 | 167 | return $editButton 168 | } 169 | 170 | const openItemButton = ({ id, name, value }) => { 171 | const $button = document.createElement('button') 172 | $button.textContent = name 173 | $button.ariaLabel = `Open ${name}` 174 | 175 | $button.addEventListener('click', (e) => { 176 | e.preventDefault() 177 | eventBus.emit(EVENTS.OPEN_EXISTING_INSTANCE, { value, id }) 178 | }) 179 | 180 | return $button 181 | } 182 | 183 | const createListItem = ({ id, name, value, isActive }) => { 184 | const $li = document.createElement('li') 185 | $li.id = `history-item-${id}` 186 | 187 | if (isActive) { 188 | $li.classList.add('is-active') 189 | } 190 | 191 | const $openButton = openItemButton({ id, name, value }) 192 | const $removeButton = removeButton({ id, name, isActive }) 193 | const $editButton = editButton({ id, name }) 194 | const $actions = document.createElement('div') 195 | 196 | $actions.classList.add('actions') 197 | 198 | $li.appendChild($openButton) 199 | $li.appendChild($actions) 200 | 201 | $actions.appendChild($editButton) 202 | $actions.appendChild($removeButton) 203 | 204 | $li.appendChild($actions) 205 | 206 | return $li 207 | } 208 | 209 | const compareTimestamps = (timestamp) => { 210 | const currentDate = new Date(new Date().getTime()) 211 | const givenDate = new Date(timestamp * 1000) 212 | 213 | const differenceInTime = currentDate - givenDate 214 | const differenceInDays = Math.floor(differenceInTime / (1000 * 60 * 60 * 24)) 215 | 216 | return differenceInDays 217 | } 218 | 219 | export const setHistory = (history) => { 220 | $historyList.innerHTML = '' 221 | const sortedItems = history.items.sort((a, b) => b.timestamp - a.timestamp) 222 | const groupedItems = {} 223 | 224 | for (let i = 0; i < sortedItems.length; i++) { 225 | const itemTimestamp = sortedItems[i].timestamp 226 | const diff = compareTimestamps(itemTimestamp) 227 | let key = '' 228 | 229 | key = `${diff} days ago` 230 | 231 | if (diff === 0) { 232 | key = 'Today' 233 | } 234 | 235 | if (diff === 1) { 236 | key = 'Yesterday' 237 | } 238 | 239 | if (diff > 30) { 240 | const months = Math.floor(diff / 30) 241 | key = `${months} ${months > 1 ? 'months' : 'month'} ago` 242 | } 243 | 244 | if (diff > 365) { 245 | const years = Math.floor(diff / 365) 246 | key = `${years} ${years > 1 ? 'years' : 'year'} ago` 247 | } 248 | 249 | groupedItems[key] = groupedItems[key] || [] 250 | groupedItems[key].push(sortedItems[i]) 251 | } 252 | 253 | for (const [key, value] of Object.entries(groupedItems)) { 254 | const $group = document.createElement('div') 255 | $group.classList.add('group') 256 | const $title = document.createElement('h4') 257 | $title.textContent = key 258 | 259 | $group.appendChild($title) 260 | value.forEach(({ id, name, value }) => { 261 | const $li = createListItem({ id, name, value, isActive: history.current === id }) 262 | $group.appendChild($li) 263 | }) 264 | 265 | $historyList.appendChild($group) 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/console.js: -------------------------------------------------------------------------------- 1 | import { createConsoleBadge } from './constants/console-icons' 2 | import { $ } from './utils/dom' 3 | 4 | const $iframe = $('iframe') 5 | const $consoleList = $('#console .console-list') 6 | const $consoleBadge = $('.console-badge-count') 7 | 8 | let consoleLogCount = 0 9 | 10 | const updateConsoleBadge = () => { 11 | if (consoleLogCount === 0) { 12 | $consoleBadge.setAttribute('hidden', '') 13 | } else { 14 | $consoleBadge.removeAttribute('hidden') 15 | $consoleBadge.textContent = consoleLogCount > 99 ? '+99' : consoleLogCount 16 | } 17 | } 18 | 19 | const clearConsole = () => { 20 | $consoleList.innerHTML = '' 21 | consoleLogCount = 0 22 | updateConsoleBadge() 23 | } 24 | 25 | export const resetConsoleBadge = () => { 26 | consoleLogCount = 0 27 | updateConsoleBadge() 28 | } 29 | 30 | const isValidIdentifier = (key) => /^[a-zA-Z_$][0-9a-zA-Z_$]*$/.test(key) 31 | 32 | const escapeHtml = (unsafe) => { 33 | return unsafe 34 | .replace(/&/g, '&') 35 | .replace(//g, '>') 37 | .replace(/"/g, '"') 38 | .replace(/'/g, ''') 39 | } 40 | 41 | const formatTable = (data) => { 42 | if (!data || typeof data !== 'object') { 43 | return formatValue(data) 44 | } 45 | 46 | if (Array.isArray(data)) { 47 | if (data.length === 0) return '[]' 48 | 49 | // Check if array contains objects with similar keys 50 | const firstItem = data[0] 51 | if (typeof firstItem === 'object' && firstItem !== null && !Array.isArray(firstItem)) { 52 | const keys = Object.keys(firstItem) 53 | let table = '
' 54 | table += '' 55 | keys.forEach(key => { 56 | table += `` 57 | }) 58 | table += '' 59 | 60 | data.forEach((item, index) => { 61 | table += `` 62 | keys.forEach(key => { 63 | const value = item[key] 64 | table += `` 65 | }) 66 | table += '' 67 | }) 68 | 69 | table += '
(index)${escapeHtml(key)}
${index}${formatValue(value)}
' 70 | return table 71 | } 72 | 73 | // Simple array 74 | let table = '
' 75 | data.forEach((item, index) => { 76 | table += `` 77 | }) 78 | table += '
(index)Value
${index}${formatValue(item)}
' 79 | return table 80 | } 81 | 82 | // Object 83 | const keys = Object.keys(data) 84 | if (keys.length === 0) return '{}' 85 | 86 | let table = '
' 87 | keys.forEach(key => { 88 | table += `` 89 | }) 90 | table += '
(index)Value
${escapeHtml(key)}${formatValue(data[key])}
' 91 | return table 92 | } 93 | 94 | const formatValue = (value, indentLevel = 0) => { 95 | const indent = ' '.repeat(indentLevel) 96 | 97 | if (value === null) { 98 | return 'null' 99 | } 100 | 101 | if (value === undefined) { 102 | return 'undefined' 103 | } 104 | 105 | if (typeof value === 'string') { 106 | return `"${escapeHtml(value)}"` 107 | } 108 | 109 | if (typeof value === 'number') { 110 | return `${value}` 111 | } 112 | 113 | if (typeof value === 'bigint') { 114 | return `${value}` 115 | } 116 | 117 | if (typeof value === 'boolean') { 118 | return `${value}` 119 | } 120 | 121 | if (Array.isArray(value)) { 122 | if (value.length === 0) return '[]' 123 | 124 | let result = '[\n' 125 | 126 | value.forEach((item, index) => { 127 | result += `${indent} ${formatValue(item, indentLevel + 1)}` 128 | if (index < value.length - 1) result += ',' 129 | result += '\n' 130 | }) 131 | 132 | result += `${indent}]` 133 | return result 134 | } 135 | 136 | if (typeof value === 'object') { 137 | if (value && value.type === 'function') { 138 | const fnContent = escapeHtml(value.content) 139 | const startOfBody = fnContent.indexOf('{') 140 | 141 | const isAsync = value.async 142 | const isGenerator = value.generator 143 | 144 | let className = 'console-fn' 145 | if (isAsync) { 146 | className += ' console-async-fn' 147 | } 148 | if (isGenerator) { 149 | className += ' console-generator-fn' 150 | } 151 | 152 | // Function signature logic 153 | let signature 154 | if (startOfBody === -1) { 155 | signature = fnContent.trim() 156 | } else { 157 | signature = fnContent.substring(0, startOfBody).trim() 158 | } 159 | 160 | signature = signature 161 | .replace(/^async\s+/, '') 162 | .replace(/^function\s*\*\s*/, '') 163 | .replace(/^function\s*/, '') 164 | .trim() 165 | 166 | // Function body logic 167 | let functionBody 168 | if (startOfBody === -1) { 169 | functionBody = '' 170 | } else { 171 | const bodyContent = fnContent.substring(startOfBody + 1, fnContent.lastIndexOf('}')) 172 | const compressedBody = bodyContent.trim().length > 0 ? '...' : '' 173 | functionBody = ` {${compressedBody}}` 174 | } 175 | 176 | return `${signature}${functionBody}` 177 | } 178 | 179 | if (value && value.type === 'circular') { 180 | return '[Circular]' 181 | } 182 | 183 | if (value && value.type === 'regexp') { 184 | return `${escapeHtml(value.value)}` 185 | } 186 | 187 | if (value && value.type === 'unknown') { 188 | return `${escapeHtml(String(value.value))}` 189 | } 190 | 191 | if (value && value.type === 'date') { 192 | const isoString = value.value 193 | const cleanedString = isoString 194 | .replace('T', ' ') 195 | .replace(/\.\d{3}Z$/, '') 196 | 197 | return `${escapeHtml(cleanedString)}` 198 | } 199 | 200 | if (value && value.type === 'set') { 201 | const short = `Set(${value.size})` 202 | 203 | if (value.size === 0) return `${short} {}` 204 | 205 | let result = `${short} {\n` 206 | 207 | ;(value.values || []).forEach((v, index) => { 208 | result += `${indent} ${formatValue(v, indentLevel + 1)}` 209 | if (index < value.values.length - 1) result += ',' 210 | result += '\n' 211 | }) 212 | 213 | result += `${indent}}` 214 | return result 215 | } 216 | 217 | const isSymbolKey = (k) => typeof k === 'string' && k.startsWith('Symbol(') && k.endsWith(')') 218 | 219 | const formatKey = (key) => { 220 | return (isValidIdentifier(key) || isSymbolKey(key)) 221 | ? `${escapeHtml(key)}` 222 | : `"${escapeHtml(key)}"` 223 | } 224 | 225 | if (value && value.type === 'map') { 226 | const short = `Map(${value.size})` 227 | if (value.size === 0) return `${short} {}` 228 | 229 | let result = `${short} {\n` 230 | 231 | ;(value.entries || []).forEach(([k, v], index) => { 232 | const keyFormatted = formatKey(k) 233 | const valueFormatted = formatValue(v, indentLevel + 1) 234 | 235 | result += `${indent} ${keyFormatted} => ${valueFormatted}` 236 | 237 | if (index < value.entries.length - 1) result += ',' 238 | result += '\n' 239 | }) 240 | 241 | result += `${indent}}` 242 | return result 243 | } 244 | 245 | const keys = Object.keys(value) 246 | if (keys.length === 0) return '{}' 247 | 248 | let result = '{\n' 249 | 250 | keys.forEach((key, index) => { 251 | const renderedKey = formatKey(key) 252 | 253 | result += `${indent} ${renderedKey}: ${formatValue(value[key], indentLevel + 1)}` 254 | 255 | if (index < keys.length - 1) result += ',' 256 | result += '\n' 257 | }) 258 | 259 | result += `${indent}}` 260 | return result 261 | } 262 | 263 | return String(value) 264 | } 265 | 266 | const createListItem = (content, type) => { 267 | const $li = document.createElement('li') 268 | $li.classList.add(`log-${type.split(':')[1]}`) 269 | 270 | const badge = createConsoleBadge(type) 271 | if (badge) { 272 | $li.innerHTML = badge 273 | } 274 | 275 | const $pre = document.createElement('pre') 276 | $pre.style.whiteSpace = 'pre-wrap' 277 | $pre.style.margin = '0' 278 | 279 | $pre.innerHTML = content 280 | 281 | $li.appendChild($pre) 282 | 283 | return $li 284 | } 285 | 286 | const handlers = { 287 | system: (payload) => { 288 | if (payload === 'clear') { 289 | clearConsole() 290 | } 291 | }, 292 | error: (payload) => { 293 | const { line, column, message } = payload 294 | const errorItem = createListItem(`${line}:${column} ${message}`, 'error') 295 | errorItem.classList.add('error') 296 | $consoleList.appendChild(errorItem) 297 | consoleLogCount++ 298 | updateConsoleBadge() 299 | }, 300 | default: (payload, type) => { 301 | let content 302 | if (type === 'log:table') { 303 | content = payload.map(item => formatTable(item)).join(' ') 304 | } else { 305 | content = payload.map(item => formatValue(item)).join(' ') 306 | } 307 | const listItem = createListItem(content, type) 308 | $consoleList.appendChild(listItem) 309 | consoleLogCount++ 310 | updateConsoleBadge() 311 | }, 312 | loop: (payload) => { 313 | clearConsole() 314 | const { message } = payload 315 | const errorItem = createListItem(`${message}`, 'error') 316 | errorItem.classList.add('error') 317 | $consoleList.appendChild(errorItem) 318 | consoleLogCount++ 319 | updateConsoleBadge() 320 | } 321 | } 322 | 323 | window.addEventListener('message', (ev) => { 324 | const { console: consoleData = {} } = ev.data 325 | const { payload, type } = consoleData 326 | 327 | if (ev.source === $iframe.contentWindow) { 328 | const handler = handlers[type] || handlers.default 329 | handler(payload, type) 330 | } else if (type === 'loop') { 331 | handlers.loop(payload) 332 | } 333 | }) 334 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Attribution-NonCommercial-NoDerivatives 4.0 International 2 | 3 | ======================================================================= 4 | 5 | Creative Commons Corporation ("Creative Commons") is not a law firm and 6 | does not provide legal services or legal advice. Distribution of 7 | Creative Commons public licenses does not create a lawyer-client or 8 | other relationship. Creative Commons makes its licenses and related 9 | information available on an "as-is" basis. Creative Commons gives no 10 | warranties regarding its licenses, any material licensed under their 11 | terms and conditions, or any related information. Creative Commons 12 | disclaims all liability for damages resulting from their use to the 13 | fullest extent possible. 14 | 15 | Using Creative Commons Public Licenses 16 | 17 | Creative Commons public licenses provide a standard set of terms and 18 | conditions that creators and other rights holders may use to share 19 | original works of authorship and other material subject to copyright 20 | and certain other rights specified in the public license below. The 21 | following considerations are for informational purposes only, are not 22 | exhaustive, and do not form part of our licenses. 23 | 24 | Considerations for licensors: Our public licenses are 25 | intended for use by those authorized to give the public 26 | permission to use material in ways otherwise restricted by 27 | copyright and certain other rights. Our licenses are 28 | irrevocable. Licensors should read and understand the terms 29 | and conditions of the license they choose before applying it. 30 | Licensors should also secure all rights necessary before 31 | applying our licenses so that the public can reuse the 32 | material as expected. Licensors should clearly mark any 33 | material not subject to the license. This includes other CC- 34 | licensed material, or material used under an exception or 35 | limitation to copyright. More considerations for licensors: 36 | wiki.creativecommons.org/Considerations_for_licensors 37 | 38 | Considerations for the public: By using one of our public 39 | licenses, a licensor grants the public permission to use the 40 | licensed material under specified terms and conditions. If 41 | the licensor's permission is not necessary for any reason--for 42 | example, because of any applicable exception or limitation to 43 | copyright--then that use is not regulated by the license. Our 44 | licenses grant only permissions under copyright and certain 45 | other rights that a licensor has authority to grant. Use of 46 | the licensed material may still be restricted for other 47 | reasons, including because others have copyright or other 48 | rights in the material. A licensor may make special requests, 49 | such as asking that all changes be marked or described. 50 | Although not required by our licenses, you are encouraged to 51 | respect those requests where reasonable. More considerations 52 | for the public: 53 | wiki.creativecommons.org/Considerations_for_licensees 54 | 55 | ======================================================================= 56 | 57 | Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 58 | International Public License 59 | 60 | By exercising the Licensed Rights (defined below), You accept and agree 61 | to be bound by the terms and conditions of this Creative Commons 62 | Attribution-NonCommercial-NoDerivatives 4.0 International Public 63 | License ("Public License"). To the extent this Public License may be 64 | interpreted as a contract, You are granted the Licensed Rights in 65 | consideration of Your acceptance of these terms and conditions, and the 66 | Licensor grants You such rights in consideration of benefits the 67 | Licensor receives from making the Licensed Material available under 68 | these terms and conditions. 69 | 70 | 71 | Section 1 -- Definitions. 72 | 73 | a. Adapted Material means material subject to Copyright and Similar 74 | Rights that is derived from or based upon the Licensed Material 75 | and in which the Licensed Material is translated, altered, 76 | arranged, transformed, or otherwise modified in a manner requiring 77 | permission under the Copyright and Similar Rights held by the 78 | Licensor. For purposes of this Public License, where the Licensed 79 | Material is a musical work, performance, or sound recording, 80 | Adapted Material is always produced where the Licensed Material is 81 | synched in timed relation with a moving image. 82 | 83 | b. Copyright and Similar Rights means copyright and/or similar rights 84 | closely related to copyright including, without limitation, 85 | performance, broadcast, sound recording, and Sui Generis Database 86 | Rights, without regard to how the rights are labeled or 87 | categorized. For purposes of this Public License, the rights 88 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 89 | Rights. 90 | 91 | c. Effective Technological Measures means those measures that, in the 92 | absence of proper authority, may not be circumvented under laws 93 | fulfilling obligations under Article 11 of the WIPO Copyright 94 | Treaty adopted on December 20, 1996, and/or similar international 95 | agreements. 96 | 97 | d. Exceptions and Limitations means fair use, fair dealing, and/or 98 | any other exception or limitation to Copyright and Similar Rights 99 | that applies to Your use of the Licensed Material. 100 | 101 | e. Licensed Material means the artistic or literary work, database, 102 | or other material to which the Licensor applied this Public 103 | License. 104 | 105 | f. Licensed Rights means the rights granted to You subject to the 106 | terms and conditions of this Public License, which are limited to 107 | all Copyright and Similar Rights that apply to Your use of the 108 | Licensed Material and that the Licensor has authority to license. 109 | 110 | g. Licensor means the individual(s) or entity(ies) granting rights 111 | under this Public License. 112 | 113 | h. NonCommercial means not primarily intended for or directed towards 114 | commercial advantage or monetary compensation. For purposes of 115 | this Public License, the exchange of the Licensed Material for 116 | other material subject to Copyright and Similar Rights by digital 117 | file-sharing or similar means is NonCommercial provided there is 118 | no payment of monetary compensation in connection with the 119 | exchange. 120 | 121 | i. Share means to provide material to the public by any means or 122 | process that requires permission under the Licensed Rights, such 123 | as reproduction, public display, public performance, distribution, 124 | dissemination, communication, or importation, and to make material 125 | available to the public including in ways that members of the 126 | public may access the material from a place and at a time 127 | individually chosen by them. 128 | 129 | j. Sui Generis Database Rights means rights other than copyright 130 | resulting from Directive 96/9/EC of the European Parliament and of 131 | the Council of 11 March 1996 on the legal protection of databases, 132 | as amended and/or succeeded, as well as other essentially 133 | equivalent rights anywhere in the world. 134 | 135 | k. You means the individual or entity exercising the Licensed Rights 136 | under this Public License. Your has a corresponding meaning. 137 | 138 | 139 | Section 2 -- Scope. 140 | 141 | a. License grant. 142 | 143 | 1. Subject to the terms and conditions of this Public License, 144 | the Licensor hereby grants You a worldwide, royalty-free, 145 | non-sublicensable, non-exclusive, irrevocable license to 146 | exercise the Licensed Rights in the Licensed Material to: 147 | 148 | a. reproduce and Share the Licensed Material, in whole or 149 | in part, for NonCommercial purposes only; and 150 | 151 | b. produce and reproduce, but not Share, Adapted Material 152 | for NonCommercial purposes only. 153 | 154 | 2. Exceptions and Limitations. For the avoidance of doubt, where 155 | Exceptions and Limitations apply to Your use, this Public 156 | License does not apply, and You do not need to comply with 157 | its terms and conditions. 158 | 159 | 3. Term. The term of this Public License is specified in Section 160 | 6(a). 161 | 162 | 4. Media and formats; technical modifications allowed. The 163 | Licensor authorizes You to exercise the Licensed Rights in 164 | all media and formats whether now known or hereafter created, 165 | and to make technical modifications necessary to do so. The 166 | Licensor waives and/or agrees not to assert any right or 167 | authority to forbid You from making technical modifications 168 | necessary to exercise the Licensed Rights, including 169 | technical modifications necessary to circumvent Effective 170 | Technological Measures. For purposes of this Public License, 171 | simply making modifications authorized by this Section 2(a) 172 | (4) never produces Adapted Material. 173 | 174 | 5. Downstream recipients. 175 | 176 | a. Offer from the Licensor -- Licensed Material. Every 177 | recipient of the Licensed Material automatically 178 | receives an offer from the Licensor to exercise the 179 | Licensed Rights under the terms and conditions of this 180 | Public License. 181 | 182 | b. No downstream restrictions. You may not offer or impose 183 | any additional or different terms or conditions on, or 184 | apply any Effective Technological Measures to, the 185 | Licensed Material if doing so restricts exercise of the 186 | Licensed Rights by any recipient of the Licensed 187 | Material. 188 | 189 | 6. No endorsement. Nothing in this Public License constitutes or 190 | may be construed as permission to assert or imply that You 191 | are, or that Your use of the Licensed Material is, connected 192 | with, or sponsored, endorsed, or granted official status by, 193 | the Licensor or others designated to receive attribution as 194 | provided in Section 3(a)(1)(A)(i). 195 | 196 | b. Other rights. 197 | 198 | 1. Moral rights, such as the right of integrity, are not 199 | licensed under this Public License, nor are publicity, 200 | privacy, and/or other similar personality rights; however, to 201 | the extent possible, the Licensor waives and/or agrees not to 202 | assert any such rights held by the Licensor to the limited 203 | extent necessary to allow You to exercise the Licensed 204 | Rights, but not otherwise. 205 | 206 | 2. Patent and trademark rights are not licensed under this 207 | Public License. 208 | 209 | 3. To the extent possible, the Licensor waives any right to 210 | collect royalties from You for the exercise of the Licensed 211 | Rights, whether directly or through a collecting society 212 | under any voluntary or waivable statutory or compulsory 213 | licensing scheme. In all other cases the Licensor expressly 214 | reserves any right to collect such royalties, including when 215 | the Licensed Material is used other than for NonCommercial 216 | purposes. 217 | 218 | 219 | Section 3 -- License Conditions. 220 | 221 | Your exercise of the Licensed Rights is expressly made subject to the 222 | following conditions. 223 | 224 | a. Attribution. 225 | 226 | 1. If You Share the Licensed Material, You must: 227 | 228 | a. retain the following if it is supplied by the Licensor 229 | with the Licensed Material: 230 | 231 | i. identification of the creator(s) of the Licensed 232 | Material and any others designated to receive 233 | attribution, in any reasonable manner requested by 234 | the Licensor (including by pseudonym if 235 | designated); 236 | 237 | ii. a copyright notice; 238 | 239 | iii. a notice that refers to this Public License; 240 | 241 | iv. a notice that refers to the disclaimer of 242 | warranties; 243 | 244 | v. a URI or hyperlink to the Licensed Material to the 245 | extent reasonably practicable; 246 | 247 | b. indicate if You modified the Licensed Material and 248 | retain an indication of any previous modifications; and 249 | 250 | c. indicate the Licensed Material is licensed under this 251 | Public License, and include the text of, or the URI or 252 | hyperlink to, this Public License. 253 | 254 | For the avoidance of doubt, You do not have permission under 255 | this Public License to Share Adapted Material. 256 | 257 | 2. You may satisfy the conditions in Section 3(a)(1) in any 258 | reasonable manner based on the medium, means, and context in 259 | which You Share the Licensed Material. For example, it may be 260 | reasonable to satisfy the conditions by providing a URI or 261 | hyperlink to a resource that includes the required 262 | information. 263 | 264 | 3. If requested by the Licensor, You must remove any of the 265 | information required by Section 3(a)(1)(A) to the extent 266 | reasonably practicable. 267 | 268 | 269 | Section 4 -- Sui Generis Database Rights. 270 | 271 | Where the Licensed Rights include Sui Generis Database Rights that 272 | apply to Your use of the Licensed Material: 273 | 274 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 275 | to extract, reuse, reproduce, and Share all or a substantial 276 | portion of the contents of the database for NonCommercial purposes 277 | only and provided You do not Share Adapted Material; 278 | 279 | b. if You include all or a substantial portion of the database 280 | contents in a database in which You have Sui Generis Database 281 | Rights, then the database in which You have Sui Generis Database 282 | Rights (but not its individual contents) is Adapted Material; and 283 | 284 | c. You must comply with the conditions in Section 3(a) if You Share 285 | all or a substantial portion of the contents of the database. 286 | 287 | For the avoidance of doubt, this Section 4 supplements and does not 288 | replace Your obligations under this Public License where the Licensed 289 | Rights include other Copyright and Similar Rights. 290 | 291 | 292 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 293 | 294 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 295 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 296 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 297 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 298 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 299 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 300 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 301 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 302 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 303 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 304 | 305 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 306 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 307 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 308 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 309 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 310 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 311 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 312 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 313 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 314 | 315 | c. The disclaimer of warranties and limitation of liability provided 316 | above shall be interpreted in a manner that, to the extent 317 | possible, most closely approximates an absolute disclaimer and 318 | waiver of all liability. 319 | 320 | 321 | Section 6 -- Term and Termination. 322 | 323 | a. This Public License applies for the term of the Copyright and 324 | Similar Rights licensed here. However, if You fail to comply with 325 | this Public License, then Your rights under this Public License 326 | terminate automatically. 327 | 328 | b. Where Your right to use the Licensed Material has terminated under 329 | Section 6(a), it reinstates: 330 | 331 | 1. automatically as of the date the violation is cured, provided 332 | it is cured within 30 days of Your discovery of the 333 | violation; or 334 | 335 | 2. upon express reinstatement by the Licensor. 336 | 337 | For the avoidance of doubt, this Section 6(b) does not affect any 338 | right the Licensor may have to seek remedies for Your violations 339 | of this Public License. 340 | 341 | c. For the avoidance of doubt, the Licensor may also offer the 342 | Licensed Material under separate terms or conditions or stop 343 | distributing the Licensed Material at any time; however, doing so 344 | will not terminate this Public License. 345 | 346 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 347 | License. 348 | 349 | 350 | Section 7 -- Other Terms and Conditions. 351 | 352 | a. The Licensor shall not be bound by any additional or different 353 | terms or conditions communicated by You unless expressly agreed. 354 | 355 | b. Any arrangements, understandings, or agreements regarding the 356 | Licensed Material not stated herein are separate from and 357 | independent of the terms and conditions of this Public License. 358 | 359 | 360 | Section 8 -- Interpretation. 361 | 362 | a. For the avoidance of doubt, this Public License does not, and 363 | shall not be interpreted to, reduce, limit, restrict, or impose 364 | conditions on any use of the Licensed Material that could lawfully 365 | be made without permission under this Public License. 366 | 367 | b. To the extent possible, if any provision of this Public License is 368 | deemed unenforceable, it shall be automatically reformed to the 369 | minimum extent necessary to make it enforceable. If the provision 370 | cannot be reformed, it shall be severed from this Public License 371 | without affecting the enforceability of the remaining terms and 372 | conditions. 373 | 374 | c. No term or condition of this Public License will be waived and no 375 | failure to comply consented to unless expressly agreed to by the 376 | Licensor. 377 | 378 | d. Nothing in this Public License constitutes or may be interpreted 379 | as a limitation upon, or waiver of, any privileges and immunities 380 | that apply to the Licensor or You, including from the legal 381 | processes of any jurisdiction or authority. 382 | 383 | ======================================================================= 384 | 385 | Creative Commons is not a party to its public 386 | licenses. Notwithstanding, Creative Commons may elect to apply one of 387 | its public licenses to material it publishes and in those instances 388 | will be considered the “Licensor.” The text of the Creative Commons 389 | public licenses is dedicated to the public domain under the CC0 Public 390 | Domain Dedication. Except for the limited purpose of indicating that 391 | material is shared under a Creative Commons public license or as 392 | otherwise permitted by the Creative Commons policies published at 393 | creativecommons.org/policies, Creative Commons does not authorize the 394 | use of the trademark "Creative Commons" or any other trademark or logo 395 | of Creative Commons without its prior written consent including, 396 | without limitation, in connection with any unauthorized modifications 397 | to any of its public licenses or any other arrangements, 398 | understandings, or agreements concerning use of licensed material. For 399 | the avoidance of doubt, this paragraph does not form part of the 400 | public licenses. 401 | 402 | Creative Commons may be contacted at creativecommons.org. 403 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | codi.link | HTML, CSS, JavaScript Live Editor Playground 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 37 | 38 | 39 | 40 | 53 |
54 | 450 | 451 |
452 | 457 | 458 | 459 | 460 | 461 |
462 | 463 |
464 | 465 |
466 |
467 |
468 | 469 |
470 | 477 | 484 |
485 |
486 | 487 |
488 |
489 | 490 | 491 | 492 | 493 | --------------------------------------------------------------------------------