├── .github └── workflows │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── lib └── emoji-magic │ ├── src │ ├── app_data │ │ ├── emoji.js │ │ ├── emoji.test.js │ │ ├── emoji_data.js │ │ ├── emoji_data.test.js │ │ └── emojilib_thesaurus.js │ ├── app_ui │ │ ├── emoji_details.css │ │ ├── emoji_details.css.map │ │ ├── emoji_details.js │ │ ├── emoji_details.sass │ │ ├── emoji_picker.css │ │ ├── emoji_picker.css.map │ │ └── emoji_picker.sass │ ├── bind_to_dom.js │ ├── browser_action.html │ ├── emoji.html │ ├── icons │ │ ├── crystal-ball-1f52e.svg │ │ ├── emoji-magic assets.sketch │ │ │ ├── Data │ │ │ ├── metadata │ │ │ └── version │ │ ├── emoji-magic screenshots.sketch │ │ │ ├── Data │ │ │ ├── metadata │ │ │ └── version │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── icon128.png │ │ ├── icon16.png │ │ ├── icon19.png │ │ ├── icon48.png │ │ ├── promo-tile-large.png │ │ ├── promo-tile-marquee.png │ │ ├── promo-tile-small.png │ │ ├── promo-tile.png │ │ ├── purple.png │ │ └── yellow.png │ ├── index.html │ ├── js_utils │ │ ├── code_points.js │ │ ├── code_points.test.js │ │ ├── emulate_node_modules.js │ │ ├── matchers.js │ │ ├── matchers.test.js │ │ └── store.js │ └── site │ │ ├── site.css │ │ ├── site.css.map │ │ └── site.sass │ └── third_party │ └── emojilib │ ├── LICENSE │ ├── data-by-emoji.json │ ├── emoji-en-US.json │ └── emojilib.js ├── manifest.json ├── package.json ├── rollup.config.js ├── screenshots ├── emoji-magic-blue.png ├── emoji-magic-obsidian-1.gif └── emoji-magic-obsidian-2.gif ├── src ├── cfg.ts ├── interfaces.ts ├── main.ts ├── search_modal.ts └── search_modal_suggest_variant.ts ├── styles.css └── version-bump.mjs /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-emoji-magic # Change this to match the id of your plugin. 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "14.x" 21 | 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | 45 | - name: Upload zip file 46 | id: upload-zip 47 | uses: actions/upload-release-asset@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.create_release.outputs.upload_url }} 52 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 53 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 54 | asset_content_type: application/zip 55 | 56 | - name: Upload main.js 57 | id: upload-main 58 | uses: actions/upload-release-asset@v1 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | upload_url: ${{ steps.create_release.outputs.upload_url }} 63 | asset_path: ./main.js 64 | asset_name: main.js 65 | asset_content_type: text/javascript 66 | 67 | - name: Upload manifest.json 68 | id: upload-manifest 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./manifest.json 75 | asset_name: manifest.json 76 | asset_content_type: application/json 77 | 78 | - name: Upload styles.css 79 | id: upload-css 80 | uses: actions/upload-release-asset@v1 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | with: 84 | upload_url: ${{ steps.create_release.outputs.upload_url }} 85 | asset_path: ./styles.css 86 | asset_name: styles.css 87 | asset_content_type: text/css -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | data.json 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows 28 | [Google's Open Source Community Guidelines](https://opensource.google.com/conduct/). -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This plugin lets you add emoji to your Obsidian notes more easiy using an enhanced keyword search. 2 | 3 | It has a large dictionary of keywords so you can find emoji you wouldn't find otherwise: 4 | 5 | * broad terms like "car" -> `🚓🚋🚔🏎️🚐🚕🚖` 6 | * colors like "orange" -> `📙🧡🍊🥕🚼` 7 | 8 | Animated Screenshot: 9 | 10 | ![gif in action](./screenshots/emoji-magic-obsidian-2.gif?raw=true) 11 | 12 | Still Screenshot: 13 | 14 | ![screenshot](./screenshots/emoji-magic-blue.png?raw=true) 15 | 16 | ## Features 17 | 18 | 1. Secure -- local only, no internet 19 | 2. Rich keyword search -- the dictionary contains `1,812` emoji with `199,658` searchable keywords and thesaurus entries. 20 | 3. Fast -- just keyboard shortcut and click 21 | 4. Keyboard friendly -- arrow keys, tab, however you want. 22 | 23 | > Note: This is an [Obsidian](https://obsidian.md/)-compatible fork of the [Emoji Magic](https://github.com/SimplGy/emoji-magic) Chrome Extension I wrote. 24 | 25 | ## Installing this Plugin 26 | 27 | 1. Open settings. (if you haven't yet, one time: Third party plugin -> Disable Safe mode) 28 | 1. Click "Browse community plugins" -> Search for "Magic File Hotkey" 29 | 1. Install it, then click "enable" 30 | 1. Add a hotkey. I like `cmd + shift + e` ("e" for "emoji"). 31 | 32 | ## Guiding Principles 33 | > Goals and Non-goals 34 | 35 | * Easy find 36 | * an effort is made to include lots of possible matches 37 | * eg: the color "green" or the feeling "happy" 38 | * Keyboard friendly. 39 | * Eg: `keyboard shortcut` -> `search phrase` -> `enter` and done. 40 | * Because this plugin shows a 2d grid of emoji, built two-dimensional arrow key support for navigating. 41 | * Actual emoji. No images. 42 | * This means: no custom emoji 43 | * Also means: will render platform-appropriate versions. The visual you see can vary depending on where you're viewing the file 44 | * Also means: you may see empty rectangles for emoji that are defined, but not supported by your device. (eg: 🦩 "flamingo" won't be there if you're on an older Mac) 45 | 46 | ## Similar Obsidian Plugins 47 | 48 | > AKA: "why did I need to build this?" 49 | 50 | * [Emoji Shortcodes](https://github.com/phibr0/obsidian-emoji-shortcodes) 51 | * Excellent plugin, seems to work great, but I like having a popup search panel instead of using the `:smile:` kind of syntax 52 | * [Emoji Toolbar](https://github.com/oliveryh/obsidian-emoji-toolbar) 53 | * Currently the most popular "emoji" plugin for Obsidian 54 | * Uses images instead of the text emoji char itself. That means some emoji can look different in the picker VS when I actually insert them in my file. There is a setting that might be related, but I wasn't able to get it to work. (Update: appears fixed in [v0.4.0](https://github.com/oliveryh/obsidian-emoji-toolbar/releases/tag/0.4.0)) 55 | * Started breaking for me (might be a "live preview" only bug). Would insert emoji at the start of the file instead of where my cursor is. (Update: appears fixed in [v0.4.0](https://github.com/oliveryh/obsidian-emoji-toolbar/releases/tag/0.4.0) or earlier) 56 | 57 | 58 | 59 | ## Developing this Plugin 60 | 61 | ### Building 62 | 63 | ``` 64 | # npm install 65 | npm run dev 66 | ``` 67 | 68 | (for auto refreshing) install `git clone https://github.com/pjeby/hot-reload.git` and turn it on 69 | 70 | ### (one time) symlink from dev environment to Obsidian plugin dir 71 | 72 | right click on the obsidian plugins folder, "new terminal at folder". then: 73 | 74 | ``` 75 | ln -s /Users/eric/Projects/obsidian-emoji-magic obsidian-emoji-magic 76 | ``` 77 | 78 | ### (rarely) Sync with the upstream project 79 | 80 | ``` 81 | rm -rf lib/emoji-magic 82 | git clone https://github.com/SimplGy/emoji-magic.git lib/emoji-magic 83 | ``` 84 | 85 | ### Releasing 86 | 87 | 1. Update the version in `package.json` (only) 88 | 2. `npm run version` 89 | 90 | > This will trigger `.github/workflows/release.yml`. 91 | > 92 | > Verify the workflow is running [here](https://github.com/SimplGy/obsidian-emoji-magic/actions). 93 | > Verify [releases here](https://github.com/SimplGy/obsidian-emoji-magic/releases) 94 | 95 | (you're done) simply doing a github release and running release.yml will make the new version of the plugin available on the Obsidian marketplace. Nice! 96 | 97 | 98 | 99 | ## TODO 100 | PRs welcome. 101 | 102 | - [x] change from 3 -> 2 chars required to see search results, and blank out defaults during inital typing so it doesn't look like there's a bug 103 | - [ ] improve startup time -- 2023-03-18: took a look, but not seeing anything obvious here. I think I have to find a way to defer parsing of the large `emojilib_thesaurus.js` file. AFAIK there is no actual processing happening at startup, it's just JS parse time dragging things out. 104 | - [ ] solve the `zwj` problem (eg: "plane") 105 | - [ ] (upstream) improve some of the ranking (car, plane) and 106 | - [ ] (upstream) fix the lack of "stemming" problem (eg: "race car") 107 | - [ ] (upstream) adapt the headless stuff to be easier to reuse. 108 | 109 | ## Future 110 | - [ ] consider using Obsidian's native [SuggestModal](https://marcus.se.net/obsidian-plugin-docs/reference/typescript/classes/SuggestModal) or [FuzzySuggestModal](https://marcus.se.net/obsidian-plugin-docs/reference/typescript/classes/FuzzySuggestModal) -- https://marcus.se.net/obsidian-plugin-docs/user-interface/modals -- I thought this couldn't work with a grid UI, but there's [prior art here](https://github.com/oliveryh/obsidian-emoji-toolbar/commit/1b8f7624f575cb183271a3d969ee5939c4763f8a) 111 | 112 | 113 | 114 | 115 | ## Contributing 116 | 117 | Contributions welcome. See CONTRIBUTING.md. 118 | 119 | ## Disclaimer 120 | 121 | This is not an officially supported Google product. 122 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_data/emoji.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | const {toChar, toObj, array} = require('./emoji_data'); 18 | const store = require('../js_utils/store'); 19 | const {minPrefixOverlapsByWordSet} = require('../js_utils/matchers'); 20 | const {fromCodePoints, toCodePoints} = require('../js_utils/code_points'); // re-exporting these for convenience 21 | 22 | 23 | 24 | module.exports = (() => { 25 | const CLIPBOARD_CLEAR_DELAY = 1000; // how long to leave the psuedo-clipboard content in place before clearing. (this user visible "clipboard" is what enables clicking three times in a row to copy a set of three emoji) 26 | const CLOSE_POPUP_DELAY = 800; // How long to wait after a successful copy before closing the browser extension popup 27 | const ANIMATION = 'tada'; // must match a css class 28 | const TADA_ANIM_DURATION = 400; // should match css animation duration 29 | const SHOW_COPIED_MSG_DURATION = 600; // how long to show the "copied" message (on the button) 30 | const RECENT_KEY = 'recent-selections'; 31 | const RESULT_LIMIT = 8 * 15; // for render perf, don't draw everything. 15 rows fit in Chrome's 600px height limit for default font size/zoom settings. 32 | const RECENT_SELECTION_LIMIT = 8 * 1; // at the default font size, there are 8 per row 33 | const DEFAULT_RESULTS = ['🔮','🎩','✨','🐇']; 34 | let recentSelections = []; // in memory store of recent selections. format is plain chars, not objects 35 | store.get(RECENT_KEY, val => recentSelections = val || []); // seed the recentSelections 36 | let willClearClipboard; // reference to the timeout that will clear this 37 | 38 | const $ = { 39 | results: () => document.getElementById('results'), 40 | clipboard: () => document.getElementById('clipboard'), 41 | copyBtn: () => document.getElementById('copyBtn'), 42 | search: () => document.getElementById('search'), 43 | }; 44 | 45 | // from David Walsh 46 | function debounce(func, wait, immediate) { 47 | var timeout; 48 | return function() { 49 | var context = this, args = arguments; 50 | var later = function() { 51 | timeout = null; 52 | if (!immediate) func.apply(context, args); 53 | }; 54 | var callNow = immediate && !timeout; 55 | clearTimeout(timeout); 56 | timeout = setTimeout(later, wait); 57 | if (callNow) func.apply(context, args); 58 | }; 59 | }; 60 | 61 | // Given any number of arrays, return the smallest intersection 62 | // No order is guaranteed 63 | // Does not mutate input arrays 64 | function intersection(...arrays) { 65 | return arrays.reduce((acc, cur) => 66 | acc.filter(p => cur.includes(p)) 67 | ); 68 | } 69 | 70 | // Given an array of arrays, return a flat array. Depth 1 71 | function flatten(arr) { 72 | return arr.reduce((acc, val) => acc.concat(val), []); 73 | } 74 | 75 | 76 | // add recent selection, but only track the most recent k 77 | // also deduplicates: if it's already present, remove matches first before putting the most recent at the front 78 | function trackRecent(char) { 79 | recentSelections = recentSelections.filter(c => c !== char); 80 | recentSelections.unshift(char); 81 | if (recentSelections.length > RECENT_SELECTION_LIMIT) { 82 | recentSelections = recentSelections.slice(0, RECENT_SELECTION_LIMIT); 83 | } 84 | store.set(RECENT_KEY, recentSelections); 85 | } 86 | 87 | // Main search method. 88 | // Given a search string, return an array of matching emoji objects 89 | // The data is usually pre-bound but you can provide your own data set as well (eg: testing) 90 | // eg: options.useThesaurus is not used today (always match the thesaurus) 91 | const searchOn = (data = []) => (str = '', options) => { 92 | str = str.trim(); 93 | 94 | // Blank search? Exit early. 95 | if (str === '') { 96 | const results = recentSelections.length > 0 ? recentSelections : DEFAULT_RESULTS; 97 | return results.map(toObj); 98 | } 99 | 100 | const matchesByStrength = data 101 | .map(r => [ 102 | r, 103 | matchStrengthFor(r, str), 104 | ]) 105 | // Anything above zero counts as a match (either keywords or thesaurus): 106 | .filter(r => r[1] > 0) 107 | // Sort by the match strength 108 | .sort((a, b) => b[1] - a[1]) 109 | // Drop the match strength vector from the response (plain emoji objects): 110 | .map(r => r[0]); 111 | 112 | // TODO: make it obvious which words are matching, so it's not confusing why unrelated-seeming results appear 113 | // console.log(matchesByStrength.map(({char, match}) => char + ' ' + match)); 114 | 115 | return matchesByStrength; 116 | }; 117 | 118 | function htmlForAllEmoji(emojiArray = []) { 119 | const html = emojiArray.map(htmlForEmoji).join('\n'); 120 | return html 121 | } 122 | 123 | function htmlForEmoji(emoji) { 124 | const char = toChar(emoji); 125 | return `
  • `; 126 | } 127 | 128 | // Take an emoji object and summarize it as a multiline string (useful for tooltips) 129 | function summaryString(emoji) { 130 | const { keywords = [], thesaurus = []} = emoji 131 | return `${emoji.char} ${emoji.name}\n\n` + 132 | 133 | `keywords: ${keywords.join(', ')}\n` + 134 | `version (emoji/unicode): ${emoji.emoji_version} / ${emoji.unicode_version}\n\n` + 135 | 136 | thesaurus 137 | .filter(arr => arr.length > 0) 138 | .map((arr, idx) => `${keywords[idx]}:\n${arr.join(', ')}`).join('\n\n') 139 | } 140 | 141 | // Dom aware 142 | // Tasks: Put result html into container, update disabled state of btn 143 | function render(emoji = []) { 144 | emoji = emoji.slice(0, RESULT_LIMIT); 145 | $.results().innerHTML = htmlForAllEmoji(emoji); 146 | if (emoji.length === 0) { 147 | $.copyBtn().setAttribute('disabled', true); 148 | } else { 149 | $.copyBtn().removeAttribute('disabled'); 150 | } 151 | } 152 | 153 | // Dom aware 154 | function onPressEmoji(char) { 155 | $.clipboard().value += char; 156 | 157 | // Every so often, clear this "clipboard" 158 | clearTimeout(willClearClipboard); 159 | willClearClipboard = setTimeout(clearClipboard, CLIPBOARD_CLEAR_DELAY); 160 | 161 | trackRecent(char); 162 | copyToClipboard(); 163 | } 164 | 165 | // Dom aware 166 | // true if there are any items in the tray ready to copy 167 | function clipboardHasStuff() { 168 | return $.clipboard().value.trim().length > 0; 169 | } 170 | 171 | function clearClipboard() { 172 | $.clipboard().value = ''; 173 | } 174 | 175 | // true if there are no results in dom right now 176 | function noResults() { 177 | return $.results().hasChildNodes() 178 | } 179 | 180 | // Dom aware 181 | function copyToClipboard({instant = false} = {}) { 182 | if (!clipboardHasStuff()) return; 183 | 184 | // Select the text and copy, then return focus to where it was 185 | const focusedEl = document.activeElement; // what used to be focused? make sure to reselect it. 186 | $.clipboard().select(); 187 | document.execCommand('copy'); 188 | focusedEl.focus(); 189 | 190 | // Animate success and close popup 191 | if (instant) { 192 | animateCopySuccess(); 193 | closePopup(); 194 | } else { 195 | animateCopySuccessDebounced(); 196 | setTimeout(closePopup, CLOSE_POPUP_DELAY); 197 | } 198 | } 199 | 200 | const animateCopySuccessDebounced = debounce(animateCopySuccess, 200); 201 | 202 | function animateCopySuccess() { 203 | const $copyBtn = document.getElementById('copyBtn'); 204 | $copyBtn.innerText = "Copied"; 205 | $copyBtn.classList.add(ANIMATION); 206 | $copyBtn.classList.add('copied'); 207 | setTimeout(() => $copyBtn.classList.remove(ANIMATION), TADA_ANIM_DURATION); 208 | setTimeout(() => $copyBtn.innerText = "Copy", SHOW_COPIED_MSG_DURATION); 209 | } 210 | 211 | // Close the popup, but not if: 212 | // 1) we're in a unit test (node) 213 | // 2) We're on the demo/webapp, rather than in the Chrome browser_popup 214 | function closePopup() { 215 | if (typeof window.close === 'function' && chrome.browserAction) { 216 | window.close(); 217 | } 218 | } 219 | 220 | function copyFirstEmoji() { 221 | const btn = $.results().querySelector('button'); // get the first button 222 | if (!btn) return; 223 | const char = btn.innerText; 224 | $.clipboard().value = char; 225 | copyToClipboard({instant: true}); 226 | } 227 | 228 | // Move focus horizontally. +1 is right, -1 is left. 229 | // wraps around 230 | // boundary protection works because focusOn just exits if it doesn't find an el to focus 231 | // really really dom coupled 232 | function moveFocusX(direction = 0) { 233 | const wasFromSearch = document.activeElement === $.search(); 234 | if (wasFromSearch) return; // don't do custom arrow behavior if we're in the search box already 235 | const curLi = document.activeElement.parentElement; 236 | const idx = Array.from($.results().children).indexOf(curLi); 237 | focusOn(idx + direction); 238 | } 239 | 240 | // +1 is down, -1 is up 241 | function moveFocusY(direction = 0) { 242 | 243 | // special case: moving down from search 244 | const wasFromSearch = document.activeElement === $.search(); 245 | if(wasFromSearch) { 246 | if (direction > 0) { 247 | focusOn(0); 248 | } 249 | return; // don't do custom arrow behavior if we're in the search box already 250 | } 251 | 252 | let el = document.activeElement.parentElement; 253 | const left = el.offsetLeft; 254 | const top = el.offsetTop; 255 | let prevEl; 256 | 257 | // if we're going down a row... 258 | if (direction > 0) { 259 | // 1. look forward until we find something with a higher offsetTop (first el of next row) 260 | while(el && el.offsetTop <= top) { 261 | prevEl = el; 262 | el = el.nextElementSibling; 263 | } 264 | 265 | // 2. look forward until we find something >= the current offsetLeft (same col, or close) 266 | while (el && el.offsetLeft < left) { 267 | prevEl = el; 268 | el = el.nextElementSibling; 269 | } 270 | 271 | // another approach: just look for the next element with nearly the same left position, it'll be in the next row. 272 | // I like the way the current one handles sparse rows though. 273 | 274 | // if we're going up a row... 275 | } else if (direction < 0) { 276 | // 1. look backward until we find something with a lower offsetTop (last el of previous row) 277 | while(el && el.offsetTop >= top) { 278 | prevEl = el; 279 | el = el.previousElementSibling; 280 | } 281 | // 2. look backward until we find something <= the current offsetLeft 282 | while (el && el.offsetLeft > left) { 283 | prevEl = el; 284 | el = el.previousElementSibling; 285 | } 286 | } 287 | 288 | if (el) { 289 | focusOnButton(el); 290 | } else if(prevEl) { 291 | // if moving up and we didn't find a correct focus target, choose the search box 292 | if (direction < 0) { 293 | $.search().focus(); 294 | $.search().select(); 295 | } else { 296 | focusOnButton(prevEl); 297 | } 298 | } 299 | 300 | } 301 | 302 | // really really dom coupled 303 | function focusOn(idx) { 304 | const li = $.results().children[idx]; 305 | if (!li) return; 306 | focusOnButton(li); 307 | } 308 | 309 | function focusOnButton(el) { 310 | const btn = el.querySelector('button'); 311 | if (!btn) return; 312 | btn.focus(); 313 | } 314 | 315 | /* 316 | example emoji object: 317 | { 318 | "name": "grinning", 319 | "keywords": [ 320 | "face", 321 | "smile", 322 | "happy", 323 | "joy", 324 | ":D", 325 | "grin" 326 | ], 327 | "char": "😀", 328 | "category": "people", 329 | "thesaurus": [ 330 | ["human face","external body part"], 331 | ["expression","look"] 332 | ] 333 | } 334 | */ 335 | 336 | /* 337 | * For a given emoji object and query, return a numeric "strength" for the match. 338 | * It gets match strength for all the terms on 2 dimensions: 339 | * 1: Keywords 340 | * 2: Thesaurus 341 | * 342 | * It then averages the match strength on each dimension. 343 | * To simplify sorting, it returns the keyword match strength in standard order, 344 | * summed with the thesaurus match strength divided by 1000 345 | * eg: 1 number: `0.501` (indicating a 50% avg keyword match and a 100% avg thesaurus match) 346 | */ 347 | function matchStrengthFor(emojiObj = {}, query = '') { 348 | const [n, k, t] = computeMatchVectorForEmoji(emojiObj, query); 349 | 350 | // debug: log the match strength with nice readable output: 351 | // if (n + k + t > 0) { 352 | // console.log(`\nmatchStrengthFor('${emojiObj.char}', '${query}') name`, n) 353 | // console.log(`matchStrengthFor('${emojiObj.char}', '${query}') keyword`, k) 354 | // console.log(`matchStrengthFor('${emojiObj.char}', '${query}') thesaurus`, t) 355 | // } 356 | 357 | return n + k/10 + t/1000; // eg: 1.001 or 0.005 358 | } 359 | 360 | // returns a set like [0, 0, 0.25] representing match strength on three vectors: 361 | // [name, keywords, thesaurus] 362 | function computeMatchVectorForEmoji(emojiObj = {}, query = '') { 363 | const nameParts = emojiObj.nameParts; 364 | const keywords = emojiObj.keywords; 365 | const thesaurus = flatten(emojiObj.thesaurus); 366 | return minPrefixOverlapsByWordSet(query)([nameParts, keywords, thesaurus]); 367 | } 368 | 369 | 370 | 371 | return { 372 | __id__: 'emoji', 373 | // Key data method 374 | search: searchOn(array), 375 | // UI Methods 376 | render, 377 | onPressEmoji, 378 | copyToClipboard, 379 | copyFirstEmoji, 380 | closePopup, 381 | // re-exports 382 | toCodePoints, 383 | fromCodePoints, 384 | // Exported for test only: 385 | computeMatchVectorForEmoji, 386 | matchStrengthFor, 387 | htmlForAllEmoji, 388 | searchOn, 389 | 390 | // New exports: 391 | moveFocusX, 392 | moveFocusY, 393 | htmlForEmoji, 394 | summaryString, 395 | DEFAULT_RESULTS, 396 | RECENT_SELECTION_LIMIT, 397 | toObj, 398 | } 399 | })(); 400 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_data/emoji.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | const {array, toChars, toObj} = require('./emoji_data'); 18 | const store = require('../js_utils/store'); 19 | const emoji = require('./emoji'); 20 | 21 | function flatten(arr) { 22 | return arr.reduce((acc, val) => acc.concat(val), []); 23 | } 24 | 25 | 26 | 27 | describe("emoji.js", () => { 28 | 29 | // shorthand for doing an emoji search and converting the results to chars instead of objects 30 | s = (term) => toChars(emoji.search(term)); 31 | 32 | // expect that a term includes emojiChar somewhere in results 33 | expectSearchIncludes = (term, emojiChar) => { 34 | let result = s(term); 35 | expect(result).toContain(emojiChar); 36 | }; 37 | 38 | // expect that the top ranked result for a term is emojiChar 39 | expectFirstResult = (term, emojiChar) => { 40 | const result = s(term); 41 | expect(result[0]).toBe(emojiChar); 42 | }; 43 | 44 | describe("setup", () => { 45 | it("has prerequisites defined", () => { 46 | expect(store).toBeDefined(); 47 | expect(emoji).toBeDefined(); 48 | }); 49 | }); 50 | 51 | describe("emoji.search()", () => { 52 | it('returns an array of structured objects', () => { 53 | const result = emoji.search('diamond'); 54 | expect(result.length).toBeGreaterThanOrEqual(6); // might add more emoji. Never remove or invalidate keywords 55 | expect(toChars(result)).toContain('💍'); 56 | expect(result[0].nameParts.length).toBeGreaterThan(0); 57 | expect(result[0].keywords.length).toBeGreaterThan(0); 58 | expect(result[0].thesaurus.length).toBeGreaterThan(0); 59 | }); 60 | it("matches epected symbols for 'crystal'", () => { 61 | const result = s('crystal'); 62 | // WARNING: if you check length on a joined string result instead of this array, you'll probably see 4, not 2, because many emoji are multi-byte chars. 63 | expect(result).toContain('🔮'); 64 | expect(result).toContain('💠'); 65 | }); 66 | 67 | it("matches epected symbols for 'pepper'", () => { 68 | const topThreeResults = s('pepper').slice(0,3); 69 | expect(topThreeResults).toContain('🌶️'); 70 | }); 71 | 72 | it("matches other simple searches", () => { 73 | expect(s('green')).toContain('💚'); 74 | }); 75 | 76 | it("handles multi-word searches", () => { 77 | expectFirstResult('blue heart', '💙'); 78 | expectFirstResult(' heart blue ', '💙'); // funny spacing 79 | expectFirstResult('green ball', '🎾'); 80 | expectFirstResult('sad cat', '😿'); 81 | }); 82 | 83 | it("only matches prefixes", () => { 84 | const result = s('ice'); 85 | expect(result).not.toContain('👨‍💼'); // Shouldn't match "off[ice] worker" 86 | }); 87 | 88 | it("matches some common prefixes", () => { 89 | const result = s('fire'); 90 | expect(result).toContain('🔥'); // fire 91 | expect(result).toContain('🚒'); // fire engine 92 | expect(result).toContain('👩‍🚒'); // woman firefighter 93 | }); 94 | 95 | it("can match the second word of multi word emoji names like 'shaved ice'", () => { 96 | const emojiObj = toObj('🍧'); 97 | expect(emojiObj.name).toBe('shaved ice'); 98 | 99 | // even though it doesn't startWith "ice", because it's in the name, it matches 100 | expectSearchIncludes('ice', '🍧') 101 | }); 102 | 103 | it("does't just blindly match anywhere in multi word emoji names", () => { 104 | const emojiObj = toObj('🍧'); 105 | expect(emojiObj.name).toBe('shaved ice'); 106 | 107 | // even though it doesn't startWith "ice", because it's in the name, it matches 108 | expect(s('have')).not.toContain('🍧') // "have" does not match "shaved" 109 | }); 110 | }); 111 | 112 | describe("emoji.computeMatchVectorForEmoji(emojiObj, query)", () => { 113 | const subject = toObj('💚'); 114 | it('calculates a three component vector', () => { 115 | const result = emoji.computeMatchVectorForEmoji(subject, 'love green heart'); 116 | expect(result.length).toBe(3); 117 | }); 118 | it('finds "green heart" in keywords for "💚"', () => { 119 | const [n, k, t] = emoji.computeMatchVectorForEmoji(subject, 'green heart'); 120 | expect(n).toBe(1); // 100% match on name -- all of the input words match 100% of something in corpus 121 | }); 122 | it('finds "love" in "💚"', () => { 123 | const [n, k, t] = emoji.computeMatchVectorForEmoji(subject, 'love'); 124 | expect(k).toBeGreaterThan(0, 'keyword match strength'); 125 | expect(t).toBeGreaterThan(0, 'thesaurus match strength'); 126 | }); 127 | it('returns a zero score on "rutabaga" for "💚"', () => { 128 | const [n, k, t] = emoji.computeMatchVectorForEmoji(subject, 'rutabaga'); 129 | expect(n + k + t).toBe(0); 130 | }); 131 | }); 132 | 133 | describe("emoji.matchStrengthFor(emojiObj, query)", () => { 134 | // mock "emoji" data with known match percentages 135 | const subject = { 136 | char: 'z', 137 | nameParts: ['aaaaaaaa', 'cc'], 138 | keywords: ['aaaa', 'cc'], 139 | thesaurus: [ 140 | ['aa', 'cccccccc'], 141 | ['aaa', 'ccccc'] 142 | ], 143 | }; 144 | // Multi word query, so all words must match at least a little bit 145 | const query = 'aa cc'; 146 | 147 | it('computeMatchVectorForEmoji() returns expected vector strengths', () => { 148 | const [n, k, t] = emoji.computeMatchVectorForEmoji(subject, query); 149 | expect(n).toBe(0.25, 'name'); // "aa" is the weakest query term at "aa"/"aaaaaaaa" -- 25% match 150 | expect(k).toBe(0.5, 'keyword'); // "aa" is the weakest query term at "aa"/"aaaa" -- 50% match 151 | expect(t).toBe(0.4, 'thesaurus'); // "cc" is the weakest query term at "cc"/"ccccc" -- 40% match 152 | }); 153 | it('calculates match strength as expected', () => { 154 | // Test the vector combination "math" 155 | const strength = emoji.matchStrengthFor(subject, query); 156 | const name = 0.25; // known result, validated in test above 157 | const keyword = 0.5; // known result 158 | const thesaurus = 0.4; // known result 159 | expect(strength).toBe(name + keyword/10 + thesaurus/1000); // the keywords and thesaurus matches are downweighted so they sort lower 160 | }); 161 | }); 162 | 163 | describe("Thesaurus Matching", () => { 164 | 165 | it("finds things using thesaurus it otherwise wouldn't (eg: 😀)", () => { 166 | expectSearchIncludes('visage', '😀'); 167 | }); 168 | 169 | it("finds things using thesaurus it otherwise wouldn't (eg: 🥶)", () => { 170 | expectSearchIncludes('ice', '🥶'); 171 | 172 | }); 173 | 174 | it("finds things using thesaurus it otherwise wouldn't (eg: 🤬)", () => { 175 | expectSearchIncludes('tempestuous', '🤬'); 176 | }); 177 | 178 | it("finds synonyms for 'barf'", () => { 179 | expectSearchIncludes('sick', '🤮'); // this is the human entered, "canonical" keyword 180 | expectSearchIncludes('barf', '🤮'); 181 | expectSearchIncludes('puke', '🤮'); 182 | }); 183 | }); 184 | 185 | 186 | 187 | // -------------------------------------------- Reverse search. Do we see expected keywords and synonyms for a symbol? 188 | describe("Emoji Objects", () => { 189 | const sickEmoji = toObj('🤮'); 190 | it('has keywords', () => { 191 | expect(sickEmoji.keywords.length).toBeGreaterThanOrEqual(2); // '🤮' has some keywords 192 | }); 193 | it('has a reasonable looking thesaurus', () => { 194 | const sickThesaurus = flatten(sickEmoji.thesaurus); 195 | expect(sickThesaurus.length).toBeGreaterThan(80); 196 | // Has expected synonyms 197 | expect(sickThesaurus).toEqual(jasmine.arrayContaining(['afflicted','seasick','dizzy','unwell'])); 198 | // And not ones you wouldn't 199 | expect(sickThesaurus).not.toEqual(jasmine.arrayContaining(['giraffe','elephant'])); 200 | }); 201 | }); 202 | 203 | }); 204 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_data/emoji_data.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | const emojilib_thesaurus = require('./emojilib_thesaurus'); 17 | 18 | 19 | 20 | module.exports = ((global) => { 21 | 22 | // Utility functions 23 | 24 | // emoji object to char. O(1) 25 | const toChar = o => o.char; 26 | 27 | // char to emoji object. O(n) 28 | const toObj = char => emojilib_thesaurus.array.find(el => el.char === char); 29 | 30 | // Convert from an array of emoji objects to a plain char 31 | const toChars = (arr = []) => arr.map(toChar); 32 | 33 | return { 34 | toObj, 35 | toChar, 36 | toChars, 37 | array: emojilib_thesaurus.array, 38 | __id__: 'emoji_data', // emulated node modules 39 | }; 40 | })(this); -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_data/emoji_data.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | const emojiData = require('./emoji_data'); 18 | 19 | 20 | 21 | describe("emoji_data.js", () => { 22 | describe("data array", () => { 23 | it("has more than 1500 emojis", () => { 24 | expect(emojiData.array.length).toBeGreaterThan(1500); 25 | }); 26 | it("does not always copy the 'name' into 'keywords'", () => { 27 | const o = emojiData.toObj('💚'); 28 | expect(o.name).toBe('green heart'); 29 | expect(o.keywords).not.toContain('green'); 30 | expect(o.keywords).not.toContain('heart'); 31 | expect(o.keywords).not.toContain('green heart'); 32 | }); 33 | it("has the full_slug in 'keywords'", () => { 34 | const o = emojiData.toObj('💚'); 35 | expect(o.slug).toBe('green_heart'); 36 | expect(o.keywords).toContain('green_heart'); 37 | }); 38 | it("provides a special location for multi word names", () => { 39 | const o = emojiData.toObj('💚'); 40 | expect(o.name).toBe('green heart'); 41 | expect(o.nameParts).toContain('green'); 42 | expect(o.nameParts).toContain('heart'); 43 | }); 44 | // Validate this, because then the search weighting score would count it for both 45 | it("never has a name that exactly matches a keyword", () => { 46 | for (let emoji of emojiData.array) { 47 | for (let namePart of emoji.nameParts) { 48 | expect(emoji.keywords).not.toContain(namePart, emoji.char); 49 | } 50 | } 51 | const o = emojiData.toObj('💚'); 52 | expect(o.slug).toBe('green_heart'); 53 | expect(o.keywords).toContain('green_heart'); 54 | }); 55 | }); 56 | }); -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_ui/emoji_details.css: -------------------------------------------------------------------------------- 1 | .emoji-details dl { 2 | color: gray; 3 | } 4 | 5 | .emoji-details h1, .emoji-details h2 { 6 | text-align: center; 7 | } 8 | 9 | .emoji-details h1 { 10 | font-size: 6rem; 11 | margin-bottom: 0; 12 | } 13 | 14 | .emoji-details h2 { 15 | margin-top: 0; 16 | padding-top: 5px; 17 | border-top: 1px solid rgba(255, 255, 255, 0.3); 18 | } 19 | /*# sourceMappingURL=emoji_details.css.map */ -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_ui/emoji_details.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "AAAA,AACE,cADY,CACZ,EAAE,CAAC;EACD,KAAK,EAAE,IAAI;CAAG;;AAFlB,AAGE,cAHY,CAGZ,EAAE,EAHJ,cAAc,CAGR,EAAE,CAAC;EACL,UAAU,EAAE,MAAM;CAAG;;AAJzB,AAKE,cALY,CAKZ,EAAE,CAAC;EACD,SAAS,EAAE,IAAI;EACf,aAAa,EAAE,CAAC;CAAG;;AAPvB,AAQE,cARY,CAQZ,EAAE,CAAC;EACD,UAAU,EAAE,CAAC;EACb,WAAW,EAAE,GAAG;EAChB,UAAU,EAAE,GAAG,CAAC,KAAK,CAAC,wBAAqB;CAAG", 4 | "sources": [ 5 | "emoji_details.sass" 6 | ], 7 | "names": [], 8 | "file": "emoji_details.css" 9 | } -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_ui/emoji_details.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | 19 | module.exports = ((global) => { 20 | const WORD = 'word'; 21 | 22 | // given an array of words, return html with WORD classnames 23 | const renderWords = (arr, joiner = ", ") => arr.map(renderWord).join(joiner); 24 | // Given a word, return html with WORD classname 25 | const renderWord = str => `${str}` 26 | 27 | // return a string of rendered html for the given emoji 28 | function render(emoji) { 29 | const { keywords = [], thesaurus = [] } = emoji; 30 | 31 | const nameWords = emoji.name.split('_'); 32 | 33 | return ` 34 |

    ${emoji.char}

    35 |

    ${renderWords(nameWords, ' ')}

    36 |

    37 | ${ renderWords(keywords) } 38 |

    39 | 40 |
    41 | ${ 42 | thesaurus 43 | .filter(arr => arr.length > 0) 44 | .map((arr, idx) => ` 45 |
    ${keywords[idx]}
    46 |
    ${renderWords(arr)}
    47 | `) 48 | .join('\n') 49 | } 50 |
    51 | `; 52 | } 53 | 54 | return { 55 | WORD, 56 | render, 57 | __id__: 'emoji_details', // emulated node modules 58 | }; 59 | })(this); -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_ui/emoji_details.sass: -------------------------------------------------------------------------------- 1 | .emoji-details 2 | dl 3 | color: gray 4 | h1, h2 5 | text-align: center 6 | h1 7 | font-size: 6rem 8 | margin-bottom: 0 9 | h2 10 | margin-top: 0 11 | padding-top: 5px 12 | border-top: 1px solid rgba(255,255,255,0.3) -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_ui/emoji_picker.css: -------------------------------------------------------------------------------- 1 | html, body, ol { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | body, input, button { 7 | font-size: 1rem; 8 | } 9 | 10 | button { 11 | border-radius: 5px; 12 | cursor: pointer; 13 | } 14 | 15 | button:disabled { 16 | cursor: default; 17 | } 18 | 19 | body { 20 | background: #fafafa; 21 | font-family: Helvetica, Ubuntu, Arial, sans-serif; 22 | } 23 | 24 | .emoji-picker { 25 | min-width: 300px; 26 | } 27 | 28 | .search { 29 | margin: 0.5rem; 30 | margin-bottom: 0; 31 | padding: 0 1rem; 32 | border-radius: 1rem; 33 | line-height: 2rem; 34 | -webkit-box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); 35 | box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); 36 | border: none; 37 | } 38 | 39 | .search:focus { 40 | outline: none; 41 | -webkit-box-shadow: 0 0 0 2px #AA8DD8; 42 | box-shadow: 0 0 0 2px #AA8DD8; 43 | } 44 | 45 | input { 46 | padding: 0 0.5rem; 47 | min-width: 0; 48 | } 49 | 50 | input:focus { 51 | background: white; 52 | } 53 | 54 | .results { 55 | padding: 0.5rem 1rem; 56 | overflow-y: auto; 57 | } 58 | 59 | .results button { 60 | background: transparent; 61 | border: none; 62 | -webkit-appearance: none; 63 | padding: 0; 64 | margin: 0; 65 | height: 100%; 66 | width: 100%; 67 | font-size: 1.5rem; 68 | padding-left: 1px; 69 | padding-top: 1px; 70 | } 71 | 72 | .results button:focus { 73 | outline: none; 74 | border-radius: 2px; 75 | -webkit-box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3); 76 | box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3); 77 | background: rgba(0, 0, 0, 0.1); 78 | } 79 | 80 | .results ol { 81 | list-style: none; 82 | display: -ms-grid; 83 | display: grid; 84 | -ms-grid-columns: (2rem)[auto-fill]; 85 | grid-template-columns: repeat(auto-fill, 2rem); 86 | grid-auto-rows: 2rem; 87 | } 88 | 89 | .results li { 90 | text-align: center; 91 | } 92 | 93 | .search:focus + .results li:first-of-type button { 94 | background: rgba(0, 0, 0, 0.1); 95 | } 96 | 97 | output { 98 | margin-top: 0; 99 | display: -webkit-box; 100 | display: -ms-flexbox; 101 | display: flex; 102 | -webkit-box-orient: horizontal; 103 | -webkit-box-direction: reverse; 104 | -ms-flex-direction: row-reverse; 105 | flex-direction: row-reverse; 106 | height: 3rem; 107 | line-height: 3rem; 108 | padding-left: 0.5rem; 109 | background: white; 110 | -ms-flex-negative: 0; 111 | flex-shrink: 0; 112 | } 113 | 114 | .pseudo-clipboard { 115 | opacity: 0.4; 116 | margin: 10px; 117 | font-size: 1.5rem; 118 | border: none; 119 | } 120 | 121 | .pseudo-clipboard:focus { 122 | opacity: 1; 123 | } 124 | 125 | button.copy { 126 | background-color: #7553ac; 127 | border: none; 128 | border-bottom: 3px solid #463267; 129 | color: white; 130 | font-weight: 500; 131 | margin: 0.5rem; 132 | margin-right: 0.75rem; 133 | padding: 0 0.5rem; 134 | min-width: 5rem; 135 | -webkit-transition: color ease 200ms; 136 | transition: color ease 200ms; 137 | -webkit-transition: background-color ease 200ms; 138 | transition: background-color ease 200ms; 139 | } 140 | 141 | button.copy:disabled { 142 | background: #ddd; 143 | color: rgba(255, 255, 255, 0.7); 144 | background-color: #AA8DD8; 145 | border-bottom: 3px solid #7553ac; 146 | } 147 | 148 | button.copy:focus { 149 | outline: none; 150 | -webkit-box-shadow: 0 0 0 2px white, 0 0 0 4px #AA8DD8; 151 | box-shadow: 0 0 0 2px white, 0 0 0 4px #AA8DD8; 152 | } 153 | 154 | .flex-kids-y { 155 | display: -webkit-box; 156 | display: -ms-flexbox; 157 | display: flex; 158 | -webkit-box-orient: vertical; 159 | -webkit-box-direction: normal; 160 | -ms-flex-direction: column; 161 | flex-direction: column; 162 | } 163 | 164 | .flex-1 { 165 | -webkit-box-flex: 1; 166 | -ms-flex: 1 1 0px; 167 | flex: 1 1 0; 168 | } 169 | 170 | @-webkit-keyframes tada { 171 | 0% { 172 | -webkit-transform: scaleX(1); 173 | transform: scaleX(1); 174 | } 175 | 10%, 20% { 176 | -webkit-transform: scale3d(0.9, 0.9, 0.9) rotate(-3deg); 177 | transform: scale3d(0.9, 0.9, 0.9) rotate(-3deg); 178 | } 179 | 40%, 80% { 180 | -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate(3deg); 181 | transform: scale3d(1.1, 1.1, 1.1) rotate(3deg); 182 | } 183 | 60% { 184 | -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate(-3deg); 185 | transform: scale3d(1.1, 1.1, 1.1) rotate(-3deg); 186 | } 187 | to { 188 | -webkit-transform: scaleX(1); 189 | transform: scaleX(1); 190 | } 191 | } 192 | 193 | @keyframes tada { 194 | 0% { 195 | -webkit-transform: scaleX(1); 196 | transform: scaleX(1); 197 | } 198 | 10%, 20% { 199 | -webkit-transform: scale3d(0.9, 0.9, 0.9) rotate(-3deg); 200 | transform: scale3d(0.9, 0.9, 0.9) rotate(-3deg); 201 | } 202 | 40%, 80% { 203 | -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate(3deg); 204 | transform: scale3d(1.1, 1.1, 1.1) rotate(3deg); 205 | } 206 | 60% { 207 | -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate(-3deg); 208 | transform: scale3d(1.1, 1.1, 1.1) rotate(-3deg); 209 | } 210 | to { 211 | -webkit-transform: scaleX(1); 212 | transform: scaleX(1); 213 | } 214 | } 215 | 216 | .tada { 217 | -webkit-animation-name: tada; 218 | animation-name: tada; 219 | } 220 | 221 | .animated { 222 | -webkit-animation-duration: .4s; 223 | animation-duration: .4s; 224 | -webkit-animation-fill-mode: both; 225 | animation-fill-mode: both; 226 | } 227 | 228 | @media (prefers-reduced-motion: reduce), (print) { 229 | .animated { 230 | -webkit-animation-duration: 1ms !important; 231 | animation-duration: 1ms !important; 232 | -webkit-transition-duration: 1ms !important; 233 | transition-duration: 1ms !important; 234 | -webkit-animation-iteration-count: 1 !important; 235 | animation-iteration-count: 1 !important; 236 | } 237 | } 238 | /*# sourceMappingURL=emoji_picker.css.map */ -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_ui/emoji_picker.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "AAkCA,AAAA,IAAI,EAAE,IAAI,EAAE,EAAE,CAAC;EACb,MAAM,EAAE,CAAC;EACT,OAAO,EAAE,CAAC;CAAG;;AACf,AAAA,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC;EAClB,SAAS,EAAE,IAAI;CAAG;;AACpB,AAAA,MAAM,CAAC;EACL,aAAa,EAAE,GAAG;EAClB,MAAM,EAAE,OAAO;CAEO;;AAJxB,AAGE,MAHI,AAGH,SAAS,CAAC;EACT,MAAM,EAAE,OAAO;CAAG;;AAKtB,AAAA,IAAI,CAAC;EACH,UAAU,EAjCF,OAAO;EAkCf,WAAW,EAAE,oCAAoC;CAAG;;AACtD,AAAA,aAAa,CAAC;EACZ,SAAS,EAAE,KAAK;CAEhB;;AAKF,AAAA,OAAO,CAAC;EACN,MAAM,EAAE,MAAM;EACd,aAAa,EAAE,CAAC;EAChB,OAAO,EAAE,MAAM;EACf,aAAa,EAAE,IAAa;EAC5B,WAAW,EAnCF,IAAI;EAoCb,UAAU,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CA1Cb,kBAAe;EA2CvB,MAAM,EAAE,IAAI;CAG4B;;AAV1C,AAQE,OARK,AAQJ,MAAM,CAAC;EACN,OAAO,EAAE,IAAI;EACb,UAAU,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAnDT,OAAO;CAmDiB;;AAExC,AAAA,KAAK,CAAC;EACJ,OAAO,EAAE,QAAQ;EACjB,SAAS,EAAE,CAAC;CAEY;;AAJ1B,AAGE,KAHG,AAGF,MAAM,CAAC;EACN,UAAU,EAAE,KAAK;CAAG;;AAKxB,AAAA,QAAQ,CAAC;EACP,OAAO,EAAE,WAAW;EACpB,UAAU,EAAE,IAAI;CA0BS;;AA5B3B,AAKE,QALM,CAKN,MAAM,CAAC;EACL,UAAU,EAAE,WAAW;EACvB,MAAM,EAAE,IAAI;EACZ,kBAAkB,EAAE,IAAI;EACxB,OAAO,EAAE,CAAC;EACV,MAAM,EAAE,CAAC;EACT,MAAM,EAAE,IAAI;EACZ,KAAK,EAAE,IAAI;EACX,SAAS,EAjED,MAAM;EAkEd,YAAY,EAAE,GAAG;EACjB,WAAW,EAAE,GAAG;CAMW;;AArB/B,AAiBI,QAjBI,CAKN,MAAM,AAYH,MAAM,CAAC;EACN,OAAO,EAAE,IAAI;EACb,aAAa,EAAE,GAAG;EAClB,UAAU,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CA3EjB,kBAAe;EA4EnB,UAAU,EA9EN,kBAAe;CA8EI;;AArB7B,AAsBE,QAtBM,CAsBN,EAAE,CAAC;EACD,UAAU,EAAE,IAAI;EAChB,OAAO,EAAE,IAAI;EACb,qBAAqB,EAAE,uBAAuB;EAC9C,cAAc,EAAE,IAAI;CAAG;;AA1B3B,AA2BE,QA3BM,CA2BN,EAAE,CAAC;EACD,UAAU,EAAE,MAAM;CAAG;;AAIzB,AAAA,OAAO,AAAA,MAAM,GAAG,QAAQ,CAAC,EAAE,AAAA,cAAc,CAAC,MAAM,CAAC;EAC/C,UAAU,EA1FF,kBAAe;CA0FA;;AAKzB,AAAA,MAAM,CAAC;EACL,UAAU,EAAE,CAAC;EACb,OAAO,EAAE,IAAI;EACb,cAAc,EAAE,WAAW;EAC3B,MAAM,EAAE,IAAI;EACZ,WAAW,EAAE,IAAI;EACjB,YAAY,EAAE,MAAM;EACpB,UAAU,EAAE,KAAK;EACjB,WAAW,EAAE,CAAC;CAAG;;AAEnB,AAAA,iBAAiB,CAAC;EAChB,OAAO,EAAE,GAAG;EACZ,MAAM,EAAE,IAAI;EACZ,SAAS,EAvGC,MAAM;EAwGhB,MAAM,EAAE,IAAI;CAEK;;AANnB,AAKE,iBALe,AAKd,MAAM,CAAC;EACN,OAAO,EAAE,CAAC;CAAG;;AACjB,AAAA,MAAM,AAAA,KAAK,CAAC;EACV,gBAAgB,EArHF,OAAO;EAsHrB,MAAM,EAAE,IAAI;EACZ,aAAa,EAAE,GAAG,CAAC,KAAK,CArHV,OAAO;EAsHrB,KAAK,EAAE,KAAK;EACZ,WAAW,EAAE,GAAG;EAChB,MAAM,EAAE,MAAM;EACd,YAAY,EAAE,OAAO;EACrB,OAAO,EAAE,QAAQ;EACjB,SAAS,EAAE,IAAI;EACf,UAAU,EAAE,gBAAgB;EAC5B,UAAU,EAAE,2BAA2B;CAQkB;;AAnB3D,AAYE,MAZI,AAAA,KAAK,AAYR,SAAS,CAAC;EACT,UAAU,EAAE,IAAI;EAChB,KAAK,EA1HC,wBAAqB;EA2H3B,gBAAgB,EApIJ,OAAO;EAqInB,aAAa,EAAE,GAAG,CAAC,KAAK,CApIZ,OAAO;CAoIuB;;AAhB9C,AAiBE,MAjBI,AAAA,KAAK,AAiBR,MAAM,CAAC;EACN,OAAO,EAAE,IAAI;EACb,UAAU,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAxI1B,OAAO;CAwIkC;;AAOzD,AAAA,YAAY,CAAC;EACX,OAAO,EAAE,IAAI;EACb,cAAc,EAAE,MAAM;CAAG;;AAC3B,AAAA,OAAO,CAAC;EACN,IAAI,EAAE,KAAK;CAAG;;AAMhB,UAAU,CAAV,IAAU;EACR,EAAE;IACA,SAAS,EAAE,SAAS;;EACtB,GAAG,EAAC,GAAG;IACL,SAAS,EAAE,sBAAsB,CAAC,aAAa;;EACjD,GAAG,EAAE,GAAG;IACN,SAAS,EAAE,sBAAsB,CAAC,YAAY;;EAChD,GAAG;IACD,SAAS,EAAE,sBAAsB,CAAC,aAAa;;EACjD,EAAE;IACA,SAAS,EAAE,SAAS;;;;AAExB,AAAA,KAAK,CAAC;EACJ,cAAc,EAAE,IAAI;CAAG;;AAEzB,AAAA,SAAS,CAAC;EACR,kBAAkB,EAAE,GAAG;EACvB,2BAA2B,EAAE,IAAI;EACjC,mBAAmB,EAAE,IAAI;CAAG;;AAE9B,MAAM,EAAE,sBAAsB,EAAE,MAAM,IAAI,KAAK;EAC7C,AAAA,SAAS,CAAC;IACR,kBAAkB,EAAE,cAAc;IAClC,mBAAmB,EAAE,cAAc;IACnC,yBAAyB,EAAE,YAAY;GAAG", 4 | "sources": [ 5 | "emoji_picker.sass" 6 | ], 7 | "names": [], 8 | "file": "emoji_picker.css" 9 | } -------------------------------------------------------------------------------- /lib/emoji-magic/src/app_ui/emoji_picker.sass: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | 16 | // ------------------------------------------ vars 17 | $colorBg: #fafafa 18 | $colorBrandL90: #e3d9f2 // Luminosity 90 19 | $colorBrandL70: #AA8DD8 20 | $colorBrandL50: #7553ac 21 | $colorBrandL40: #5e428a 22 | $colorBrandL30: #463267 23 | $colorBrand: $colorBrandL70 24 | $shade10: rgba(0,0,0,0.1) 25 | $shade20: rgba(0,0,0,0.2) 26 | $shade30: rgba(0,0,0,0.3) 27 | $light50: rgba(255,255,255,0.5) 28 | $light70: rgba(255,255,255,0.7) 29 | $emojiSize: 1.5rem 30 | $gridSize: 2rem // extra room for whitespace around emoji 31 | 32 | 33 | 34 | // ------------------------------------------ reset 35 | html, body, ol 36 | margin: 0 37 | padding: 0 38 | body, input, button 39 | font-size: 1rem 40 | button 41 | border-radius: 5px 42 | cursor: pointer 43 | &:disabled 44 | cursor: default 45 | 46 | 47 | 48 | // ------------------------------------------ Outer containers 49 | body 50 | background: $colorBg 51 | font-family: Helvetica, Ubuntu, Arial, sans-serif 52 | .emoji-picker 53 | min-width: 300px 54 | // max-height: 485.410196625px // golden ratio. Nevermind. Better to let this be decided by RESULT_LIMIT 55 | // outline: 1px dashed #ddd // for the dev environment only 56 | 57 | 58 | 59 | // ------------------------------------------ Search Area 60 | .search 61 | margin: 0.5rem 62 | margin-bottom: 0 63 | padding: 0 1rem 64 | border-radius: $gridSize / 2 // half of height 65 | line-height: $gridSize 66 | box-shadow: 0 0 0 2px $shade10 67 | border: none 68 | &:focus 69 | outline: none 70 | box-shadow: 0 0 0 2px $colorBrand 71 | 72 | input 73 | padding: 0 0.5rem 74 | min-width: 0 75 | &:focus 76 | background: white 77 | 78 | 79 | 80 | // ------------------------------------------ Results Area 81 | .results 82 | padding: 0.5rem 1rem 83 | overflow-y: auto 84 | 85 | // want the button for semantics and keyboard nav, not appearance 86 | button 87 | background: transparent 88 | border: none 89 | -webkit-appearance: none 90 | padding: 0 91 | margin: 0 92 | height: 100% 93 | width: 100% 94 | font-size: $emojiSize 95 | padding-left: 1px // alignment tweak, so emojis look centered 96 | padding-top: 1px // alignment tweak, so emojis look centered 97 | 98 | &:focus 99 | outline: none 100 | border-radius: 2px 101 | box-shadow: 0 0 0 2px $shade30 102 | background: $shade10 103 | ol 104 | list-style: none 105 | display: grid 106 | grid-template-columns: repeat(auto-fill, 2rem) 107 | grid-auto-rows: 2rem 108 | li 109 | text-align: center 110 | 111 | // While search is focused, highlight the first item, 112 | // because "enter" will copy and close 113 | .search:focus + .results li:first-of-type button 114 | background: $shade10 115 | 116 | 117 | 118 | // ------------------------------------------ Output / copy area 119 | output 120 | margin-top: 0 121 | display: flex 122 | flex-direction: row-reverse // so the button can be first in tab order but last visually 123 | height: 3rem 124 | line-height: 3rem 125 | padding-left: 0.5rem 126 | background: white 127 | flex-shrink: 0 128 | 129 | .pseudo-clipboard 130 | opacity: 0.4 131 | margin: 10px 132 | font-size: $emojiSize 133 | border: none 134 | &:focus 135 | opacity: 1 136 | button.copy 137 | background-color: $colorBrandL50 138 | border: none 139 | border-bottom: 3px solid $colorBrandL30 140 | color: white 141 | font-weight: 500 142 | margin: 0.5rem 143 | margin-right: 0.75rem // optical align with search box 144 | padding: 0 0.5rem 145 | min-width: 5rem 146 | transition: color ease 200ms 147 | transition: background-color ease 200ms 148 | &:disabled 149 | background: #ddd 150 | color: $light70 151 | background-color: $colorBrand 152 | border-bottom: 3px solid $colorBrandL50 153 | &:focus 154 | outline: none 155 | box-shadow: 0 0 0 2px white, 0 0 0 4px $colorBrand 156 | 157 | 158 | 159 | // ------------------------------------------ css atoms 160 | // .muted 161 | // color: #999 162 | .flex-kids-y 163 | display: flex 164 | flex-direction: column 165 | .flex-1 166 | flex: 1 1 0 167 | 168 | 169 | 170 | // ------------------------------------------ Animation 171 | // Adapted from https://daneden.github.io/animate.css/ 172 | @keyframes tada 173 | 0% 174 | transform: scaleX(1) 175 | 10%,20% 176 | transform: scale3d(0.9, 0.9, 0.9) rotate(-3deg) 177 | 40%, 80% 178 | transform: scale3d(1.1, 1.1, 1.1) rotate(3deg) 179 | 60% 180 | transform: scale3d(1.1, 1.1, 1.1) rotate(-3deg) 181 | to 182 | transform: scaleX(1) 183 | 184 | .tada 185 | animation-name: tada 186 | 187 | .animated 188 | animation-duration: .4s 189 | -webkit-animation-fill-mode: both 190 | animation-fill-mode: both 191 | 192 | @media (prefers-reduced-motion: reduce), (print) 193 | .animated 194 | animation-duration: 1ms !important 195 | transition-duration: 1ms !important 196 | animation-iteration-count: 1 !important -------------------------------------------------------------------------------- /lib/emoji-magic/src/bind_to_dom.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | document.addEventListener("DOMContentLoaded", () => { 18 | const el = document.getElementById.bind(document); 19 | const $search = el('search'); 20 | const $copyBtn = el('copyBtn'); 21 | 22 | // Filter as you type 23 | $search.addEventListener('input', ({target: {value}}) => { 24 | const chars = emoji.search(value); 25 | 26 | // TODO: If no results, use thesaurus 27 | if (results.length === 0) { 28 | emoji.search(value, {useThesaurus: true}); 29 | } 30 | 31 | emoji.render(chars); 32 | }); 33 | 34 | // If you press "enter" in the search box, immediately copy the first emoji 35 | $search.addEventListener('keyup', evt => { 36 | switch (evt.key) { 37 | case 'Enter': 38 | emoji.copyFirstEmoji(); 39 | break; 40 | } 41 | }); 42 | 43 | // If you press "Escape" in the search box AND the query is blank, close the popup 44 | // This effectively means you can hit escape twice to close the browser popup. Nice! 45 | // NOTE: use 'keydown', because by 'keyup' the search query is already cleared 46 | $search.addEventListener('keydown', evt => { 47 | switch (evt.key) { 48 | case 'Escape': 49 | const query = evt.target.value; 50 | if (query === '') emoji.closePopup(); 51 | break; 52 | } 53 | }); 54 | 55 | // If you hit the copy button, make it happen 56 | $copyBtn.addEventListener('click', evt => { 57 | evt.preventDefault(); 58 | evt.stopPropagation(); 59 | emoji.copyToClipboard({instant: true}); 60 | }) 61 | 62 | // if you click any other buttons (emojis), copy them. 63 | document.addEventListener('click', (evt) => { 64 | // TODO: presumes we've prevented any other buttons from bubbling up to document 65 | if (evt.target.tagName === 'BUTTON') { 66 | emoji.onPressEmoji(evt.target.innerText); 67 | } 68 | }); 69 | document.addEventListener('keyup', (evt) => { 70 | // if you use the arrow keys, move focus 71 | switch (evt.key) { 72 | case 'ArrowLeft': 73 | emoji.moveFocusX(-1); 74 | break; 75 | case 'ArrowRight': 76 | emoji.moveFocusX(+1); 77 | break; 78 | case 'ArrowUp': 79 | emoji.moveFocusY(-1); 80 | break; 81 | case 'ArrowDown': 82 | emoji.moveFocusY(+1); 83 | break; 84 | } 85 | }); 86 | 87 | // Start things off with a blank search 88 | emoji.render(emoji.search()); 89 | }); -------------------------------------------------------------------------------- /lib/emoji-magic/src/browser_action.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/emoji.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 | 15 |
    16 | 17 |
    18 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 79 | 80 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/crystal-ball-1f52e.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/emoji-magic assets.sketch/Data: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/emoji-magic assets.sketch/Data -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/emoji-magic assets.sketch/metadata: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | app 6 | com.bohemiancoding.sketch3 7 | build 8 | 7574 9 | commit 10 | 8099b4f0b25d32201a4f1a171811eda68e6b1bd1 11 | fonts 12 | 13 | length 14 | 110831 15 | version 16 | 36 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/emoji-magic assets.sketch/version: -------------------------------------------------------------------------------- 1 | 36 -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/emoji-magic screenshots.sketch/Data: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/emoji-magic screenshots.sketch/Data -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/emoji-magic screenshots.sketch/metadata: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | app 6 | com.bohemiancoding.sketch3 7 | build 8 | 7574 9 | commit 10 | 8099b4f0b25d32201a4f1a171811eda68e6b1bd1 11 | fonts 12 | 13 | length 14 | 92967 15 | version 16 | 36 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/emoji-magic screenshots.sketch/version: -------------------------------------------------------------------------------- 1 | 36 -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/favicon-16x16.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/favicon-32x32.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/icon128.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/icon16.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/icon19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/icon19.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/icon48.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/promo-tile-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/promo-tile-large.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/promo-tile-marquee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/promo-tile-marquee.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/promo-tile-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/promo-tile-small.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/promo-tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/promo-tile.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/purple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/purple.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/icons/yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/lib/emoji-magic/src/icons/yellow.png -------------------------------------------------------------------------------- /lib/emoji-magic/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
    13 | 14 |

    🔮 Emoji Magic

    15 | 16 | 26 | 27 | 31 | 32 |
    33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 52 | 53 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/js_utils/code_points.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | 19 | module.exports = (() => { 20 | 21 | // Given an emoji, return an array of code points. 22 | // Must be an array to support multichar emoji like "🇨🇨" (2) and "🙇‍♀️" (4) 23 | function toCodePoints(char) { 24 | return Array.from(char).map(s => s.codePointAt(0)); 25 | } 26 | 27 | // Given an array of numeric "code points", return a char 28 | function fromCodePoints(arr = []) { 29 | return arr.map(n => String.fromCodePoint(n)).join(''); 30 | } 31 | 32 | return { 33 | toCodePoints, 34 | fromCodePoints, 35 | __id__: 'code_points', 36 | } 37 | })(); 38 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/js_utils/code_points.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | const {fromCodePoints, toCodePoints} = require('./code_points'); 18 | 19 | 20 | 21 | describe("Codepoint Conversion", () => { 22 | 23 | it('converts a single codepoint emoji back and forth', () => { 24 | const char = '💙'; 25 | const codes = toCodePoints(char); 26 | expect(codes).toEqual([128153]); // an array with only one code in it 27 | expect(fromCodePoints(codes)).toBe(char); 28 | }); 29 | 30 | it('converts a multi codepoint emoji back and forth', () => { 31 | const char = '🇨🇨'; 32 | const codes = toCodePoints(char); 33 | expect(codes.length).toBe(2); 34 | expect(fromCodePoints(codes)).toBe(char); 35 | }); 36 | 37 | it("converts '🙇‍♀️' back and forth", () => { 38 | const char = '🙇‍♀️'; 39 | expect(fromCodePoints(toCodePoints(char))).toBe(char); 40 | }); 41 | 42 | it("converts '👨‍👩‍👧‍👦' back and forth", () => { 43 | const char = '👨‍👩‍👧‍👦'; 44 | expect(fromCodePoints(toCodePoints(char))).toBe(char); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/js_utils/emulate_node_modules.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | 19 | // @fileoverview: Make it so the browser and chrome extension can both run node module syntax. 20 | // The browser works by storing and plucking the require key from the window global 21 | // Node works in the usual way. 22 | // Seemed easier than getting node to support window/global namespaced "modules". 23 | // ...the module wars have to stop, this is a waste of everyone's time. 24 | 25 | class EmulatedModule { 26 | set exports(val) { 27 | const key = val.__id__; // convention 28 | // console.log('set exports', val, key); 29 | if (!key) { 30 | return console.warn("Can't export a fake browser 'node module' without an __id__"); 31 | } 32 | window[key] = val; 33 | } 34 | } 35 | 36 | function emulatedRequire(id) { 37 | let thing; 38 | id = id.split('/').pop(); // "./store" -> "store" 39 | // console.log('emulatedRequire', id, thing); 40 | return window[id]; 41 | } 42 | 43 | 44 | 45 | window = this; 46 | window.module = window.module || new EmulatedModule(); 47 | window.require = window.require || emulatedRequire; 48 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/js_utils/matchers.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 18 | module.exports = (() => { 19 | 20 | const QUERY_SEPARATORS = /\s+/; // Specifies how search strings are tokenized 21 | const min = arr => Math.min.apply(Math, arr); 22 | const max = arr => Math.max(Math.max.apply(Math, arr), 0); 23 | 24 | // Given a pair of strings, how much does the `keyword` prefix the `candidate`? 25 | // eg: "foo", "foobar" -> 0.50 prefix 26 | // eg: "b", "bake" -> 0.25 prefix 27 | // Returns a number between 0 and 1, representing the overlap ratio 28 | const prefixOverlap = (term = '') => (candidate = '') => { 29 | if (term.length === 0 || candidate.lenth === 0) return 0; 30 | 31 | if(candidate.startsWith(term)) { 32 | return term.length / candidate.length; 33 | } 34 | 35 | return 0; 36 | }; 37 | 38 | // Given a string, then an array of strings, return the maxPrefixOverlap 39 | // eg: "ca", ["cake", "calendar"] -> 0.5 is the max prefix overlap 40 | // Returns a number between 0 and 1, representing the overlap ratio 41 | const maxPrefixOverlap = (term = '') => (candidates = []) => { 42 | if (term.length === 0 || candidates.lenth === 0) return 0; 43 | 44 | const weights = candidates.map(prefixOverlap(term)); 45 | return max(weights); 46 | }; 47 | 48 | // Given a string, then a string[][], calculate the maxPrefixOverlap for each candidate array 49 | // eg: "ca", [["cake", "calendar"],["ca"]] -> [0.5, 1] is the max for each 50 | // Returns an array of numbers between 0 and 1, one number for each element in arr. 51 | const calcPrefixOverlaps = (term) => (arr = []) => 52 | arr.map(maxPrefixOverlap(term)); 53 | 54 | // Given a (possibly) multi-term query, call calcPrefixOverlaps for each term 55 | // Return an array of prefixOverlap arrays, one for each term 56 | // eg: 57 | // "red car goes", [a, b] -> 58 | // 'red': [number, number] 59 | // 'car': [number, number] 60 | // 'goes': [number, number] 61 | const prefixOverlapsByQueryTerm = (queryString = '') => (arr = []) => { 62 | const terms = queryString.split(QUERY_SEPARATORS); 63 | return terms.map(t => calcPrefixOverlaps(t)(arr)); 64 | }; 65 | 66 | // Given a (possibly) multi-term query, call calcPrefixOverlaps for each set of words 67 | // same as prefixOverlapsByQueryTerm, but the first dimension is the set of words, rather than the query term 68 | // Return an array of prefixOverlap arrays, one for each word set 69 | // eg: 70 | // "red car goes", [a, b] -> 71 | // a: [number, number, number] 72 | // b: [number, number, number] 73 | const prefixOverlapsByWordSet = (queryString = '') => (arr = []) => { 74 | const terms = queryString.split(QUERY_SEPARATORS); 75 | return arr.map(candidates => 76 | terms.map(t => maxPrefixOverlap(t)(candidates)) 77 | ); 78 | }; 79 | 80 | const minPrefixOverlapsByWordSet = (queryString = '') => { 81 | const fn = prefixOverlapsByWordSet(queryString); 82 | return (arr = []) => fn(arr).map(min); 83 | }; 84 | 85 | 86 | 87 | return { 88 | QUERY_SEPARATORS, 89 | prefixOverlap, 90 | maxPrefixOverlap, 91 | calcPrefixOverlaps, 92 | prefixOverlapsByQueryTerm, 93 | prefixOverlapsByWordSet, 94 | minPrefixOverlapsByWordSet, 95 | __id__: 'matchers', 96 | } 97 | })(); 98 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/js_utils/matchers.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | const {prefixOverlap, maxPrefixOverlap, calcPrefixOverlaps, prefixOverlapsByQueryTerm, prefixOverlapsByWordSet, minPrefixOverlapsByWordSet} = require('./matchers'); 18 | 19 | 20 | 21 | describe("matchers.js", () => { 22 | 23 | describe("prefixOverlap()", () => { 24 | it('sees matches', () => { 25 | const result = prefixOverlap('foo')('foobar'); 26 | expect(result).toBeGreaterThan(0); 27 | }); 28 | it('only matches prefixes', () => { 29 | expect(prefixOverlap('a')('ab')).toBeGreaterThan(0); 30 | expect(prefixOverlap('b')('ab')).toBe(0); 31 | }); 32 | it('does not match a blank keyword', () => { 33 | expect(prefixOverlap('')('foo')).toBe(0); 34 | }); 35 | it('calculates expected prefix ratios', () => { 36 | expect(prefixOverlap('f')('farm')).toBe(0.25); 37 | expect(prefixOverlap('fa')('farm')).toBe(0.50); 38 | expect(prefixOverlap('far')('farm')).toBe(0.75); 39 | expect(prefixOverlap('farm')('farm')).toBe(1); 40 | }); 41 | }); 42 | 43 | describe("maxPrefixOverlap()", () => { 44 | it('returns 0 for no matches', () => { 45 | expect(maxPrefixOverlap('a')(['boo', 'baby', 'car'])).toBe(0); 46 | }); 47 | it('returns the expected maximum', () => { 48 | const candidates = ['camera', 'california', 'card', 'cabrio']; 49 | expect(maxPrefixOverlap('ca')(candidates)).toBe(0.5); // 50% overlap in 'ca'/'card' 50 | }); 51 | }); 52 | 53 | describe("calcPrefixOverlaps()", () => { 54 | it('calculates overlaps for multiple word sets', () => { 55 | const keywords = ['card', 'camera']; 56 | const lessCertainKeywords = ['cardinals', 'car']; 57 | const term = 'car'; 58 | const result = calcPrefixOverlaps(term)([keywords, lessCertainKeywords]); 59 | expect(result).toEqual([ 60 | 0.75, // car/card 61 | 1, // car/car 62 | ]); 63 | }); 64 | }); 65 | 66 | describe("prefixOverlapsByQueryTerm()", () => { 67 | const a = ['card', 'camera', 'redemption', 'foo']; 68 | const b = ['cardinals', 'car', 'bar', 'baz']; 69 | const result = prefixOverlapsByQueryTerm('red car goes')([a, b]); 70 | it('uses query terms as the primary dimension', () => { 71 | expect(result.length).toBe(3); 72 | expect(result[0].length).toBe(2); 73 | }); 74 | it('calculates overlaps for a multi-term query against multiple word sets', () => { 75 | expect(result).toEqual([ 76 | // a b 77 | [.3, 0], // 'red' 78 | [.75, 1], // 'car' 79 | [0, 0], // 'goes' 80 | ]); 81 | }); 82 | }); 83 | 84 | describe("prefixOverlapsByWordSet()", () => { 85 | const a = ['card', 'camera', 'redemption', 'foo']; 86 | const b = ['cardinals', 'car', 'bar', 'baz']; 87 | const result = prefixOverlapsByWordSet('red car goes')([a, b]); 88 | it('uses candidate word sets as the primary dimension', () => { 89 | expect(result.length).toBe(2); 90 | expect(result[0].length).toBe(3); 91 | }); 92 | it('calculates overlaps for a multi-term query against multiple word sets', () => { 93 | expect(result).toEqual([ 94 | // red car goes 95 | [.3, .75, 0], // a 96 | [0, 1, 0], // b 97 | ]); 98 | }); 99 | }); 100 | 101 | describe("minPrefixOverlapsByWordSet()", () => { 102 | const a = ['card', 'carmen', 'red']; 103 | const b = ['cardinals', 'car', 'redemptive']; 104 | const result = minPrefixOverlapsByWordSet('red car')([a, b]); 105 | it('picks the min for each word set', () => { 106 | // a b 107 | expect(result).toEqual([.75, .3]); 108 | // car/card 109 | // red/redemptive 110 | }); 111 | }); 112 | 113 | }); 114 | -------------------------------------------------------------------------------- /lib/emoji-magic/src/js_utils/store.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /* 18 | * @fileoverview 19 | * An abstraction around a client-side persistent storage mechanism. 20 | * You can keep stuff here and it'll last between usages of the tool on the user's device. 21 | * Data is not sent to any server. 22 | */ 23 | 24 | module.exports = ((global) => { 25 | const noop = () => {}; 26 | 27 | // Get and set for LocalStorage 28 | const localStoreGet = (key, fn) => { 29 | const val = localStorage.getItem(key); 30 | // Warning: localStorage always returns strings. Does chrome.storage do the same? 31 | fn(JSON.parse(val)); 32 | }; 33 | const localStoreSet = (key, val) => { 34 | localStorage.setItem(key, JSON.stringify(val)); 35 | } 36 | 37 | // Get and set for chrome.storage 38 | // const isRunningAsExtension = chrome.storage && chrome.storage.local; 39 | // const chromeGet = (key, fn) => chrome.storage.local.get([key], fn); 40 | // const chromeSet = (key, val) => chrome.storage.local.set({[key]: val}); 41 | // Guard: Are we running in a chrome context with access to chrome.storage? 42 | // if (!isRunningAsExtension) { 43 | // console.info("chrome.storage.local not available"); 44 | // } 45 | 46 | // Guard: Are we running in a node context with no localStorage? 47 | if (global == null || global.localStorage == null) { 48 | return { 49 | get: noop, 50 | set: noop, 51 | }; 52 | } else { 53 | return { 54 | get: localStoreGet, 55 | set: localStoreSet, 56 | __id__: 'store', // emulated node modules 57 | }; 58 | } 59 | })(this); -------------------------------------------------------------------------------- /lib/emoji-magic/src/site/site.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #222; 3 | color: white; 4 | line-height: 1.3; 5 | } 6 | 7 | .appContainer { 8 | margin: 0 auto; 9 | max-width: 400px; 10 | } 11 | 12 | h1 { 13 | text-align: center; 14 | font-weight: 300; 15 | color: #ccc; 16 | font-size: 2rem; 17 | } 18 | 19 | .emoji-picker { 20 | margin: 0 auto; 21 | border-radius: 3px; 22 | -webkit-box-shadow: 0 0 0 10px rgba(0, 0, 0, 0.4); 23 | box-shadow: 0 0 0 10px rgba(0, 0, 0, 0.4); 24 | background: whitesmoke; 25 | } 26 | 27 | .emoji-details, .emoji-picker { 28 | max-width: 400px; 29 | margin: 0 auto; 30 | } 31 | 32 | footer { 33 | font-size: smaller; 34 | color: gray; 35 | text-align: center; 36 | } 37 | 38 | footer a { 39 | color: lightgray; 40 | } 41 | 42 | h1, footer { 43 | margin: 1rem 0; 44 | } 45 | 46 | dt { 47 | margin-top: 0.5rem; 48 | } 49 | 50 | .highlight { 51 | background: rgba(255, 250, 130, 0.8); 52 | color: black; 53 | } 54 | /*# sourceMappingURL=site.css.map */ -------------------------------------------------------------------------------- /lib/emoji-magic/src/site/site.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "AAiBA,AAAA,IAAI,CAAC;EACH,UAAU,EAAE,IAAI;EAChB,KAAK,EAAE,KAAK;EACZ,WAAW,EAAE,GAAG;CAAG;;AAErB,AAAA,aAAa,CAAC;EACZ,MAAM,EAAE,MAAM;EACd,SAAS,EAAE,KAAK;CAAG;;AAErB,AAAA,EAAE,CAAC;EACD,UAAU,EAAE,MAAM;EAClB,WAAW,EAAE,GAAG;EAChB,KAAK,EAAE,IAAI;EACX,SAAS,EAAE,IAAI;CAAG;;AAEpB,AAAA,aAAa,CAAC;EACZ,MAAM,EAAE,MAAM;EACd,aAAa,EAAE,GAAG;EAClB,UAAU,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,kBAAe;EACtC,UAAU,EAAE,UAAU;CAAG;;AAE3B,AAAA,cAAc,EAAE,aAAa,CAAC;EAC5B,SAAS,EAAE,KAAK;EAChB,MAAM,EAAE,MAAM;CAAG;;AAEnB,AAAA,MAAM,CAAC;EACL,SAAS,EAAE,OAAO;EAClB,KAAK,EAAE,IAAI;EACX,UAAU,EAAE,MAAM;CAEK;;AALzB,AAIE,MAJI,CAIJ,CAAC,CAAC;EACA,KAAK,EAAE,SAAS;CAAG;;AAEvB,AAAA,EAAE,EAAE,MAAM,CAAC;EACT,MAAM,EAAE,MAAM;CAAG;;AAGnB,AAAA,EAAE,CAAC;EACD,UAAU,EAAE,MAAM;CAAG;;AAEvB,AAAA,UAAU,CAAC;EACT,UAAU,EAAE,wBAAwB;EACpC,KAAK,EAAE,KAAK;CAAG", 4 | "sources": [ 5 | "site.sass" 6 | ], 7 | "names": [], 8 | "file": "site.css" 9 | } -------------------------------------------------------------------------------- /lib/emoji-magic/src/site/site.sass: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // CSS specific to the demo website implementation of emoji-magic 16 | // None of this should be needed for the chrome extension 17 | 18 | body 19 | background: #222 20 | color: white 21 | line-height: 1.3 22 | 23 | .appContainer 24 | margin: 0 auto 25 | max-width: 400px 26 | 27 | h1 28 | text-align: center 29 | font-weight: 300 30 | color: #ccc 31 | font-size: 2rem 32 | 33 | .emoji-picker 34 | margin: 0 auto 35 | border-radius: 3px 36 | box-shadow: 0 0 0 10px rgba(0,0,0,0.4) 37 | background: whitesmoke 38 | 39 | .emoji-details, .emoji-picker 40 | max-width: 400px 41 | margin: 0 auto 42 | 43 | footer 44 | font-size: smaller 45 | color: gray 46 | text-align: center 47 | a 48 | color: lightgray 49 | 50 | h1, footer 51 | margin: 1rem 0 52 | 53 | // definition title 54 | dt 55 | margin-top: 0.5rem 56 | 57 | .highlight 58 | background: rgba(255, 250, 130, 0.8) 59 | color: black 60 | -------------------------------------------------------------------------------- /lib/emoji-magic/third_party/emojilib/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Mu-An Chiou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "emoji-magic", 3 | "name": "Emoji Magic", 4 | "version": "0.2.2", 5 | "description": "Easily add emoji, with a powerful keyword search. 🔮 ✨ 🐇", 6 | "author": "simplgy", 7 | "authorUrl": "https://github.com/simplgy", 8 | "isDesktopOnly": false 9 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-emoji-magic", 3 | "version": "0.2.2", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js", 9 | "version": "node version-bump.mjs && git add . && git commit -m 'bump' && git tag -a $npm_package_version -m \"$npm_package_version\" && git push origin $npm_package_version" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "APACHE", 14 | "devDependencies": { 15 | "@rollup/plugin-commonjs": "^15.1.0", 16 | "@rollup/plugin-node-resolve": "^9.0.0", 17 | "@rollup/plugin-typescript": "^6.0.0", 18 | "@types/node": "^14.14.2", 19 | "obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master", 20 | "rollup": "2.32.1", 21 | "tslib": "^2.0.3", 22 | "typescript": "^4.0.3" 23 | }, 24 | "dependencies": {} 25 | } 26 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | 5 | export default { 6 | input: 'src/main.ts', 7 | output: { 8 | dir: '.', 9 | sourcemap: 'inline', 10 | format: 'cjs', 11 | exports: 'default' 12 | }, 13 | external: ['obsidian'], 14 | plugins: [ 15 | nodeResolve({ browser: true }), 16 | typescript({ module: 'CommonJS' }), 17 | commonjs({ extensions: ['.js', '.ts'] }) // the ".ts" extension is required 18 | ] 19 | }; -------------------------------------------------------------------------------- /screenshots/emoji-magic-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/screenshots/emoji-magic-blue.png -------------------------------------------------------------------------------- /screenshots/emoji-magic-obsidian-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/screenshots/emoji-magic-obsidian-1.gif -------------------------------------------------------------------------------- /screenshots/emoji-magic-obsidian-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimplGy/obsidian-emoji-magic/95e7f5b3627c5d92f1823e5d2a6f2d8e87f95c4b/screenshots/emoji-magic-obsidian-2.gif -------------------------------------------------------------------------------- /src/cfg.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const CHROME_EXTENSION_URL = 'https://chrome.google.com/webstore/detail/emoji-magic/jfegjdogmpipkpmapflkkjpkhbnfppln'; 4 | export const STATIC_WEB_APP_URL = 'https://www.simple.gy/emoji-magic/'; 5 | export const GITHUB_REPO_URL = 'https://github.com/SimplGy/obsidian-emoji-magic'; 6 | export const GITHUB_BUG_REPORT_PATH = 'https://github.com/SimplGy/obsidian-emoji-magic/issues'; -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { Plugin } from 'obsidian'; 18 | 19 | export interface EmojiMagicSettings { 20 | recentEmoji: string[]; 21 | } 22 | 23 | export interface EmojiMagicPluginType extends Plugin { 24 | settings: EmojiMagicSettings; 25 | } 26 | 27 | export interface Emoji {} -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { App, Plugin, PluginSettingTab } from 'obsidian'; 18 | 19 | import { SearchModal } from './search_modal'; 20 | import { EmojiMagicSettings, EmojiMagicPluginType } from './interfaces'; 21 | import {CHROME_EXTENSION_URL, GITHUB_BUG_REPORT_PATH} from './cfg'; 22 | 23 | // The compiled "database" from Emoji Magic upstream 24 | // import {array as emojilibThesaurus} from '../lib/emoji-magic/src/app_data/emoji_data.js'; 25 | 26 | const emojiCount = 1_812; // emojilibThesaurus.length; 27 | const words = 119_658; // emojilibThesaurus.reduce((sum, obj) => sum + obj.keywords.length + obj.thesaurus.flat().length, 0); 28 | 29 | const DEFAULT_SETTINGS: EmojiMagicSettings = { 30 | recentEmoji: [], 31 | }; 32 | 33 | // ---------------------------------------------------- Plugin Definition 34 | export default class EmojiMagicPlugin extends Plugin implements EmojiMagicPluginType { 35 | 36 | settings: EmojiMagicSettings; 37 | 38 | async onload() { 39 | await this.loadSettings(); 40 | 41 | // For fun, print how many keywords this plugin (except don't live-calculate, because it's slow to calculate) 42 | console.log(`${this.manifest.name}: Loading ${prettyNum(emojiCount)} emoji with ${prettyNum(words)} searchable keywords and thesaurus entries`); 43 | 44 | this.addSettingTab(new SettingsTab(this.app, this)); 45 | this.resetCommands(); 46 | } 47 | 48 | onunload() { 49 | // no-op 50 | } 51 | 52 | async loadSettings() { 53 | this.settings = {...DEFAULT_SETTINGS, ...await this.loadData()}; 54 | } 55 | 56 | // note: this is called ~every keystroke, so be aware 57 | async saveSettings() { 58 | await this.saveData(this.settings); 59 | } 60 | 61 | 62 | // ---------------------------------------------------- Command Setup 63 | resetCommands() { 64 | // if you "add" with the same id, it works as an "update" 65 | this.addCommand({ 66 | id: 'insert', // automatically prefixed with plugin name 67 | name: `Insert emoji...`, 68 | callback: () => this.openSearchUX(this.insertTextAtCursor), 69 | }); 70 | this.addCommand({ 71 | id: 'copy', 72 | name: `Copy emoji to clipboard...`, 73 | callback: () => this.openSearchUX(this.copyToClipboard, "Copy emoji"), 74 | }); 75 | this.addCommand({ 76 | id: 'add-to-filename-start', 77 | name: `Add emoji to filename...`, 78 | callback: () => this.openSearchUX(this.updateFilenameStart, "Add emoji to filename"), 79 | }); 80 | } 81 | 82 | 83 | // ---------------------------------------------------- Actual Behavior 84 | 85 | // Open the search UX. when complete, calls the callback with the selected emoji. 86 | // if you want, override the placeholder message 87 | openSearchUX(callback: (text?: string) => void, placeholder?: string) { 88 | const modal = new SearchModal(this, callback, placeholder); 89 | modal.open(); 90 | } 91 | 92 | insertTextAtCursor = (text: string = '') => { 93 | const view = this.app.workspace.activeEditor; 94 | 95 | // Make sure the user is editing a Markdown file. 96 | if (view?.editor) { 97 | // Insert text 98 | const cursor = view.editor.getCursor(); 99 | view.editor.replaceRange(text, cursor); 100 | // then, move their cursor *after* the text we inserted 101 | const newPos = cursor.ch + text.length; 102 | view.editor.setCursor({ ...cursor, ch: newPos }); 103 | } else { 104 | console.warn("Asked to insertTextAtCursor, but didn't find a MarkdownView"); 105 | } 106 | }; 107 | 108 | copyToClipboard = (text?: string) => { 109 | if (!text) return; 110 | navigator.clipboard.writeText(text) 111 | }; 112 | 113 | updateFilenameStart = (text?: string) => { 114 | if (!text) return; 115 | const view = this.app.workspace.activeEditor; 116 | if (!view?.file) return console.warn('no view.file'); 117 | 118 | // Calculate the new "path" (simple file rename) 119 | let curPath = view.file.path; 120 | const idx = curPath.lastIndexOf(view.file.name); 121 | const start = curPath.substring(0, idx); 122 | const end = curPath.substring(idx) 123 | const newPath = `${start}${text} ${end}`; 124 | 125 | // Set the value 126 | this.app.fileManager.renameFile(view.file, newPath); 127 | }; 128 | 129 | } 130 | 131 | 132 | 133 | // ---------------------------------------------------- Settings Screen 134 | class SettingsTab extends PluginSettingTab { 135 | 136 | plugin: EmojiMagicPlugin; 137 | 138 | constructor(app: App, plugin: EmojiMagicPlugin) { 139 | super(app, plugin); 140 | this.plugin = plugin; 141 | } 142 | 143 | display() { 144 | const { containerEl } = this; 145 | containerEl.empty(); 146 | containerEl.createEl("h1", { text: this.plugin.manifest.name }); 147 | containerEl.createEl("h3", { text: 'How to use' }); 148 | containerEl.addClass('emoji-magic-settings'); 149 | 150 | const aside = containerEl.createEl('aside'); 151 | aside.createEl('p', { text: 'This plugin is most useful if you add a hotkey. eg:' }); 152 | aside.createEl('p', { text: '' }).createEl('code', {text: 'cmd + shift + e'}); 153 | aside.createEl('hr'); 154 | aside.createEl('p', { text: 'Your recent emoji:' }).createEl('code', {text: this.plugin.settings.recentEmoji.join(' '), cls: 'padded-sides' }); 155 | aside.createEl('hr'); 156 | aside.createEl('p', { text: 'Emoji Magic is also available as a ', cls: 'muted' }).createEl('a', {text: 'Chrome Extension', href: CHROME_EXTENSION_URL}); 157 | aside.createEl('p', { text: 'Please report problems with specific search phrases on ', cls: 'muted' }).createEl('a', {text: 'GitHub', href: GITHUB_BUG_REPORT_PATH}); 158 | } 159 | } 160 | 161 | 162 | 163 | // ---------------------------------------------------- dep-free Util Function Lib 164 | function prettyNum(n: number) { 165 | return new Intl.NumberFormat().format(n); 166 | } -------------------------------------------------------------------------------- /src/search_modal.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { Modal, Plugin } from 'obsidian'; 18 | 19 | import { Emoji, EmojiMagicPluginType } from './interfaces'; 20 | import {CHROME_EXTENSION_URL, STATIC_WEB_APP_URL} from './cfg'; 21 | 22 | // from Emoji Magic upstream 23 | import { 24 | // pre-bound 2nd order fn `searchOn(array)`. This is an expensive function and just naiively scans an array in O(n) time, but should only run on keypress 25 | search as emojiSearch, 26 | // This generates HTML from an array of emoji objects, but does not do any kind of lookup or refer to the full dictionary 27 | htmlForAllEmoji as htmlForTheseEmoji, 28 | // constants, as you'd expect 29 | RECENT_SELECTION_LIMIT, 30 | DEFAULT_RESULTS, 31 | // RISK: Does a `.find` O(n) lookup in emojilib_thesaurus.array 32 | toObj as toEmojiObj 33 | } from '../lib/emoji-magic/src/app_data/emoji.js'; 34 | 35 | 36 | 37 | // -------------------------------------------------------- Constants 38 | const EMOJIS_PER_ROW = 8; // Not 100% fixed, depends on font size/zoom settings 39 | const RESULT_LIMIT = EMOJIS_PER_ROW * 15; // for render perf, don't draw everything. 40 | const SEARCH_CHARS_NEEDED = 2; // don't bother searching if user has typed fewer than this many letters. 41 | 42 | // Rotate these messages whenever the popup opens to maybe keep things fresh/fun 43 | const MESSAGES = [ 44 | `Also available as a Chrome Extension`, 45 | `Brought to you by simple.gy`, 46 | `Check out the web app`, 47 | ]; 48 | const EXAMPLE_SEARCHES = [ 'party', 'thank', 'happy', 'new', 'food', 'drink', 'wine' ]; 49 | 50 | const DEFAULT_PLACEHOLDER = 'Search for emoji'; 51 | 52 | // keycodes 53 | const LEFT = 37; 54 | const UP = 38; 55 | const RIGHT = 39; 56 | const DOWN = 40; 57 | const ENTER = 13; 58 | 59 | 60 | 61 | // -------------------------------------------------------- Type Definitions 62 | interface EmojiPickerDom { 63 | // must be the elemen that gets document.activeElement focus 64 | searchEl: HTMLInputElement, 65 | // must have direct children whose count exactly equals the count of emoji in the search results 66 | // (enables index-based lookup into dom) 67 | resultsEl: HTMLOListElement, 68 | } 69 | 70 | 71 | 72 | // -------------------------------------------------------- Main Class 73 | export class SearchModal extends Modal { 74 | result: string; 75 | private dom?: EmojiPickerDom; 76 | 77 | constructor( 78 | readonly plugin: EmojiMagicPluginType, // Type indirection here instead of direct class avoids a circular dependency 79 | readonly onSubmit: (result?: string) => void, 80 | readonly placeholderMsg: string = DEFAULT_PLACEHOLDER, 81 | ) { 82 | super(plugin.app); 83 | this.modalEl.addClass('emoji-magic-modal'); 84 | } 85 | 86 | public onOpen() { 87 | const { contentEl } = this; 88 | 89 | // ------------------------ Search Box 90 | const searchEl = document.createElement('input'); 91 | searchEl.setAttribute('type', 'search'); 92 | searchEl.setAttribute('autofocus', 'true'); 93 | const eg = randomPick(EXAMPLE_SEARCHES); 94 | searchEl.setAttribute('placeholder', `${this.placeholderMsg} (eg: "${eg}")`); 95 | 96 | // ------------------------ Grid of Emoji results 97 | const resultsEl = document.createElement('ol'); 98 | resultsEl.addClass('results'); 99 | 100 | // ------------------------ Grid of Emoji results 101 | const footerEl = document.createElement('footer'); 102 | 103 | // ------------------------ Footer with a rotating message 104 | footerEl.innerHTML = randomPick(MESSAGES); 105 | 106 | // ------------------------ DOM mutation 107 | contentEl.appendChild(searchEl); 108 | contentEl.appendChild(resultsEl); 109 | contentEl.appendChild(footerEl); 110 | this.dom = { 111 | searchEl, 112 | resultsEl, 113 | }; 114 | 115 | // ------------------------ Event Listeners 116 | // WARNING: Obsidian closes the command palette (and launches your plugin) on `keydown`. 117 | // This means you can sometimes get one extra `keyup` event inside your plugin. Use `keypress` instead of `keyup` for this reason 118 | searchEl.addEventListener('keypress', evt => { 119 | this.onKeySearchArea(evt, this.dom!); 120 | }); 121 | // delegated button click handler. Is also fired for keyboard enter on button elements. 122 | contentEl.addEventListener('click', this.onAnyClick); 123 | contentEl.addEventListener('keyup', this.onAnyKey); 124 | 125 | // ------------------------- Start with at least one render 126 | this.filterAndRender(searchEl.value); 127 | } 128 | 129 | private onKeySearchArea = (evt, dom: EmojiPickerDom) => { 130 | const el = evt.target as HTMLInputElement|undefined; 131 | const str = el?.value ?? ''; 132 | 133 | if (evt.keyCode === ENTER) { 134 | const emoji = getFirstEmoji(dom); 135 | this.chooseEmoji(emoji); 136 | } else { 137 | this.filterAndRender(str); 138 | } 139 | }; 140 | 141 | private filterAndRender(str: string) { 142 | // Empty or short query? show recent or default 143 | str = str.trim(); 144 | const el = this.dom?.resultsEl; 145 | 146 | // No search, show recent/default 147 | if (str.length === 0) { 148 | const emoji: Emoji[] = this.getRecentEmoji().map(toEmojiObj); 149 | renderEmoji(emoji, el); 150 | 151 | // Typing, but not enough yet? show nothing. 152 | } else if (str.length < SEARCH_CHARS_NEEDED) { 153 | renderEmoji([], el); 154 | 155 | // Search for emoji 156 | } else { 157 | const emoji: Emoji[] = emojiSearch(str); 158 | // console.log(`Emoji Magic: results for '${str}'`, emoji); 159 | renderEmoji(emoji, el); 160 | } 161 | 162 | } 163 | 164 | // Get recently used emoji chars, falling back to defaults as necessary. 165 | getRecentEmoji() { 166 | const recent = this.plugin.settings.recentEmoji; 167 | return recent.length === 0 ? DEFAULT_RESULTS : recent; 168 | } 169 | 170 | private onAnyClick = (evt) => { 171 | if (isButton(evt.target)) { 172 | this.onEmojiButtonPress(evt.target); 173 | } 174 | }; 175 | 176 | private onAnyKey = (evt) => { 177 | if (this.dom == null) return console.warn("keypress, but dom not cached"); 178 | 179 | // Handle the arrow keys with nice 2d grid support 180 | switch (evt.keyCode) { 181 | case LEFT: 182 | moveFocusX(-1, this.dom); 183 | break; 184 | case RIGHT: 185 | moveFocusX(+1, this.dom); 186 | break; 187 | case UP: 188 | moveFocusY(-1, this.dom); 189 | break; 190 | case DOWN: 191 | moveFocusY(+1, this.dom); 192 | break; 193 | } 194 | }; 195 | 196 | private onEmojiButtonPress(buttonEl: HTMLButtonElement) { 197 | this.chooseEmoji(buttonEl.innerText); 198 | } 199 | 200 | private chooseEmoji(char?: string) { 201 | this.trackRecent(char); 202 | this.close(); 203 | this.onSubmit(char); 204 | } 205 | 206 | public onClose() { 207 | this.contentEl.empty(); 208 | this.contentEl.removeEventListener('click', this.onAnyClick); 209 | } 210 | 211 | // add recent selection, but only track the most recent k 212 | // also duplicates: if it's already present, remove matches first before putting the most recent at the front 213 | private trackRecent(char?: string) { 214 | if (!char) return; 215 | 216 | // local copy: 217 | let arr = this.plugin.settings.recentEmoji.slice(); 218 | // Remove the "just chosen" char, if it's already in there: 219 | arr = arr.filter(c => c !== char); 220 | // Add it to the front, no matter what: 221 | arr.unshift(char); 222 | // Limit the size, removing things from the tail: 223 | if (arr.length > RECENT_SELECTION_LIMIT) { 224 | arr = arr.slice(0, RECENT_SELECTION_LIMIT); 225 | } 226 | 227 | this.plugin.settings.recentEmoji = arr; 228 | this.plugin.saveData(this.plugin.settings); 229 | } 230 | } 231 | 232 | 233 | 234 | // ----------------------------------------------------- Lovely isolated pure function lib, but DOM-coupled 235 | function getFirstEmoji(dom: EmojiPickerDom) { 236 | const btn = dom.resultsEl.querySelector('button'); // get the first button 237 | if (!btn) return; 238 | const char = btn.innerText.trim(); 239 | return char === '' ? undefined : char; 240 | } 241 | 242 | // Move focus horizontally. +1 is right, -1 is left. 243 | // wraps around 244 | // boundary protection works because focusOn just exits if it doesn't find an el to focus 245 | // really really dom coupled 246 | function moveFocusX(direction = 0, dom: EmojiPickerDom) { 247 | const wasFromSearch = document.activeElement === dom.searchEl; 248 | if (wasFromSearch) return; // don't do custom arrow behavior if we're in the search box already 249 | 250 | const curLi = document.activeElement?.parentElement; 251 | if (!curLi) return; 252 | const idx = Array.from(dom.resultsEl.children).indexOf(curLi); 253 | focusOn(idx + direction, dom); 254 | } 255 | 256 | // +1 is down, -1 is up 257 | function moveFocusY(direction = 0, dom: EmojiPickerDom) { 258 | 259 | // special case: moving down from search 260 | const wasFromSearch = document.activeElement === dom.searchEl; 261 | if(wasFromSearch) { 262 | if (direction > 0) { 263 | focusOn(0, dom); 264 | } 265 | return; // don't do more custom arrow behavior if we're just now exiting the search box 266 | } 267 | 268 | let el = document.activeElement?.parentElement; 269 | if (el == null) return; 270 | 271 | const left = el.offsetLeft; 272 | const top = el.offsetTop; 273 | let prevEl; 274 | 275 | // if we're going down a row... 276 | if (direction > 0) { 277 | // 1. look forward until we find something with a higher offsetTop (first el of next row) 278 | while(el && el.offsetTop <= top) { 279 | prevEl = el; 280 | el = el.nextElementSibling as HTMLElement|undefined; 281 | } 282 | 283 | // 2. look forward until we find something >= the current offsetLeft (same col, or close) 284 | while (el && el.offsetLeft < left) { 285 | prevEl = el; 286 | el = el.nextElementSibling as HTMLElement|undefined; 287 | } 288 | 289 | // another approach: just look for the next element with nearly the same left position, it'll be in the next row. 290 | // I like the way the current one handles sparse rows though. 291 | 292 | // if we're going up a row... 293 | } else if (direction < 0) { 294 | // 1. look backward until we find something with a lower offsetTop (last el of previous row) 295 | while(el && el.offsetTop >= top) { 296 | prevEl = el; 297 | el = el.previousElementSibling as HTMLElement|undefined; 298 | } 299 | // 2. look backward until we find something <= the current offsetLeft 300 | while (el && el.offsetLeft > left) { 301 | prevEl = el; 302 | el = el.previousElementSibling as HTMLElement|undefined; 303 | } 304 | } 305 | 306 | if (el) { 307 | el.querySelector('button')?.focus(); 308 | } else if(prevEl) { 309 | // if moving up and we didn't find a correct focus target, choose the search box 310 | if (direction < 0) { 311 | dom.searchEl.focus(); 312 | dom.searchEl.select(); 313 | } else { 314 | prevEl.querySelector('button')?.focus(); 315 | } 316 | } 317 | 318 | } 319 | 320 | function focusOn(idx, dom: EmojiPickerDom) { 321 | const li = dom.resultsEl.children[idx]; 322 | if (!li) return; 323 | li.querySelector('button')?.focus(); 324 | } 325 | 326 | 327 | 328 | 329 | // ----------------------------------------------------- Lovely isolated pure function lib 330 | function randomPick(arr: Array): T { 331 | const idx = Math.floor(Math.random() * arr.length); 332 | return arr[idx]; 333 | } 334 | 335 | function isButton(o: any): o is HTMLButtonElement { 336 | if ((o as HTMLButtonElement)?.tagName === 'BUTTON') return true; 337 | return false; 338 | } 339 | 340 | function renderEmoji(emojiObjects, targetEl) { 341 | emojiObjects = emojiObjects.slice(0, RESULT_LIMIT); 342 | const html = htmlForTheseEmoji(emojiObjects); 343 | targetEl.innerHTML = html; 344 | } 345 | -------------------------------------------------------------------------------- /src/search_modal_suggest_variant.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { App, SuggestModal } from 'obsidian'; 18 | // import EditTask from './ui/EditTask.svelte'; 19 | // import type { Task } from './Task'; 20 | // import { StatusRegistry } from './StatusRegistry'; 21 | // import { Status } from './Status'; 22 | 23 | 24 | 25 | 26 | // from Emoji Magic upstream 27 | import {search as emojiSearch, htmlForAllEmoji as htmlForTheseEmoji, summaryString, htmlForEmoji} from '../lib/emoji-magic/src/app_data/emoji.js'; 28 | 29 | 30 | 31 | 32 | const RESULT_LIMIT = 8 * 15; // for render perf, don't draw everything. size by row assuming default font size/zoom settings. 33 | 34 | 35 | interface Emoji { 36 | char: string; 37 | } 38 | 39 | 40 | export class SearchModal extends SuggestModal { 41 | emptyStateText = 'Find Emoji by key words'; 42 | 43 | result: string; 44 | public readonly onSubmit = (result: string) => { 45 | console.log('onSubmit', result); 46 | }; 47 | 48 | constructor({ app, onSubmit }: { app: App; onSubmit: (result: string) => void }) { 49 | super(app); 50 | 51 | // this.task = task; 52 | // this.onSubmit = (updatedTasks: Task[]) => { 53 | // updatedTasks.length && onSubmit(updatedTasks); 54 | // this.close(); 55 | // }; 56 | } 57 | 58 | getSuggestions(query: string): Emoji[] { 59 | if (query.length === 1) return []; 60 | if (query.length === 2) return []; 61 | // >3 chars? ok, search: 62 | 63 | const emojiObjects = emojiSearch(query); 64 | console.log(`Emoji Magic: results for '${query}'`, emojiObjects); 65 | 66 | return emojiObjects; 67 | } 68 | 69 | // Renders each suggestion item. 70 | renderSuggestion(emoji: Emoji, el: HTMLElement) { 71 | // el.innerHTML = htmlForEmoji(emoji); 72 | 73 | el.createEl("div", { text: emoji.char }); 74 | el.setAttribute('title', summaryString(emoji)); 75 | // el.createEl("small", { text: emoji.author }); 76 | } 77 | 78 | // Perform action on the selected suggestion. 79 | onChooseSuggestion(emoji: Emoji, evt: MouseEvent | KeyboardEvent) { 80 | // new Notice(`Selected ${book.title}`); 81 | console.log('Emoji Magic: selected one', emoji); 82 | } 83 | 84 | public onOpen(): void { 85 | this.titleEl.setText('Emoji Magic: Search for emoji'); 86 | const { contentEl } = this; 87 | // const containerEl = contentEl.createEl("div"); 88 | 89 | // contentEl.setText("Look at me, I'm a modal! 👀"); 90 | // contentEl.createEl("h1", { text: "What's your name?" }); 91 | 92 | // const resultsEl = document.createElement('ol'); 93 | // const searchEl = document.createElement('input'); 94 | // searchEl.setAttribute('type', 'search'); 95 | // searchEl.addEventListener('keyup', evt => { 96 | // const str = (evt.target as HTMLInputElement)?.value; 97 | // filterAndRender(str, resultsEl); 98 | // }); 99 | // contentEl.appendChild(searchEl); 100 | // contentEl.appendChild(resultsEl); 101 | 102 | 103 | 104 | 105 | 106 | 107 | // function filterAndRender(str: string, targetEl) { 108 | 109 | // renderEmoji(emojiObjects, targetEl); 110 | // } 111 | 112 | function renderEmoji(emojiObjects, targetEl) { 113 | emojiObjects = emojiObjects.slice(0, RESULT_LIMIT); 114 | const html = htmlForTheseEmoji(emojiObjects); 115 | targetEl.innerHTML = html; 116 | 117 | // if (emoji.length === 0) { 118 | // $.copyBtn().setAttribute('disabled', true); 119 | // } else { 120 | // $.copyBtn().removeAttribute('disabled'); 121 | // } 122 | } 123 | 124 | 125 | 126 | 127 | /* 128 | goal: 129 | 130 |
    131 | 132 |
    133 | 134 |
    135 | */ 136 | 137 | 138 | 139 | 140 | // new Setting(contentEl) 141 | // .setName("Name") 142 | // .addText((text) => 143 | // text.onChange((value) => { 144 | // this.result = value 145 | // })); 146 | 147 | // new Setting(contentEl) 148 | // .addButton((btn) => 149 | // btn 150 | // .setButtonText("Submit") 151 | // .setCta() 152 | // .onClick(() => { 153 | // this.close(); 154 | // this.onSubmit(this.result); 155 | // })); 156 | 157 | 158 | 159 | // const statusOptions = this.getKnownStatusesAndCurrentTaskStatusIfNotKnown(); 160 | 161 | // new EditTask({ 162 | // target: contentEl, 163 | // props: { task: this.task, statusOptions: statusOptions, onSubmit: this.onSubmit }, 164 | // }); 165 | } 166 | 167 | /** 168 | * If the task being edited has an unknown status, make sure it is added 169 | * to the dropdown list. 170 | * This allows the user to switch to a different status and then change their 171 | * mind and return to the initial status. 172 | */ 173 | // private getKnownStatusesAndCurrentTaskStatusIfNotKnown() { 174 | // const statusOptions: Status[] = StatusRegistry.getInstance().registeredStatuses; 175 | // if (StatusRegistry.getInstance().bySymbol(this.task.status.symbol) === Status.EMPTY) { 176 | // statusOptions.push(this.task.status); 177 | // } 178 | // return statusOptions; 179 | // } 180 | 181 | public onClose(): void { 182 | const { contentEl } = this; 183 | contentEl.empty(); 184 | } 185 | } -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 Google LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /* Global stylesheet for this plugin. Not sure how it gets included, I guess by naming convention? */ 18 | /* confirmed, I think: https://marcus.se.net/obsidian-plugin-docs/user-interface/html-elements#style-your-elements */ 19 | 20 | 21 | 22 | .emoji-magic-modal .modal-close-button { 23 | display: none; /* clicking in bg and escape key still work */ 24 | } 25 | 26 | .emoji-magic-modal { 27 | /* prevent "jitter" as the content height changes when users type */ 28 | align-self: flex-start; 29 | top: 10%; 30 | 31 | width: 300px; /* because of a flex container and related rules, this is functionally a max-width */ 32 | } 33 | 34 | .emoji-magic-modal input[type="search"] { 35 | width: 100%; 36 | margin-bottom: var(--size-4-4); /* obsidian var */ 37 | } 38 | 39 | .emoji-magic-modal footer { 40 | margin-top: var(--size-4-4); /* obsidian var */ 41 | color: var(--text-faint); 42 | font-size: var(--font-smallest); 43 | } 44 | 45 | .emoji-magic-modal footer a { 46 | color: var(--text-faint); 47 | } 48 | 49 | .emoji-magic-modal footer a:hover, 50 | .emoji-magic-modal footer a:focus { 51 | color: var(--link-color); 52 | } 53 | 54 | .emoji-magic-modal ol.results { 55 | margin: 0; 56 | padding: 0; 57 | list-style: none; 58 | display: grid; 59 | grid-template-columns: repeat(auto-fill, 2rem); 60 | grid-auto-rows: 2rem; 61 | } 62 | 63 | .emoji-magic-modal .results button:disabled { 64 | cursor: default; 65 | } 66 | 67 | .emoji-magic-modal .results button { 68 | border-radius: 5px; 69 | cursor: pointer; 70 | background: transparent; 71 | border: none; 72 | box-shadow: none; 73 | -webkit-appearance: none; 74 | padding: 0; 75 | margin: 0; 76 | height: 100%; 77 | width: 100%; 78 | font-size: 1.5rem; 79 | padding-top: 4px; /* visually center the emojiiii */ 80 | overflow: hidden; /* in some cases, (ie: zwj fails) the text can overflow. don't let that confuse other parts of the UI. */ 81 | } 82 | 83 | .emoji-magic-modal .results button:focus { 84 | outline: none; 85 | background: var(--interactive-hover); 86 | } 87 | 88 | .emoji-magic-modal .results button:hover { 89 | background: var(--interactive-hover); 90 | } 91 | 92 | .emoji-magic-modal .results li { 93 | text-align: center; 94 | } 95 | 96 | /* While search is focused, highlight the first item, 97 | because "enter" will copy and close */ 98 | .emoji-magic-modal input[type="search"]:focus + .results li:first-of-type button { 99 | background: var(--interactive-hover); 100 | } 101 | 102 | .emoji-magic-modal .results li:first-of-type button { 103 | /* background: pink !important; */ 104 | } 105 | 106 | .muted { 107 | color: var(--text-faint); 108 | } 109 | 110 | .padded-sides { 111 | padding: 0 4px; 112 | } 113 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // manifest.json - bump to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | manifest.version = targetVersion; 8 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 9 | --------------------------------------------------------------------------------