From 762a1761cf1581a4868b5d527e24829878f87bbc Mon Sep 17 00:00:00 2001 From: Prasanjit Singh Date: Sun, 10 Nov 2019 14:16:58 +0530 Subject: [PATCH] Add ability to include libraries (#7) --- index.html | 259 +++++++++++++++++++++++++++++++++++-------- package-lock.json | 2 +- package.json | 6 +- src/editor.ts | 10 +- src/main.ts | 2 +- src/menu.ts | 77 +++++++------ src/output.ts | 20 +--- src/ui.ts | 275 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 551 insertions(+), 100 deletions(-) create mode 100644 src/ui.ts diff --git a/index.html b/index.html index d482efe..cc0ab28 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,10 @@ box-sizing: border-box; } + html { + overflow: hidden; + } + body { width: 100%; height: 100vh; @@ -34,14 +38,13 @@ } header { + display: flex; + justify-content: flex-end; height: 24px; - padding-top: 4px; - padding-right: 10px; - color: #4f4f55; - font-family: 'Cascadia Code', 'Menlo', monospace; - font-size: 13px; - text-align: right; + padding-top: 6px; + padding-right: 6px; user-select: none; + font-family: 'Cascadia Code', 'Menlo', monospace; -webkit-app-region: drag; } @@ -97,16 +100,177 @@ border-radius: 50%; } + .package__add { + position: relative; + background-color: transparent; + padding: 0 4px; + border: 1px solid #646469; + height: 17px; + width: 17px; + display: block; + cursor: pointer; + border-radius: 50%; + cursor: pointer; + } + + .package__add::before { + content: '✕'; + display: block; + transform: rotate(45deg); + color: #646469; + line-height: 13px; + font-size: 13px; + } + + .package__add:hover, + .popup-visible .package__add { + border-color: #F8D163; + background-color: rgba(248, 208, 99, 0.15); + } + + .package__add:hover::before, + .popup-visible .package__add::before { + color: #F8D163; + } + + .package__popup { + position: absolute; + right: 5px; + top: 30px; + z-index: 1; + width: 340px; + padding: 15px 20px; + border-radius: 6px; + background-color: rgba(10, 10, 10, 0.9); + text-align: left; + opacity: 0; + transform: scale(0.96); + transform-origin: top right; + visibility: hidden; + transition: opacity 0.05s, transform 0.05s, visibility 0.05s; + } + + .package__popup__title { + color: #e8e8e8; + margin: 0; + font-weight: 400; + } + + .package__popup__description { + color: #8a8888; + font-size: 12px; + margin: 4px 0 15px; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; + } + + .package__close { + position: absolute; + top: 6px; + right: 10px; + color: #8e8e8e; + font-family: serif; + font-size: 18px; + cursor: pointer; + } + + .package__close:hover { + color: #d4d4d4; + } + + .popup-visible .package__popup { + opacity: 1; + transform: none; + visibility: visible; + } + + .package__form { + position: relative; + margin-top: 10px; + margin-bottom: 10px; + } + + .input-block { + display: flex; + } + + .package__input { + width: 100%; + padding: 10px; + border: 0; + border-radius: 4px; + font-family: inherit; + font-size: 12px; + background-color: #f3f3f3; + outline: none; + } + + .package__list { + position: absolute; + top: 25px; + left: 0; + width: 100%; + max-height: 250px; + padding: 0; + border-radius: 6px; + background-color: #f3f3f3; + text-align: left; + font-size: 14px; + list-style: none; + overflow: auto; + } + + .package__list-item { + color: #444444; + padding: 8px 0; + padding-left: 15px; + list-style-position: inside; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + outline: none; + } + + .package__list-item.is-active { + background-color: rgba(248, 208, 99, 0.2); + } + + .loaded-list { + list-style: none; + color: #d0d0d0; + text-align: left; + padding: 0; + margin: 15px 0 0; + font-size: 12px; + } + + .loaded-list__item { + display: flex; + align-items: center; + margin-bottom: 5px; + } + + .loaded-list__item svg { + height: 12px; + margin-right: 6px; + } + .help { position: absolute; right: 0; bottom: 0; - display: block; - color: #fff; - font-size: 20px; + display: flex; + align-items: center; padding: 7px 4px; } + .opium-text { + color: #4b4b50; + font-family: 'Cascadia Code', 'Menlo', monospace; + font-size: 13px; + margin-top: 3px; + margin-right: 4px; + } + .help__icon { width: 20px; height: 20px; @@ -119,32 +283,32 @@ } .help__icon:hover .bulb-glow, - .help-visible .bulb-glow { + .popup-visible .bulb-glow { opacity: 1; } .help__icon:hover g, - .help-visible .help__icon g { + .popup-visible .help__icon g { fill: #ccc; } .help__popup { position: absolute; - right: 25px; - bottom: 25px; - height: 205px; - width: 330px; - padding: 25px; + right: 5px; + bottom: 32px; + height: 196px; + width: 315px; + padding: 22px; border-radius: 6px; background-color: rgba(10, 10, 10, 0.9); opacity: 0; transform: scale(0.96); transform-origin: bottom right; visibility: hidden; - transition: opacity 0.1s, transform 0.1s, visibility 0.1s; + transition: opacity 0.05s, transform 0.05s, visibility 0.05s; } - .help-visible .help__popup { + .popup-visible .help__popup { opacity: 1; transform: none; visibility: visible; @@ -154,15 +318,20 @@ position: absolute; top: 12px; right: 15px; - color: #d4d4d4; + color: #8e8e8e; font-size: 16px; cursor: pointer; } + .help__close:hover { + color: #d4d4d4; + } + .help__commands { display: grid; - grid-template-columns: 125px auto; + grid-template-columns: 115px auto; row-gap: 10px; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; } .help__commands__key { @@ -171,7 +340,6 @@ align-items: center; width: 28px; height: 28px; - font-family: 'Arial', sans-serif; font-size: 13px; color: #efeff5; border: 1px solid rgba(255, 255, 255, 0.15); @@ -182,23 +350,41 @@ display: flex; align-items: center; margin: 0; - font-family: 'Helvetica Neue', sans-serif; font-weight: 300; font-size: 14px; - letter-spacing: 0.5px; - color: #fff; + color: #eaeaea; } -
opiumJS
+
+
+ +
+

Add libraries

+

+ By default it looks up for JS libraries in cdnjs, however you can enter any URL below + to load a library. +

+ +
+
+ +
+ +
+
    +
    +
    +
    + opiumJS
    @@ -242,9 +428,6 @@
    + diff --git a/package-lock.json b/package-lock.json index d6f0b29..63cde9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "opium-js", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 4ee6661..75fca30 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "opium-js", "productName": "opiumJS", - "version": "1.0.0", - "description": "A desktop utility app for running JS snippets", + "version": "1.2.0", + "description": "MacOS utility for running JavaScript snippets", "main": "main.js", "scripts": { "build": "tsc", @@ -32,7 +32,7 @@ ] }, "directories": { - "buildResources": "assets", + "buildResources": "../assets", "output": "dist" } }, diff --git a/src/editor.ts b/src/editor.ts index 8905362..3813755 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -69,8 +69,14 @@ const initMonacoInput = () => { editor.getAction('editor.action.formatDocument').run() - const { error } = instrument(code) - if (error) { + // const { error } = instrument(code) + // if (error) { + // opmAppendOutput(error, true) + // } + + try { + eval(code) + } catch (error) { opmAppendOutput(error, true) } diff --git a/src/main.ts b/src/main.ts index 9a08471..22c77c7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { app, BrowserWindow, Menu } from 'electron' -const menuTemplate = require('./menu') +import menuTemplate from './menu' let appWindow: Electron.BrowserWindow diff --git a/src/menu.ts b/src/menu.ts index 9bbd967..c06e7be 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,34 +1,49 @@ -const { app, shell } = require('electron') +import { app, shell } from 'electron' -const menuTemplate = [ - { - label: 'View', - submenu: [ - { role: 'resetzoom' }, - { role: 'zoomin' }, - { role: 'zoomout' }, - { type: 'separator' }, - { role: 'togglefullscreen' } - ] - }, - { - role: 'window', - submenu: [ - { role: 'close' }, - { role: 'minimize' }, - { role: 'zoom' } - ] - }, - { - role: 'help', - submenu: [ - { - label: 'Github repo', - click() { shell.openExternal('https://github.com/pb03/opium-js') } - } - ] - } -] +const edit = { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectall' } + ] +} + +const view = { + label: 'View', + submenu: [ + { role: 'resetzoom' }, + { role: 'zoomin' }, + { role: 'zoomout' }, + { type: 'separator' }, + { role: 'togglefullscreen' } + ] +} + +const window = { + role: 'window', + submenu: [ + { role: 'close' }, + { role: 'minimize' }, + { role: 'zoom' } + ] +} + +const help = { + role: 'help', + submenu: [ + { + label: 'Github repo', + click() { shell.openExternal('https://github.com/pb03/opium-js') } + } + ] +} + +const menuTemplate: any[] = [edit, view, window, help] if (process.platform === 'darwin') { menuTemplate.unshift({ @@ -45,4 +60,4 @@ if (process.platform === 'darwin') { }) } -module.exports = menuTemplate +export default menuTemplate diff --git a/src/output.ts b/src/output.ts index b2b0c87..87ce091 100644 --- a/src/output.ts +++ b/src/output.ts @@ -29,22 +29,12 @@ const output = (code: any) => { } const isCyclic = obj => { - let seenObjects = [] - - function detect(obj) { - if (obj && typeof obj === 'object') { - if (seenObjects.indexOf(obj) !== -1) { - return true - } - seenObjects.push(obj) - for (let key in obj) { - if (obj.hasOwnProperty(key) && detect(obj[key])) { - return true - } - } - } + try { + JSON.stringify(obj) + } catch (error) { + return error.message.includes('Converting circular structure to JSON') } - return detect(obj) + return false } module.exports = output diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..466344c --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,275 @@ +const DOWN_ARROW_KEY_CODE = 40 +const UP_ARROW_KEY_CODE = 38 +const ESCAPE_KEY_CODE = 27 + +type ResultItem = { + name: string + latest: string +} + +/** + * Package popup + */ +const $package = document.getElementById('package') as HTMLDivElement +const $packageInput = document.getElementById('package-input') as HTMLInputElement +const $loadedList = document.getElementById('loaded-list') as HTMLUListElement +const $addPackageTrigger = document.getElementById('add-package-trigger') as HTMLSpanElement +const $packageForm = document.getElementById('package-form') as HTMLFormElement +const $packageListContainer = document.getElementById('dropdown-container') as HTMLDivElement +const $closePackagePopup = document.getElementById('close-package-popup') as HTMLSpanElement + +let _selectedPackageName = '' +let _typingTimeout = null + +/** + * Fetches the list of packages from cdnjs.com + */ +const _fetchPackageList = async () => { + const searchTerm: string = $packageInput.value + const response = await fetch(`https://api.cdnjs.com/libraries?search=${searchTerm}`) + const data = await response.json() + // Always remove previous dropdown if present + _removePackageDropdown() + if (data.results.length) { + _createDropdown(data.results) + } +} + +/** + * Filters and returns only JS libraries + */ +const _getFilteredResults = (results: ResultItem[]) => { + return results.filter(item => /\.js$/.test(item.latest)) +} + +/** + * Renders dropdown for the list of packages + */ +const _createDropdown = (results: ResultItem[]) => { + const $ul = document.createElement('ul') + $ul.setAttribute('id', 'package-list') + $ul.setAttribute('class', 'package__list') + $packageListContainer.appendChild($ul) + + const jsPackages = _getFilteredResults(results) + + for (let i = 0; i < jsPackages.length; i++) { + const $li = document.createElement('li') + $li.setAttribute('class', 'package__list-item') + $li.setAttribute('data-url', jsPackages[i].latest) + $li.appendChild(document.createTextNode(jsPackages[i].name)) + $li.addEventListener('click', _setSelectedListItem) + $li.addEventListener('mouseover', _hoverItem) + $li.addEventListener('mouseout', _unhoverItem) + $ul.appendChild($li) + } + + _focusFirstItem() +} + +const _handleDropdownNavigation = (e: KeyboardEvent) => { + const $isDropdownVisible = document.getElementById('package-list') + if (!$isDropdownVisible) return + + switch (e.keyCode) { + case DOWN_ARROW_KEY_CODE: + _focusNextItem() + return + case UP_ARROW_KEY_CODE: + _focusPreviousItem() + return + case ESCAPE_KEY_CODE: + _removePackageDropdown() + return + } +} + +const _hoverItem = e => { + e.target.classList.add('is-active') +} + +const _unhoverItem = e => { + e.target.classList.remove('is-active') +} + +const _focusFirstItem = () => { + document.querySelector('.package__list-item').classList.add('is-active') +} + +const _getActiveItem = () => { + return document.querySelector('.package__list-item.is-active') as HTMLLIElement +} + +const _focusNextItem = () => { + const $currentItem: HTMLLIElement = _getActiveItem() + $currentItem.classList.remove('is-active') + const { nextSibling } = $currentItem + if (nextSibling) { + // @ts-ignore + nextSibling.classList.add('is-active') + // @ts-ignore + nextSibling.scrollIntoView({ block: 'end', behavior: 'smooth' }) + } else { + _focusFirstItem() + } +} + +const _focusPreviousItem = () => { + const $currentItem: HTMLLIElement = _getActiveItem() + const { previousSibling } = $currentItem + if (previousSibling) { + $currentItem.classList.remove('is-active') + // @ts-ignore + previousSibling.classList.add('is-active') + // @ts-ignore + previousSibling.scrollIntoView({ behavior: 'smooth' }) + } +} + +/** + * Selects the package on click + */ +const _setSelectedListItem = e => { + const url: string = e.target.getAttribute('data-url') + _removePackageDropdown() + _selectedPackageName = e.target.innerText + _opiumLoadJS(url) +} + +/** + * Removes package list dropdown from DOM + */ +const _removePackageDropdown = () => { + const $dropdown = document.getElementById('package-list') + if ($dropdown) { + $dropdown.remove() + } +} + +/** + * Triggers on Enter press, after filling package url + */ +const _onPackageFormSubmit = (e: KeyboardEvent) => { + e.preventDefault() + let url: string + const activeItem: HTMLLIElement = document.querySelector('.package__list-item.is-active') + if (activeItem) { // Package selected via keyboard navigation + url = activeItem.getAttribute('data-url') + _selectedPackageName = activeItem.innerText + } else { // Custom url + url = $packageInput.value + _selectedPackageName = url.substr(url.lastIndexOf('/') + 1) + } + _removePackageDropdown() + _opiumLoadJS(url) +} + +/** + * Load the selected script + */ +const _opiumLoadJS = async (url: string) => { + if (!/^https:\/\//.test(url)) return + const script = document.createElement('script') + script.src = url + document.head.appendChild(script) + + const isValid = await _verifyPackage(url) + _appendToLoadedList(isValid) + + // Clear input after load + $packageInput.value = '' + + _selectedPackageName = '' + $packageInput.focus() +} + +/** + * Checks if the package includes DOM operations + */ +const _verifyPackage = async (url: string) => { + const response = await fetch(url) + const data = await response.text() + return !/document\./g.test(data) +} + +/** + * Append package name to the list + */ +const _appendToLoadedList = (isValid: boolean) => { + const $li = document.createElement('li') + $li.setAttribute('class', 'loaded-list__item') + $li.innerHTML = isValid + ? `` + : `` + $li.appendChild(document.createTextNode(`${_selectedPackageName} ${isValid ? '' : '(DOM not available)'}`)) + $loadedList.appendChild($li) +} + +/** + * Fetch packages on type + */ +const _handleType = (e: KeyboardEvent) => { + if (e.keyCode === UP_ARROW_KEY_CODE || e.keyCode === DOWN_ARROW_KEY_CODE) { + e.preventDefault() + return + } + + clearTimeout(_typingTimeout) + _typingTimeout = setTimeout(() => { + const $el = e.target as HTMLInputElement + if ($el.value.length > 2) { + _fetchPackageList() + } else { + _removePackageDropdown() + } + }, 500) +} + +/** + * Show/hide package popup + */ +const _togglePackagePopup = () => { + $package.classList.toggle('popup-visible') + + // Trigger focus after popup animation finishes + if (!$package.classList.contains('popup-visible')) return + setTimeout(() => { + $packageInput.focus() + }, 100) +} + +const _hidePackagePopup = () => { + $package.classList.remove('popup-visible') +} + +$packageInput.onkeyup = _handleType +$packageInput.onkeydown = _handleDropdownNavigation +$addPackageTrigger.onclick = _togglePackagePopup +$packageForm.onsubmit = _onPackageFormSubmit +$closePackagePopup.onclick = _hidePackagePopup + +/** + * Help popup + */ +const $help = document.getElementById('help') as HTMLDivElement +const $helpTrigger = document.getElementById('help-trigger') as HTMLDivElement +const $closeHelp = document.getElementById('close-help') as HTMLSpanElement + +$helpTrigger.addEventListener('click', () => { + $help.classList.toggle('popup-visible') +}) + +$closeHelp.addEventListener('click', () => { + $help.classList.remove('popup-visible') +}) + +/** + * Removes any visible popup on Esc + */ +document.addEventListener('keyup', (e: KeyboardEvent) => { + if (e.keyCode === ESCAPE_KEY_CODE) { + _removePackageDropdown() + _hidePackagePopup() + $help.classList.remove('popup-visible') + } +})