Repository: zonetti/zonote Branch: master Commit: 706be61b3679 Files: 16 Total size: 49.1 KB Directory structure: gitextract_2n15ldnx/ ├── .github/ │ └── workflows/ │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── build/ │ └── icon.icns ├── package.json ├── src/ │ ├── main/ │ │ ├── formatter.js │ │ ├── main.js │ │ └── state_manager.js │ └── renderer/ │ ├── notes.js │ ├── script.js │ ├── tabs.js │ ├── top_menu.js │ └── util.js └── static/ ├── index.html └── style.css ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/release.yml ================================================ name: Release binaries on: release: types: [published] jobs: build: runs-on: ${{ matrix.os }} strategy: matrix: os: [windows-latest, ubuntu-latest, macOS-latest] include: - os: windows-latest zip_name: zonote-win - os: ubuntu-latest zip_name: zonote-linux - os: macOS-latest zip_name: zonote-mac steps: - uses: actions/checkout@v1 - uses: actions/setup-node@v1 with: node-version: 12 - name: Install dependencies run: npm i - name: Build Windows if: matrix.os == 'windows-latest' shell: bash run: npm run build:windows - name: Build Linux if: matrix.os == 'ubuntu-latest' shell: bash run: npm run build:linux - name: Build Mac if: matrix.os == 'macOS-latest' shell: bash run: npm run build:mac - name: Zip shell: bash env: ZIP_NAME: ${{ matrix.zip_name }} run: | cd dist 7z a ../${ZIP_NAME}.zip . - name: Upload release asset if: github.event.action == 'published' uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ github.event.release.upload_url }} asset_path: ./${{ matrix.zip_name }}.zip asset_name: ${{ matrix.zip_name }}.zip asset_content_type: application/zip ================================================ FILE: .gitignore ================================================ node_modules dist ================================================ FILE: LICENSE ================================================ Copyright (c) 2020 Osvaldo Zonetti Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Cross-platform desktop note-taking app
Sticky notes + Markdown + Tabs
All in one .txt file

### Download You can find the latest version to download on the [release page](https://github.com/zonetti/zonote/releases/latest). ### Development ``` git clone git@github.com:zonetti/zonote.git cd zonote npm install && npm start ``` ================================================ FILE: package.json ================================================ { "name": "zonote", "version": "0.4.4", "main": "src/main/main.js", "scripts": { "start": "electron . --dev", "build:windows": "electron-packager . --out=dist --asar --overwrite --icon=build/icon.ico --version-string.CompanyName=zonetti --version-string.FileDescription=zonetti --version-string.ProductName=zonote", "build:linux": "electron-packager . --out=dist --asar --overwrite --icon=build/icon.png", "build:mac": "electron-packager . --out=dist --asar --overwrite --icon=build/icon.icns" }, "repository": { "type": "git", "url": "https://github.com/zonetti/zonote.git" }, "dependencies": { "dompurify": "^2.2.6", "electron-store": "^5.2.0", "events": "^3.2.0", "hotkeys-js": "^3.8.1", "marked": "^1.1.0", "shortid": "^2.2.15" }, "devDependencies": { "electron": "^9.0.0", "electron-packager": "^15.0.0" } } ================================================ FILE: src/main/formatter.js ================================================ const NOTE_MIN_WIDTH = 100 const NOTE_MIN_HEIGHT = 100 const NOTE_MAX_WIDTH = 5000 const NOTE_MAX_HEIGHT = 5000 const isTabs = /^znt-tabs\|.+/ const isNote = /^znt-note\|\d+,\d+,\d+,\d+,\d+/ const colors = ['default', 'white', 'black', 'primary', 'warning', 'danger'] function newNote ({ howManyTabs, t = 0, x = 0, y = 0, w = 500, h = 300, c = 0 }) { const note = { t: t >= 0 ? t : 0, x: x >= 0 ? x : 0, y: y >= 0 ? y : 0, w: w >= NOTE_MIN_WIDTH && w <= NOTE_MAX_WIDTH ? w : NOTE_MIN_WIDTH, h: h >= NOTE_MIN_HEIGHT && h <= NOTE_MAX_HEIGHT ? h : NOTE_MIN_HEIGHT, text: '', color: (c < 0 || c > colors.length - 1) ? colors[0] : colors[c] } if (t > 0 && (!howManyTabs || t > howManyTabs - 1)) note.t = 0 return note } function fromText (text) { text = text.replace(/\r/g, '') if (typeof text !== 'string') { throw new Error('"text" must be a string') } let tabs = [] const notes = [] const lines = text.split('\n') let currentNote = null for (let i = 0; i < lines.length; i++) { const line = lines[i] if (!line && !currentNote) continue if (i === 0 && isTabs.test(line)) { tabs = line.split('|').pop().split(',').map(tab => { return tab.length <= 100 ? tab : tab.substring(0, 100) }) continue } if (isNote.test(line)) { if (currentNote) { notes.push(currentNote) currentNote = null } const [t, x, y, w, h, c] = line.split('|').pop().split(',').map(n => parseInt(n, 10)) currentNote = newNote({ howManyTabs: tabs.length, t, x, y, w, h, c }) continue } if (!currentNote) { currentNote = newNote({ howManyTabs: tabs.length }) } currentNote.text += line + '\n' } if (currentNote) notes.push(currentNote) return { tabs, notes } } function toText ({ tabs, notes }) { let text = '' if (tabs.length) { text += 'znt-tabs|' tabs.forEach(tab => { text += `${tab},` }) text = text.substring(0, text.length - 1) + '\r\n' } for (let i = 0; i < notes.length; i++) { const note = notes[i] const color = note.color || 'default' note.c = colors.indexOf(color) text += `znt-note|${note.t},${note.x},${note.y},${note.w},${note.h},${note.c}\r\n` text += note.text.replace(/\r/g, '').replace(/\n/g, '\r\n') + '\r\n' } return text } if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = { fromText, toText } } else { window.fromText = fromText window.toText = toText } ================================================ FILE: src/main/main.js ================================================ const path = require('path') const { app, BrowserWindow, ipcMain, dialog } = require('electron') const stateManager = require('./state_manager') let state = stateManager.getInitialState() function close () { if (process.platform === 'darwin') return app.quit() } function start () { const minWidth = 800 const minHeight = 600 const browserWindowOptions = { minWidth, minHeight, width: minWidth, height: minHeight, autoHideMenuBar: true, frame: false, webPreferences: { nodeIntegration: true, enableRemoteModule: true } } const previousWindowState = stateManager.getWindowState() if (previousWindowState) { browserWindowOptions.x = previousWindowState.x browserWindowOptions.y = previousWindowState.y browserWindowOptions.width = previousWindowState.w browserWindowOptions.height = previousWindowState.h } const win = new BrowserWindow(browserWindowOptions) if (previousWindowState && previousWindowState.isMaximized) { win.maximize() } win.on('close', () => { stateManager.saveWindowState({ isMaximized: win.isMaximized(), x: win.getPosition()[0], y: win.getPosition()[1], w: win.getSize()[0], h: win.getSize()[1] }) }) ipcMain.on('minimize', () => win.isMinimized() ? win.restore() : win.minimize()) ipcMain.on('maximize', () => win.isMaximized() ? win.restore() : win.maximize()) ipcMain.on('get state', event => event.reply('update state', state)) ipcMain.on('close', close) ipcMain.on('update state', (event, guiState) => { state = guiState stateManager.saveState(state) }) ipcMain.on('new file', event => { state = stateManager.getNewState() event.reply('update state', state) }) ipcMain.on('open file', (event, filePath) => { try { state = stateManager.getStateFromFile(filePath) event.reply('update state', state) } catch (err) { console.log(err) dialog.showMessageBoxSync({ message: 'Oh no... something is not right' }) event.reply('done') } }) ipcMain.on('save file', (event, savePath) => { try { const filePath = savePath || state.path stateManager.saveStateToFile(filePath, state) event.reply('done', filePath, path.basename(filePath)) } catch (err) { if (err.message && err.message.includes('ENOENT')) { delete state.path return event.reply('done', 'save as') } console.log(err) dialog.showMessageBoxSync({ message: 'Oh no... something is not right' }) event.reply('done') } }) win.setMenu(null) win.loadFile(path.join(__dirname, '..', '..', 'static', 'index.html')) if (process.argv.includes('--dev')) { win.webContents.openDevTools() } } app.on('window-all-closed', close) app.on('activate', () => { if (BrowserWindow.getAllWindows().length !== 0) return start() }) app.whenReady().then(start) ================================================ FILE: src/main/state_manager.js ================================================ const fs = require('fs') const path = require('path') const Store = require('electron-store') const formatter = require('./formatter') const store = new Store() const NEW_STATE = { file: 'new file', path: null, isDirty: false, activeTab: 0, tabs: [], notes: [] } function saveState (state) { store.set('state', state) } function getNewState () { return JSON.parse(JSON.stringify(NEW_STATE)) } function getInitialState () { const state = store.get('state') if (!state) return getNewState() state.notes = state.notes.map(note => { delete note.id return note }) if (state.path) { try { fs.readFileSync(state.path) } catch (err) { state.isDirty = true } } return state } function saveStateToFile (filePath, state) { fs.writeFileSync(filePath, formatter.toText(state)) } function getStateFromFile (filePath) { const parsed = formatter.fromText(fs.readFileSync(filePath).toString()) return { file: path.basename(filePath), path: filePath, isDirty: false, activeTab: 0, tabs: parsed.tabs, notes: parsed.notes } } function saveWindowState (data) { store.set('win-state', data) } function getWindowState () { return store.get('win-state') } module.exports = { saveState, getNewState, getInitialState, saveStateToFile, getStateFromFile, saveWindowState, getWindowState } ================================================ FILE: src/renderer/notes.js ================================================ const shortid = require('shortid') const marked = require('marked') const DOMPurify = require('dompurify') marked.setOptions({ breaks: true, gfm: true, smartypants: true }) const contentElm = document.getElementById('content') const editMessageElm = document.getElementById('edit-message') const backMessageElm = document.getElementById('back-message') const removeNoteElm = document.getElementById('remove-note') const colorPickerElm = document.getElementById('color-picker') function generateId () { const id = shortid().replace(/\W/g, '') + new Date().getTime() return id.split('').sort(() => 0.5 - Math.random()).join('') } let editMessageTimeout = null EVENTS.on('render', () => { editMessageElm.style.display = 'none' const visibleNotes = STATE.notes.filter(note => note.t === STATE.activeTab) backMessageElm.style.display = visibleNotes.length ? 'none' : 'block' contentElm.style.top = TOP_OFFSET() + 'px' document.querySelectorAll('.note').forEach(e => { if (e.isSameNode(GUI_STATE.elementBeingDraggedElm)) return e.remove() }) STATE.notes.forEach(note => { EVENTS.emit('render note', note) }) }) function enableResizing (noteElm) { let resizeStartX, resizeStartY, resizeStartWidth, resizeStartHeight function resize (resizerClass) { return function (event) { if (['resizer-right', 'resizer-both'].includes(resizerClass)) { let resizeAmount = resizeStartWidth + event.pageX - resizeStartX if (resizeAmount < NOTE_MIN_WIDTH) { resizeAmount = NOTE_MIN_WIDTH } GET_NOTE_BY_ID(GUI_STATE.elementBeingResizedElm.dataset.id).w = resizeAmount GUI_STATE.elementBeingResizedElm.style.width = resizeAmount + 'px' } if (['resizer-bottom', 'resizer-both'].includes(resizerClass)) { let resizeAmount = resizeStartHeight + event.pageY - resizeStartY if (resizeAmount < NOTE_MIN_HEIGHT) { resizeAmount = NOTE_MIN_HEIGHT } GET_NOTE_BY_ID(GUI_STATE.elementBeingResizedElm.dataset.id).h = resizeAmount GUI_STATE.elementBeingResizedElm.style.height = resizeAmount + 'px' } } } function stopResizing () { GUI_STATE.isResizing = false UTIL.removeClass(GUI_STATE.elementBeingResizedElm, 'selected') GUI_STATE.elementBeingResizedElm = null document.documentElement.onmousemove = null document.documentElement.onmouseup = null EVENTS.emit('touch state') EVENTS.emit('render') } function startResizing (event) { EVENTS.emit('hide menu') event.stopPropagation() GUI_STATE.isResizing = true GUI_STATE.elementBeingResizedElm = this.parentNote resizeStartX = event.pageX resizeStartWidth = parseInt(document.defaultView.getComputedStyle(GUI_STATE.elementBeingResizedElm).width, 10) resizeStartY = event.pageY resizeStartHeight = parseInt(document.defaultView.getComputedStyle(GUI_STATE.elementBeingResizedElm).height, 10) UTIL.addClass(GUI_STATE.elementBeingResizedElm, 'selected') document.documentElement.onmousemove = resize(this.className) document.documentElement.onmouseup = stopResizing } const rightElm = document.createElement('div') rightElm.className = 'resizer-right' noteElm.appendChild(rightElm) rightElm.onmousedown = startResizing rightElm.parentNote = noteElm const bottomElm = document.createElement('div') bottomElm.className = 'resizer-bottom' noteElm.appendChild(bottomElm) bottomElm.onmousedown = startResizing bottomElm.parentNote = noteElm const bothElm = document.createElement('div') bothElm.className = 'resizer-both' noteElm.appendChild(bothElm) bothElm.onmousedown = startResizing bothElm.parentNote = noteElm } function enableDragging (noteElm) { const draggingMouseDifference = [0, 0] function dragElement (event) { if (!GUI_STATE.elementBeingDraggedElm) return event = event || window.event let noteNewPosition = [ event.pageX + draggingMouseDifference[0], event.pageY - TOP_OFFSET() + draggingMouseDifference[1] ] if (noteNewPosition[0] <= CANVAS_PADDING()) { noteNewPosition[0] = CANVAS_PADDING() } if (noteNewPosition[1] <= CANVAS_PADDING()) { noteNewPosition[1] = CANVAS_PADDING() } const note = GET_NOTE_BY_ID(GUI_STATE.elementBeingDraggedElm.dataset.id) note.x = noteNewPosition[0] - CANVAS_PADDING() note.y = noteNewPosition[1] - CANVAS_PADDING() GUI_STATE.elementBeingDraggedElm.style.left = noteNewPosition[0] + 'px' GUI_STATE.elementBeingDraggedElm.style.top = noteNewPosition[1] + 'px' UTIL.addClass(GUI_STATE.elementBeingDraggedElm, 'selected') if (STATE.isInRemoveNoteArea) { UTIL.addClass(GUI_STATE.elementBeingDraggedElm, 'remove') } else { UTIL.removeClass(GUI_STATE.elementBeingDraggedElm, 'remove') } } function stopDragging () { if (STATE.isInRemoveNoteArea) { const confirmationDialogResponse = DIALOG.showMessageBoxSync({ message: 'Are you sure you want to remove this note?', buttons: ['Yes', 'No'] }) const removeConfirmed = confirmationDialogResponse === 0 if (removeConfirmed) { const noteIdToRemove = GUI_STATE.elementBeingDraggedElm.dataset.id let noteIndex = 0 for (let i = 0; i < STATE.notes.length; i++) { if (STATE.notes[i].id !== noteIdToRemove) continue noteIndex = i break } STATE.notes.splice(noteIndex, 1) GUI_STATE.elementBeingDraggedElm.remove() EVENTS.emit('render') } } UTIL.removeClasses(GUI_STATE.elementBeingDraggedElm, ['selected', 'remove']) GUI_STATE.isDragging = false removeNoteElm.style.opacity = 0 GUI_STATE.elementBeingDraggedElm = null document.onmouseup = null document.onmousemove = null EVENTS.emit('touch state') EVENTS.emit('render') } function startDragging (event) { EVENTS.emit('hide menu') GUI_STATE.elementBeingDraggedElm = this GUI_STATE.elementBeingDraggedElm.style.zIndex = NEXT_ZINDEX() removeNoteElm.style.zIndex = NEXT_ZINDEX() event = event || window.event draggingMouseDifference[0] = GUI_STATE.elementBeingDraggedElm.offsetLeft - event.pageX draggingMouseDifference[1] = GUI_STATE.elementBeingDraggedElm.offsetTop - (event.pageY - TOP_OFFSET()) GUI_STATE.isDragging = true UTIL.addClass(GUI_STATE.elementBeingDraggedElm, 'selected') removeNoteElm.style.opacity = 1 removeNoteElm.style.top = (TOP_OFFSET() + removeNoteElm.offsetHeight) + 'px' document.onmouseup = stopDragging document.onmousemove = dragElement } noteElm.onmousedown = startDragging } EVENTS.on('render note text', (note, noteElm) => { const noteTextElm = document.createElement('div') noteTextElm.className = 'text' noteTextElm.innerHTML = DOMPurify.sanitize(marked(note.text)) noteTextElm.onclick = event => { EVENTS.emit('hide menu') event.stopPropagation() } noteTextElm.ondblclick = () => { EVENTS.emit('hide menu') note.isEditing = true editMessageElm.style.display = 'none' EVENTS.emit('render note', note) } noteTextElm.onmouseenter = function () { this.style.userSelect = 'text' editMessageElm.style.display = 'block' editMessageElm.style.top = (noteElm.offsetTop + noteElm.offsetHeight - 15) + 'px' editMessageElm.style.left = (noteElm.offsetLeft + (noteElm.offsetWidth / 2) - (editMessageElm.offsetWidth / 2)) + 'px' editMessageElm.style.zIndex = '' + (parseInt(noteElm.style.zIndex, 10) + 5) clearTimeout(editMessageTimeout) editMessageTimeout = setTimeout(() => { editMessageElm.style.display = 'none' }, 1000) } noteTextElm.onmouseleave = function () { this.style.userSelect = 'none' editMessageElm.style.display = 'none' } noteTextElm.onmousedown = event => event.stopPropagation() noteTextElm.onmouseout = event => event.stopPropagation() noteTextElm.querySelectorAll('a').forEach(linkElm => { linkElm.onclick = event => { event.preventDefault() require('electron').shell.openExternal(event.target.href) } }) noteElm.appendChild(noteTextElm) }) EVENTS.on('render note', note => { if (!note.id) { note.id = generateId() } let noteElm = document.querySelector(`.note[data-id="${note.id}"]`) if (noteElm && !noteElm.isSameNode(GUI_STATE.elementBeingDraggedElm)) { noteElm.remove() noteElm = null } if (note.t !== STATE.activeTab) return if (!noteElm) { noteElm = document.createElement('div') } noteElm.dataset.id = note.id noteElm.dataset.tab = note.t noteElm.className = 'note ' + (note.color || 'default') noteElm.style.display = note.t === STATE.activeTab ? 'block' : 'none' noteElm.style.top = note.y + CANVAS_PADDING() + 'px' noteElm.style.left = note.x + CANVAS_PADDING() + 'px' noteElm.style.width = note.w + 'px' noteElm.style.height = note.h + 'px' noteElm.style.zIndex = NEXT_ZINDEX() enableResizing(noteElm) enableDragging(noteElm) noteElm.ondblclick = event => event.stopPropagation() noteElm.onmouseenter = function (event) { if (GUI_STATE.isDragging || GUI_STATE.isResizing) return this.style.zIndex = NEXT_ZINDEX() } if (note.isEditing) { const noteTextareaElm = document.createElement('textarea') noteTextareaElm.onblur = function () { delete note.isEditing this.remove() EVENTS.emit('render note text', note, noteElm) } noteTextareaElm.onclick = event => event.stopPropagation() noteTextareaElm.onmousedown = event => event.stopPropagation() noteTextareaElm.onkeydown = function (event) { if (event.keyCode === KEY_ESC) return EVENTS.emit('render note', note) if (event.keyCode === KEY_TAB) { event.preventDefault() const selectionStart = this.selectionStart this.value = this.value.substring(0, selectionStart) + ' ' + this.value.substring(this.selectionEnd) this.selectionEnd = selectionStart + 2 } note.text = this.value EVENTS.emit('touch state') } noteTextareaElm.oninput = function (event) { note.text = this.value EVENTS.emit('touch state') } noteElm.appendChild(noteTextareaElm) setTimeout(() => { noteTextareaElm.value = note.text noteTextareaElm.focus() }, 0) } else if (!noteElm.isSameNode(GUI_STATE.elementBeingDraggedElm)) { EVENTS.emit('render note text', note, noteElm) } noteElm.addEventListener('contextmenu', e => { e.preventDefault() colorPickerElm.style.display = 'block' colorPickerElm.style.top = (e.pageY - 15) + 'px' colorPickerElm.style.left = (e.pageX - 15) + 'px' colorPickerElm.style.zIndex = NEXT_ZINDEX() GUI_STATE.noteRightClicked = note }) contentElm.appendChild(noteElm) }) removeNoteElm.onmouseenter = () => { STATE.isInRemoveNoteArea = true } removeNoteElm.onmouseleave = () => { STATE.isInRemoveNoteArea = false } function hideColorPicker () { colorPickerElm.style.display = 'none' GUI_STATE.noteRightClicked = null } colorPickerElm.onmouseleave = hideColorPicker colorPickerElm.querySelectorAll('span').forEach(spanElm => { spanElm.onclick = e => { e.preventDefault() GUI_STATE.noteRightClicked.color = spanElm.className.split(' ')[0] EVENTS.emit('touch state') hideColorPicker() } }) contentElm.ondblclick = event => { const newNote = { t: STATE.activeTab, x: event.pageX - CANVAS_PADDING(), y: event.pageY - TOP_OFFSET() - CANVAS_PADDING(), w: 200, h: 200, text: '', isEditing: true, color: 'default' } if (newNote.x < 0) newNote.x = 0 if (newNote.y < 0) newNote.y = 0 STATE.notes.push(newNote) EVENTS.emit('touch state') EVENTS.emit('render') } ================================================ FILE: src/renderer/script.js ================================================ const EventEmitter = require('events') const electron = require('electron') window.STATE = {} window.GUI_STATE = { zIndex: 1 } window.EVENTS = new EventEmitter() window.DIALOG = electron.remote.dialog window.IPC = electron.ipcRenderer window.NEXT_ZINDEX = () => '' + ++GUI_STATE.zIndex // REFACTOR -> window.CANVAS_PADDING = () => 20 window.TOP_OFFSET = () => { const topBarElm = document.getElementById('top') const tabsElm = document.getElementById('tabs') return topBarElm.offsetHeight + tabsElm.offsetHeight } window.ALLOWED_EXTENSIONS = ['znt', 'txt'] window.KEY_ESC = 27 window.KEY_TAB = 9 window.NOTE_MIN_HEIGHT = 100 window.NOTE_MIN_WIDTH = 100 window.GET_NOTE_BY_ID = id => { for (let i = 0; i < STATE.notes.length; i++) { if (STATE.notes[i].id === id) return STATE.notes[i] } return null } // <- REFACTOR require('../src/renderer/util') require('../src/renderer/top_menu') require('../src/renderer/tabs') require('../src/renderer/notes') document.body.onclick = () => EVENTS.emit('lose focus') document.body.onresize = () => EVENTS.emit('render tab scroll') EVENTS.on('render', () => IPC.send('update state', STATE)) IPC.on('update state', (event, newState) => { STATE = newState EVENTS.emit('render') }) IPC.send('get state') ================================================ FILE: src/renderer/tabs.js ================================================ const hotkeys = require('hotkeys-js') const tabsElm = document.getElementById('tabs') const tabScrollLeftElm = document.getElementById('tab-scroll-left') const tabScrollRightElm = document.getElementById('tab-scroll-right') const removeNoteElm = document.getElementById('remove-note') EVENTS.on('toggle tabs', () => { EVENTS.emit('hide menu') if (STATE.tabs.length > 0) { const tabsWithNotes = STATE.notes .map(note => note.t) .filter((value, index, self) => self.indexOf(value) === index) if (tabsWithNotes.length === 1) { STATE.notes = STATE.notes.map(note => ({ ...note, t: 0 })) } else if (tabsWithNotes.length > 1) { const chosenAction = DIALOG.showMessageBoxSync({ message: 'All your existing notes will be merged into one canvas.\nAre you sure you want to proceed?', buttons: ['Yes', 'No'] }) if (chosenAction === 1) return STATE.notes = STATE.notes.map(note => ({ ...note, t: 0 })) } STATE.tabs = [] } else { STATE.tabs = ['new tab'] EVENTS.emit('touch state') } STATE.activeTab = 0 EVENTS.emit('touch state') EVENTS.emit('render') }) EVENTS.on('render tab scroll', () => { const newTabButtonElm = document.getElementById('new-tab-button') const showLeft = tabsElm.scrollLeft > 0 const scrollRight = tabsElm.scrollWidth - tabsElm.scrollLeft - window.innerWidth > 0 tabScrollLeftElm.style.display = showLeft ? 'block' : 'none' tabScrollRightElm.style.display = scrollRight ? 'block' : 'none' }) EVENTS.on('render', () => { tabsElm.innerText = '' tabsElm.style.display = STATE.tabs.length > 0 ? 'inline-flex' : 'none' tabScrollLeftElm.style.display = 'none' tabScrollRightElm.style.display = 'none' if (!STATE.tabs.length) return let tabBeingDragged = null STATE.tabs.forEach((tabText, tabIndex) => { const isActiveTab = STATE.activeTab === tabIndex const tabElm = document.createElement('li') tabElm.dataset.index = '' + tabIndex tabElm.draggable = 'true' tabElm.ondrag = function (event) { tabBeingDragged = this } tabElm.ondragover = event => event.preventDefault() tabElm.ondragenter = function (event) { if (this.isSameNode(tabBeingDragged)) return this.style.backgroundColor = '#4ECDC4' this.style.color = '#FFF' } tabElm.ondragleave = function (event) { if (this.isSameNode(tabBeingDragged)) return if (this.contains(document.elementFromPoint(event.pageX, event.pageY))) return this.style.backgroundColor = '' this.style.color = '' } tabElm.ondrop = function (event) { event.preventDefault() this.style.backgroundColor = '' const fromIndex = parseInt(tabBeingDragged.dataset.index, 10) const toIndex = parseInt(this.dataset.index, 10) STATE.notes = STATE.notes.map(note => { if (![fromIndex, toIndex].includes(note.t)) return note note.t = note.t === fromIndex ? toIndex : fromIndex return note }) const tempTab = STATE.tabs[fromIndex] STATE.tabs[fromIndex] = STATE.tabs[toIndex] STATE.tabs[toIndex] = tempTab tabBeingDragged = null if (STATE.activeTab === fromIndex) { STATE.activeTab = toIndex } EVENTS.emit('render') } const spanElm = document.createElement('span') spanElm.ondragover = event => event.preventDefault() spanElm.innerText = tabText if (isActiveTab) { spanElm.contentEditable = 'true' spanElm.spellcheck = false } const removeButtonElm = document.createElement('button') removeButtonElm.ondragover = event => event.preventDefault() removeButtonElm.type = 'button' removeButtonElm.style.opacity = isActiveTab ? 1 : 0 removeButtonElm.innerText = '𝗑' removeButtonElm.onclick = event => { event.stopPropagation() EVENTS.emit('remove tab', tabIndex) } tabElm.append(spanElm) tabElm.append(removeButtonElm) if (isActiveTab) { tabElm.className = 'active' tabElm.onclick = event => event.stopPropagation() spanElm.oninput = function () { EVENTS.emit('touch state') if (this.innerText.length <= 100) return this.innerText = this.innerText.substring(0, 100) } spanElm.onblur = function () { const value = this.innerText .replace(/ /g, ' ') .replace(/[,|]/g, '') .replace(/\s+/g, ' ') .trim() this.innerText = value || 'new tab' STATE.tabs[tabIndex] = this.innerText } } else { tabElm.style.userSelect = 'none' tabElm.onclick = () => { STATE.activeTab = tabIndex EVENTS.emit('render') } tabElm.onmouseenter = () => { removeButtonElm.style.opacity = 1 if (GUI_STATE.isDragging) { STATE.activeTab = tabIndex GET_NOTE_BY_ID(GUI_STATE.elementBeingDraggedElm.dataset.id).t = tabIndex EVENTS.emit('render') GUI_STATE.elementBeingDraggedElm.style.zIndex = NEXT_ZINDEX() removeNoteElm.style.zIndex = NEXT_ZINDEX() } } tabElm.onmouseleave = () => { removeButtonElm.style.opacity = 0 } } tabsElm.append(tabElm) }) const newTabButtonElm = document.createElement('li') newTabButtonElm.id = 'new-tab-button' newTabButtonElm.innerText = '+' newTabButtonElm.onmouseenter = function () { this.innerText = '+ new tab' } newTabButtonElm.onmouseleave = function () { this.innerText = '+' } newTabButtonElm.onclick = () => { STATE.tabs.push('new tab') STATE.activeTab = STATE.tabs.length - 1 EVENTS.emit('touch state') EVENTS.emit('render') } tabsElm.append(newTabButtonElm) EVENTS.emit('render tab scroll') }) EVENTS.on('new tab', () => { STATE.tabs.push('new tab') STATE.activeTab = STATE.tabs.length - 1 EVENTS.emit('render') EVENTS.emit('touch state') }) EVENTS.on('remove tab', tabIndex => { EVENTS.emit('touch state') if (STATE.tabs.length === 1) { STATE.tabs = [] return EVENTS.emit('render') } if (STATE.notes.filter(note => note.t === tabIndex).length) { const confirmationDialogResponse = DIALOG.showMessageBoxSync({ message: 'If you remove this tab all your notes inside of it will be removed as well.\nAre you sure you want to proceed?', buttons: ['Yes', 'No'] }) const removeConfirmed = confirmationDialogResponse === 0 if (!removeConfirmed) return document.querySelectorAll(`.note[data-tab="${tabIndex}"]`).forEach(noteElm => noteElm.remove()) STATE.notes = STATE.notes.filter(note => note.t !== tabIndex) } STATE.notes.filter(note => note.t > tabIndex).forEach(note => { note.t-- }) STATE.tabs.splice(tabIndex, 1) if (STATE.activeTab > 0 && STATE.activeTab >= tabIndex) { STATE.activeTab-- } EVENTS.emit('render') }) EVENTS.on('switch tab', direction => { const tabsLength = STATE.tabs.length if (tabsLength === 0 || tabsLength === 1) return let newActiveTab = STATE.activeTab if (direction === 'next') { newActiveTab++ if (newActiveTab === tabsLength) { newActiveTab = 0 } } else { newActiveTab-- if (newActiveTab < 0) { newActiveTab = tabsLength - 1 } } STATE.activeTab = newActiveTab EVENTS.emit('render') }) tabsElm.onwheel = event => tabsElm.scrollLeft += event.deltaY tabsElm.onscroll = () => EVENTS.emit('render tab scroll') let scrollTabsInterval = null function scrollTabs (amount) { tabsElm.scrollLeft += amount } tabScrollLeftElm.onmouseenter = () => scrollTabsInterval = setInterval(() => scrollTabs(-10), 50) tabScrollLeftElm.onmouseleave = () => clearInterval(scrollTabsInterval) tabScrollRightElm.onmouseenter = () => scrollTabsInterval = setInterval(() => scrollTabs(10), 50) tabScrollRightElm.onmouseleave = () => clearInterval(scrollTabsInterval) hotkeys('ctrl+t,command+t', () => EVENTS.emit('new tab')) hotkeys('ctrl+w,command+w', () => { if (!STATE.tabs.length) return EVENTS.emit('remove tab', STATE.activeTab) }) hotkeys('ctrl+tab,command+tab', () => EVENTS.emit('switch tab', 'next')) hotkeys('ctrl+shift+tab,command+shift+tab', () => EVENTS.emit('switch tab', 'previous')) ================================================ FILE: src/renderer/top_menu.js ================================================ const hotkeys = require('hotkeys-js') const menuElm = document.getElementById('menu') const menuButtonElm = document.getElementById('menu-button') const topButtonCloseAppElm = document.getElementById('close') const fileElm = document.getElementById('file') function createMenuButton (text, key) { const shortcut = process.platform === 'darwin' ? 'command' : 'ctrl' const buttonElm = document.createElement('li') buttonElm.innerText = text const span = document.createElement('span') span.innerText = `${shortcut} + ${key}` buttonElm.appendChild(span) return buttonElm } const menuButtonNewElm = createMenuButton('new', 'n') const menuButtonOpenElm = createMenuButton('open...', 'o') const menuButtonSaveElm = createMenuButton('save', 's') const menuButtonTabsElm = document.createElement('li') menuButtonTabsElm.id = 'tabs-menu-button' const menuButtonExitElm = document.createElement('li') menuButtonExitElm.innerText = 'exit' menuElm.appendChild(menuButtonNewElm) menuElm.appendChild(menuButtonOpenElm) menuElm.appendChild(menuButtonSaveElm) menuElm.appendChild(menuButtonTabsElm) menuElm.appendChild(menuButtonExitElm) EVENTS.on('render', () => { document.title = STATE.path || 'zonote' fileElm.innerText = `${STATE.isDirty ? '*' : ''}${STATE.file}` menuButtonTabsElm.innerText = STATE.tabs.length > 0 ? 'disable tabs' : 'enable tabs' }) EVENTS.on('hide menu', () => { menuElm.style.display = 'none' menuButtonElm.className = '' }) EVENTS.on('toggle menu', () => { if (menuButtonElm.className === 'open') return EVENTS.emit('hide menu') menuElm.style.display = 'block' menuButtonElm.className = 'open' menuElm.style.zIndex = 999 + NEXT_ZINDEX() }) EVENTS.on('touch state', () => { STATE.isDirty = true fileElm.innerText = `*${STATE.file}` }) EVENTS.on('lose focus', () => { EVENTS.emit('hide menu') EVENTS.emit('render') }) async function handleCurrentState () { EVENTS.emit('hide menu') if (!STATE.isDirty) return 'continue' const chosenAction = DIALOG.showMessageBoxSync({ message: 'You have unsaved changes in your current file.\nWhat do you want to do?', buttons: ['Save changes', 'Discard changes', 'Cancel'] }) if (chosenAction === 0) return EVENTS.emit('save file') if (chosenAction === 1) return 'continue' if (chosenAction === 2) return 'cancel' } EVENTS.on('new file', async () => { const action = await handleCurrentState() if (action === 'cancel') return IPC.send('new file') }) EVENTS.on('open file', async () => { const action = await handleCurrentState() if (action === 'cancel') return let filePath = DIALOG.showOpenDialogSync({ filters: [ { name: 'zonote files', extensions: ALLOWED_EXTENSIONS } ] }) if (!filePath) return filePath = filePath[0] IPC.send('open file', filePath) }) EVENTS.on('save file', async () => { EVENTS.emit('hide menu') if (!STATE.isDirty) return if (STATE.path) { await new Promise(resolve => { IPC.once('done', (event, filePath) => { if (filePath === 'save as') { delete STATE.path EVENTS.emit('save file') return resolve() } if (filePath) { STATE.path = filePath STATE.isDirty = false } resolve() }) IPC.send('save file') }) return EVENTS.emit('render') } const savePath = DIALOG.showSaveDialogSync({ filters: [ { name: 'zonote files', extensions: ALLOWED_EXTENSIONS } ], properties: [ 'createDirectory' ] }) if (!savePath) return await new Promise(resolve => { IPC.once('done', (event, filePath, fileName) => { if (filePath) { STATE.file = fileName STATE.path = filePath STATE.isDirty = false } resolve() }) IPC.send('save file', savePath) }) EVENTS.emit('render') }) EVENTS.on('close', () => { EVENTS.emit('hide menu') IPC.send('close', STATE) }) menuElm.onclick = event => event.stopPropagation() menuButtonElm.onclick = event => { event.stopPropagation() EVENTS.emit('toggle menu') } menuButtonNewElm.onclick = () => EVENTS.emit('new file') menuButtonOpenElm.onclick = () => EVENTS.emit('open file') menuButtonSaveElm.onclick = () => EVENTS.emit('save file') menuButtonTabsElm.onclick = () => EVENTS.emit('toggle tabs') menuButtonExitElm.onclick = () => EVENTS.emit('close') topButtonCloseAppElm.onclick = () => EVENTS.emit('close') hotkeys('esc', () => EVENTS.emit('lose focus')) hotkeys('ctrl+n,command+n', () => EVENTS.emit('new file')) hotkeys('ctrl+o,command+o', () => EVENTS.emit('open file')) hotkeys('ctrl+s,command+s', () => EVENTS.emit('save file')) ================================================ FILE: src/renderer/util.js ================================================ window.UTIL = {} window.UTIL.addClass = (elm, value) => { const classes = elm.className.split(' ') if (classes.includes(value)) return elm.className = classes.concat(value).join(' ') } window.UTIL.removeClass = (elm, value) => { const classes = elm.className.split(' ') if (!classes.includes(value)) return classes.splice(classes.indexOf(value), 1) elm.className = classes.join(' ') } window.UTIL.addClasses = (elm, values) => values.forEach(v => UTIL.addClass(elm, v)) window.UTIL.removeClasses = (elm, values) => values.forEach(v => UTIL.removeClass(elm, v)) ================================================ FILE: static/index.html ================================================ zonote
drag here to remove

double click to edit

double click to create a new note

================================================ FILE: static/style.css ================================================ @import url('https://fonts.googleapis.com/css?family=Merriweather:300'); * { margin: 0; padding: 0; } :focus { outline: 0; } body { font-size: 16px; font-family: "Merriweather", "PT Serif", Georgia, "Times New Roman", "STSong", Serif; } #top { position: fixed; top: 0; left: 0; width: 100%; -webkit-app-region: drag; height: 60px; background: #556270; border: 1px solid #000; box-sizing: border-box; } #remove-note { position: fixed; left: 50%; list-style-type: none; font-size: 13px; line-height: 26px; z-index: 100; -ms-transform: translate(-50%, -50%); transform: translate(-50%, -50%); border: 2px dashed #FF6B6B; background: #FAFAFA; color: #FF6B6B; border-radius: 5px; padding: 2px 10px; transition: opacity .2s; opacity: 0; } #remove-note:hover { border: 2px solid #FF6B6B; } #tabs { position: fixed; box-sizing: border-box; top: 60px; left: 0; background: #E5E5E5; width: 100%; border: 1px solid #000; border-top: none; border-bottom: none; z-index: 100; list-style-type: none; overflow: hidden; display: none; } #tabs li { display: inline-flex; padding: 10px; line-height: 20px; white-space: nowrap; cursor: pointer; border-bottom: 1px solid #000; } #tabs li:hover { background: #EEE; } #tabs li span { display: flex; } #tabs li button { float: right; opacity: 0; transition: .1s; top: 10px; right: 10px; background: #FF6B6B; text-align: center; font-size: 10px; color: #FFF; height: 20px; width: 20px; border-radius: 10px; border: 1px solid #C44D58; margin-left: 10px; cursor: pointer; } #tabs li button:hover { box-shadow: 1px 1px #333; background: #EE5A5A; } #tabs li#new-tab-button { min-width: 160px; width: 100%; } #tabs li.active { background: #FAFAFA; border-bottom: 1px solid #FAFAFA; } #tabs li.active span { cursor: text; } #tabs li:not(:first-child) { border-left: 1px solid #000; } button.tab-scroll { position: fixed; z-index: 999; top: 66px; background: #4ECDC4; border: none; color: #FFF; opacity: 1; padding: 5px 0; width: 30px; font-size: 18px; text-align: center; line-height: 18px; font-weight: bold; text-shadow: 1px 1px #333; display: none; } button.tab-scroll:hover { background: #5FDED5; cursor: pointer; opacity: 1; } button#tab-scroll-left { border-left: 1px solid #000; border-top-right-radius: 10px; border-bottom-right-radius: 10px; left: 0; } button#tab-scroll-left:hover { text-align: left; padding-left: 5px; } button#tab-scroll-right { border-right: 1px solid #000; border-top-left-radius: 10px; border-bottom-left-radius: 10px; right: 0; } button#tab-scroll-right:hover { text-align: right; padding-right: 5px; } #content { position: absolute; bottom: 0; left: 0; top: 60px; width: 100%; background: #FAFAFA; border: 1px solid #000; border-top: none; box-sizing: border-box; padding: 20px; font-size: 1.3em; } #menu-button, #menu li, #file, #close, #back-message, .note div, #tabs button, #tabs #new-tab-button, button.tab-scroll, #remove-note, #edit-message { user-select: none; } #menu-button, #close, #back-message { -webkit-app-region: no-drag; } #close { position: fixed; top: 15px; right: 20px; background: #FF6B6B; transition: .1s; color: #FFF; height: 30px; width: 30px; border-radius: 15px; font-size: 18px; border: 1px solid #C44D58; cursor: pointer; } #close:hover { box-shadow: 1px 1px #333; background: #EE5A5A; } #menu-button { position: fixed; top: 20px; left: 20px; height: 20px; width: 30px; cursor: pointer; } #file { position: fixed; top: 20px; left: 70px; font-size: 18px; line-height: 20px; color: #FFF; text-shadow: 1px 1px #333; } #menu-button span { display: block; position: absolute; height: 4px; width: 100%; background: #4ECDC4; opacity: 1; left: 0; transform: rotate(0deg); transition: .1s; } #menu-button span:nth-child(1) { top: 0px; } #menu-button span:nth-child(2), #menu-button span:nth-child(3) { top: 8px; } #menu-button span:nth-child(4) { top: 16px; } #menu-button.open span:nth-child(1), #menu-button.open span:nth-child(4) { top: 8px; width: 0%; left: 50%; } #menu-button.open span:nth-child(2) { transform: rotate(45deg); } #menu-button.open span:nth-child(3) { transform: rotate(-45deg); } #edit-message { position: absolute; border: 2px dashed #777; border-radius: 5px; background: #FAFAFA; font-size: 12px; line-height: 14px; padding: 6px 10px; display: none; } #back-message { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 14px; border: 2px dashed #888; color: #556270; border-radius: 5px; padding: 6px 10px; } #menu { position: fixed; display: none; top: 59px; left: 0; padding-top: 1px; background: #556270; z-index: 999; border: 1px solid #000; border-top: none; color: #FFF; text-shadow: 1px 1px #333; } #menu li { width: 200px; padding: 20px; list-style-type: none; font-size: 16px; line-height: 16px; cursor: pointer; } #menu li:hover { background: #4ECDC4; text-shadow: none; } #menu li span { float: right; margin-top: 1px; font-size: 12px; } #content { overflow: auto; } *::-webkit-scrollbar { width: 10px; height: 10px; background: #EEE; } *::-webkit-scrollbar-track, *::-webkit-scrollbar-corner { background: #EEE; } *::-webkit-scrollbar-thumb { background: #556270; } #color-picker { display: none; position: fixed; background: #EEE; border: 1px solid #777; box-shadow: 2px 2px #BBB; width: 84px; height: 54px; padding: 5px; } #color-picker span { display: inline-block; height: 20px; width: 20px; border: 1px solid #777; padding: 0; margin: 0; cursor: pointer; } #color-picker span.mr-5 { margin-right: 4px; } #color-picker span.mb-5 { margin-bottom: 4px; } #color-picker span.default { background: #EEE; } #color-picker span.white { background: #FAFAFA; } #color-picker span.black { background: #222; } #color-picker span.primary { background: #4ECDC4; } #color-picker span.warning { background: #FEE074; } #color-picker span.danger { background: #FF6B6B; } .note { position: absolute; border: 1px solid #777; box-shadow: 2px 2px #BBB; transition: box-shadow .5s, opacity .5s, background .5s; padding: 20px; min-width: 100px; min-height: 100px; max-width: 2000px; max-height: 2000px; font-size: 0.8em; opacity: 1; } .note.default { background: #EEE; color: #333; } .note.white { background: #FAFAFA; color: #333; } .note.black { background: #222; color: #FAFAFA; } .note.primary { background: #4ECDC4; color: #333; } .note.warning { background: #FEE074; color: #333; } .note.danger { background: #FF6B6B; color: #333; } .note.selected { border: 2px dashed #4ECDC4; opacity: 0.9; /* background: #EFEFEF; */ box-shadow: none !important; } .note:hover { cursor: move; box-shadow: 4px 4px #CCC; } .note.selected.remove { border: 2px solid #FF6B6B; box-shadow: none !important; } .note textarea { width: 100%; height: 100%; resize: none; background: #EEE; border: 2px dashed #333; color: #333; font-family: monospace; font-size: 1.2em; } .note.white textarea { background: #FAFAFA; } .note.black textarea { background: #222; color: #FAFAFA; border-color: #FAFAFA; } .note.primary textarea { background: #4ECDC4; color: #333; border-color: #333; } .note.warning textarea { background: #FEE074; color: #333; border-color: #333; } .note.danger textarea { background: #FF6B6B; color: #333; border-color: #333; } .note .resizer-right { width: 5px; height: 100%; background: transparent; position: absolute; right: 0; bottom: 0; cursor: e-resize; } .note .resizer-bottom { width: 100%; height: 5px; background: transparent; position: absolute; right: 0; bottom: 0; cursor: n-resize; } .note .resizer-both { width: 5px; height: 5px; background: transparent; z-index: 10; position: absolute; right: 0; bottom: 0; cursor: nw-resize; } .note .text { height: 100%; width: 100%; overflow: auto; } .note .text:hover { cursor: text; } .note .text *:not(:first-child) { margin-top: 15px; } .note .text hr { border: 0; border-top: 1px solid #000; } .note.black .text hr { border-color: #FAFAFA } .note .text hr:not(:first-child) { margin: 20px 0 !important; } .note .text ul, .note .text ol { padding-left: 10px; margin-left: 10px; } .note .text li { margin-top: 8px !important; } .note .text h1:not(:first-child), .note .text h2:not(:first-child) { padding: 10px 0; } .note .text h3:not(:first-child), .note .text h4:not(:first-child) { padding: 5px 0; } .note .text h5:not(:first-child), .note .text h6:not(:first-child) { padding: 3px 0; } .note .text h1 { font-size: 2.5em; } .note .text h2 { font-size: 2em; } .note .text h3 { font-size: 1.6em; } .note .text h4 { font-size: 1.3em; } .note .text h5 { font-size: 1.2em; } .note .text h6 { font-size: 1.1em; } .note .text a { color: #4ECDC4; border-bottom: 1px solid #4ECDC4; text-decoration: none; } .note.danger .text a { color: #FAFAFA; border-color: #EEE; } .note .text a:visited, .note .text a:active, .note .text a:hover { color: #3DBCB3; border-bottom: 1px solid #3DBCB3; } .note.danger .text a:visited, .note.danger .text a:active, .note.danger .text a:hover { color: #CCC; border-color: #CCC; } .note .text blockquote { padding: 10px 20px; margin: 0 0 20px; border-left: 5px solid #CCC; width: 90%; } .note.black .text blockquote { background: #222; color: #FAFAFA; border-color: #FAFAFA; } .note.primary .text blockquote { border-color: #FAFAFA; } .note.warning .text blockquote { border-color: #333; } .note.danger .text blockquote { border-color: #333; } .note .text code { background: #FAFAFA; border: 1px solid #333; padding: 0 2px; margin: 0 2px; } .note.white .text code { background: #EEE; } .note.black .text code { background: #FAFAFA; color: #222; border-color: #EEE; } .note.primary .text code { background: #FAFAFA; } .note.warning .text code { background: #FAFAFA; } .note.danger .text code { background: #FAFAFA; } .note .text pre { display: block; padding: 10px; margin: 0 0 10px; width: 95%; color: #333; word-break: break-all; word-wrap: break-word; background: #FAFAFA; border: 1px solid #CCC; border-radius: 4px; } .note.white .text pre { background: #EEE; } .note.black .text pre { background: #FAFAFA; color: #222; } .note.primary .text pre { background: #FAFAFA; } .note.warning .text pre { background: #FAFAFA; } .note.danger .text pre { background: #FAFAFA; } .note .text pre code { background: #FAFAFA; border: none; padding: 0; } .note .text table { display: table; width: 90%; border-spacing: 0; border-collapse: collapse; border: 1px solid #CCC; background: #FAFAFA; } .note.white .text table { background: #EEE; } .note.black .text table { background: #FAFAFA; color: #333; } .note.primary .text table { background: #FAFAFA; color: #333; } .note.warning .text table { background: #FAFAFA; color: #333; } .note.danger .text table { background: #FAFAFA; color: #333; } .note .text thead { display: table-header-group; vertical-align: middle; border-color: inherit; } .note .text tr { display: table-row; vertical-align: inherit; border-color: inherit; } .note .text tr th, .note .text tr td { vertical-align: bottom; padding: 8px; vertical-align: top; border-top: 1px solid #CCC; } .note .text tr th { border-bottom: 2px solid #CCC; } .note .text img { display: inline-block; max-width: 33%; }