diff --git a/README.md b/README.md index a67a8631..9398da11 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ It cannot see, access nor modify, any other files in your Google Drive. 7. Use [<ins>Context menu</ins>](#context-menu) to transfer selected text to My Notes on other computers. 8. Use [<ins>Custom theme</ins>](#custom-theme) to customize the look of My Notes in any way as needed. 9. Drag and Drop selected text onto a note's name in the Sidebar to insert the text into the note. -10. Drag and Drop a TXT file anywhere in the bottom part of the Sidebar (the area with 3 icons) to import the file as a new note. +10. Drag and Drop a TXT or HTML file anywhere in the bottom part of the Sidebar (the area with 3 icons) to import the file as a new note. 11. Drag the Sidebar line to resize the Sidebar, double-click on the Sidebar line to restore the original Sidebar width. <br><br> diff --git a/package-lock.json b/package-lock.json index 91dc0460..e08c3a77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "license": "MIT", "dependencies": { "clsx": "^1.1.1", + "fflate": "^0.7.1", "preact": "10.5.14" }, "devDependencies": { @@ -2991,6 +2992,11 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.1.tgz", + "integrity": "sha512-VYM2Xy1gSA5MerKzCnmmuV2XljkpKwgJBKezW+495TTnTCh1x5HcYa1aH8wRU/MfTGhW4ziXqgwprgQUVl3Ohw==" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -8329,6 +8335,11 @@ "bser": "2.1.1" } }, + "fflate": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.1.tgz", + "integrity": "sha512-VYM2Xy1gSA5MerKzCnmmuV2XljkpKwgJBKezW+495TTnTCh1x5HcYa1aH8wRU/MfTGhW4ziXqgwprgQUVl3Ohw==" + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", diff --git a/package.json b/package.json index 03e7895f..8114a15d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "clsx": "^1.1.1", + "fflate": "^0.7.1", "preact": "10.5.14" } } diff --git a/src/notes.tsx b/src/notes.tsx index 39ed7b99..d5483774 100644 --- a/src/notes.tsx +++ b/src/notes.tsx @@ -38,6 +38,7 @@ import { sendMessage } from "messages"; import notesHistory from "notes/history"; import keyboardShortcuts, { KeyboardShortcut } from "notes/keyboard-shortcuts"; import { Command, commands } from "notes/commands"; +import { exportNote } from "notes/export"; const getFocusOverride = (): boolean => new URL(window.location.href).searchParams.get("focus") === ""; const getActiveFromUrl = (): string => new URL(window.location.href).searchParams.get("note") || ""; // Bookmark @@ -628,10 +629,14 @@ const Notes = (): h.JSX.Element => { }); }, locked: notesProps.notes[noteName].locked ?? false, - toggleLocked: (noteName) => { + onToggleLocked: (noteName) => { setContextMenuProps(null); tabId && notesRef.current && setLocked(noteName, !(notesProps.notes[noteName].locked ?? false), tabId, notesRef.current); }, + onExport: (noteName) => { + setContextMenuProps(null); + exportNote(noteName); + }, })} onNewNote={() => onNewNote()} sync={sync} diff --git a/src/notes/components/ContextMenu.tsx b/src/notes/components/ContextMenu.tsx index 3f84df55..6fbbb297 100644 --- a/src/notes/components/ContextMenu.tsx +++ b/src/notes/components/ContextMenu.tsx @@ -9,11 +9,12 @@ export interface ContextMenuProps { onRename: (noteName: string) => void onDelete: (noteName: string) => void locked: boolean - toggleLocked: (noteName: string) => void + onToggleLocked: (noteName: string) => void + onExport: (noteName: string) => void } const ContextMenu = ({ - noteName, x, y, onRename, onDelete, locked, toggleLocked, + noteName, x, y, onRename, onDelete, locked, onToggleLocked, onExport, }: ContextMenuProps): h.JSX.Element => { const [offsetHeight, setOffsetHeight] = useState<number>(0); const ref = useRef<HTMLDivElement>(null); @@ -40,7 +41,8 @@ const ContextMenu = ({ }}> <div class={clsx("action", locked && "disabled")} onClick={() => !locked && onRename(noteName)}>Rename</div> <div class={clsx("action", locked && "disabled")} onClick={() => !locked && onDelete(noteName)}>Delete</div> - <div class="action" onClick={() => toggleLocked(noteName)}>{locked ? "Unlock" : "Lock"}</div> + <div class="action" onClick={() => onToggleLocked(noteName)}>{locked ? "Unlock" : "Lock"}</div> + <div class="action" onClick={() => onExport(noteName)}>Export</div> </div> ); }; diff --git a/src/notes/export/index.ts b/src/notes/export/index.ts new file mode 100644 index 00000000..3d9c7328 --- /dev/null +++ b/src/notes/export/index.ts @@ -0,0 +1,31 @@ +import { zipSync, Zippable } from "fflate"; +import { NotesObject } from "shared/storage/schema"; +import { downloadBlob } from "shared/download"; + +export const exportNote = (noteName: string): void => chrome.storage.local.get("notes", (local) => { + const { notes } = local as { notes: NotesObject }; + if (!(noteName in notes)) { + return; // there is no note to export + } + + const encoder = new TextEncoder(); + const data = encoder.encode(notes[noteName].content); + + downloadBlob(data, `${noteName}.html`, "text/html"); +}); + +export const exportNotes = (): void => chrome.storage.local.get("notes", (local) => { + const { notes } = local as { notes: NotesObject }; + if (!Object.keys(notes).length) { + return; // there are no notes to export + } + + const encoder = new TextEncoder(); + const data: Zippable = Object.keys(notes).reduce((acc, curr) => { + acc[`${curr}.html`] = encoder.encode(notes[curr].content); + return acc; + }, {} as Zippable); + + const gzipped = zipSync(data, { level: 0 }); + downloadBlob(gzipped, "notes.zip", "application/zip"); +}); diff --git a/src/notes/import/index.ts b/src/notes/import/index.ts index 1c24f4f7..dee19e66 100644 --- a/src/notes/import/index.ts +++ b/src/notes/import/index.ts @@ -1,7 +1,12 @@ import { readFile } from "./read-file"; +const SUPPORTED_FILE_TYPES = [ + "text/plain", + "text/html", +]; + export const importNoteFromTxtFile = (file: File, callback: () => void): void => { - if (!file.type.match("text/plain")) { + if (!SUPPORTED_FILE_TYPES.includes(file.type)) { callback(); return; } diff --git a/src/options.tsx b/src/options.tsx index ffcf563e..29b169f3 100644 --- a/src/options.tsx +++ b/src/options.tsx @@ -6,11 +6,13 @@ import __Size from "options/Size"; import __Theme from "options/Theme"; import __KeyboardShortcuts from "options/KeyboardShortcuts"; import __Options from "options/Options"; +import __Export from "options/Export"; import __Version from "options/Version"; import { Os, Storage, + NotesObject, RegularFont, GoogleFont, Theme, @@ -21,6 +23,7 @@ import { setTheme as setThemeCore } from "themes/set-theme"; const Options = (): h.JSX.Element => { const [os, setOs] = useState<Os | undefined>(undefined); const [version] = useState<string>(chrome.runtime.getManifest().version); + const [notesCount, setNotesCount] = useState<number>(0); const [font, setFont] = useState<RegularFont | GoogleFont | undefined>(undefined); const [size, setSize] = useState<number>(0); const [theme, setTheme] = useState<Theme | undefined>(undefined); @@ -34,6 +37,7 @@ const Options = (): h.JSX.Element => { chrome.runtime.getPlatformInfo((platformInfo) => setOs(platformInfo.os === "mac" ? "mac" : "other")); chrome.storage.local.get([ + "notes", "font", "size", "theme", @@ -45,6 +49,7 @@ const Options = (): h.JSX.Element => { ], items => { const local = items as Storage; + setNotesCount(Object.keys(local.notes).length); setFont(local.font); setSize(local.size); setTheme(local.theme); @@ -60,6 +65,12 @@ const Options = (): h.JSX.Element => { return; } + if (changes["notes"]) { + const newValue: NotesObject = changes["notes"].newValue; + const newNotesCount = Object.keys(newValue).length; + setNotesCount(newNotesCount); + } + if (changes["font"]) { setFont(changes["font"].newValue); } @@ -116,6 +127,7 @@ const Options = (): h.JSX.Element => { tab={tab} tabSize={tabSize} /> + <__Export canExport={notesCount > 0} /> <__Version version={version} /> </Fragment> ); diff --git a/src/options/Export.tsx b/src/options/Export.tsx new file mode 100644 index 00000000..5b2b1915 --- /dev/null +++ b/src/options/Export.tsx @@ -0,0 +1,31 @@ +import { h, Fragment } from "preact"; +import clsx from "clsx"; +import { exportNotes } from "notes/export"; + +let locked = false; + +interface ExportProps { + canExport: boolean +} + +const Export = ({ canExport }: ExportProps): h.JSX.Element => ( + <Fragment> + <h2>Export</h2> + <input + type="button" + class={clsx("bold", "button", !canExport && "disabled")} + value="Export all notes" + onClick={() => { + if (!canExport || locked) { + return; + } + + locked = true; + exportNotes(); + window.setTimeout(() => locked = false, 1000); + }} + /> + </Fragment> +); + +export default Export; diff --git a/src/options/Font.tsx b/src/options/Font.tsx index 2979cedd..3a15f0f5 100644 --- a/src/options/Font.tsx +++ b/src/options/Font.tsx @@ -88,6 +88,7 @@ const Font = ({ font }: FontProps): h.JSX.Element => { <input type="text" placeholder="Font Name (E.g. Roboto Mono)" + class="input" value={googleFontName} onInput={(event) => { setGoogleFontName((event.target as HTMLInputElement).value); @@ -96,8 +97,7 @@ const Font = ({ font }: FontProps): h.JSX.Element => { /> <input type="submit" - id="submit" - class={clsx("bold", (googleFontName !== font?.name) && "active")} + class={clsx("bold", "button", (googleFontName === font?.name) && "disabled")} value={googleSubmitButtonText} onClick={() => { const trimmedGoogleFontName = googleFontName.trim(); diff --git a/src/options/Options.tsx b/src/options/Options.tsx index 3ee27e2c..1d501d06 100644 --- a/src/options/Options.tsx +++ b/src/options/Options.tsx @@ -94,7 +94,7 @@ const Options = ({ sync, autoSync, tab, tabSize }: OptionsProps): h.JSX.Element </div> <div class={clsx("space-top", !tab && "disabled")}> <label>Tab size:</label> - <select name="tab-size" value={tabSize} onChange={(event) => { + <select name="tab-size" class="select" value={tabSize} onChange={(event) => { const newTabSize: number = parseInt((event.target as HTMLSelectElement).value); chrome.storage.local.set({ tabSize: newTabSize }); }}> diff --git a/src/shared/download/index.ts b/src/shared/download/index.ts new file mode 100644 index 00000000..2986b3db --- /dev/null +++ b/src/shared/download/index.ts @@ -0,0 +1,17 @@ +export const downloadURL = (data: string, fileName: string): void => { + const a = document.createElement("a"); + a.href = data; + a.download = fileName; + a.click(); + a.remove(); +}; + +export const downloadBlob = (data: Uint8Array, fileName: string, mimeType: string): void => { + const blob = new Blob([data], { + type: mimeType, + }); + + const url = window.URL.createObjectURL(blob); + downloadURL(url, fileName); + window.setTimeout(() => window.URL.revokeObjectURL(url), 2000); +}; diff --git a/static/options.css b/static/options.css index c44e5155..cbddd36a 100644 --- a/static/options.css +++ b/static/options.css @@ -23,13 +23,38 @@ input[type="radio"], input[type='checkbox'] { margin: 0 10px 0 2px; } -select { border-radius: 3px; } - .separator { padding: 0 12px; } .bold { font-weight: bold; } .space-top { margin-top: 1em; } .space-left { margin-left: 1em; } +.disabled { + opacity: .3; + user-select: none; + pointer-events: none; +} + +/* Inputs */ + +.button { + cursor: pointer; +} + +.button, .input, .select { + max-width: 500px; + box-sizing: content-box; + outline: none; + border: 1px solid silver; + padding: 12px; + border-radius: 3px; +} + +#dark .button, +#dark .input, +#dark .select { + border-color: transparent; +} + /* Selection */ .selection { padding: 10px 0; } @@ -65,9 +90,6 @@ body#dark .comment { .font-category { cursor: pointer; } .font-category.active { text-decoration: underline; } -#submit { opacity: .3; } -#submit.active { opacity: 1; cursor: pointer; } - .font-area .selection { font-size: 80%; display: flex; @@ -82,26 +104,14 @@ body#dark .comment { #google-fonts-area ol { margin-top: 0; } #google-fonts-area ol li { line-height: 2em; } -#google-fonts-area input, select { - max-width: 500px; - box-sizing: content-box; - outline: none; - border: 1px solid silver; - padding: 12px; -} - -#dark #google-fonts-area input, select { - border-color: transparent; +#google-fonts-area .input { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } -#google-fonts-area input[type="text"] { - border-top-left-radius: 3px; - border-top-right-radius: 3px; -} - -#google-fonts-area input[type="submit"] { - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; +#google-fonts-area .button { + border-top-left-radius: 0; + border-top-right-radius: 0; } /* Font size */ @@ -189,16 +199,6 @@ body#dark .comment { word-break: break-all; } -div.disabled { - opacity: .3; - user-select: none; -} - -div.disabled input, -div.disabled select { - pointer-events: none; -} - /* Media Queries */ @media only screen and (max-width: 600px) {