new_editor: Second revision #125
|
@ -2,9 +2,9 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
.editor .btn-group .filler {
|
||||
.editor .btn-group #filler {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.editor .btn-group button {
|
||||
|
@ -34,8 +34,7 @@
|
|||
.editor .btn-group .separator {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
border-left: 1px solid #d9d9d9;
|
||||
border-right: 1px solid #ffffff;
|
||||
border: 1px solid var(--text);
|
||||
color: transparent;
|
||||
text-indent: -10px;
|
||||
}
|
||||
|
@ -43,8 +42,8 @@
|
|||
min-height: 15rem;
|
||||
}
|
||||
.editor #editor_content_preview {
|
||||
padding: 5px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
margin-top: 5px;
|
||||
border: var(--border);
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
|
||||
.filler {
|
||||
#filler {
|
||||
flex-grow: 1;
|
||||
}
|
||||
button {
|
||||
|
@ -39,12 +39,10 @@
|
|||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
// From gitea
|
||||
.separator {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
border-left: 1px solid #d9d9d9;
|
||||
border-right: 1px solid #fff;
|
||||
border: 1px solid var(--text);
|
||||
color: transparent;
|
||||
text-indent: -10px;
|
||||
}
|
||||
|
@ -55,8 +53,8 @@
|
|||
}
|
||||
|
||||
#editor_content_preview {
|
||||
padding: 5px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
margin-top: 5px;
|
||||
border: var(--border);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
|
|
@ -11,19 +11,23 @@ function editor_event_source(event)
|
|||
/* Grab the button and the parent editor block. The onclick event itself
|
||||
usually reports the SVG in the button as the source */
|
||||
let node = event.target || event.srcElement;
|
||||
while(node != document.body) {
|
||||
if(node.tagName == "BUTTON" && !button) {
|
||||
while (node != document.body) {
|
||||
if (node.tagName == "BUTTON" && !button) {
|
||||
button = node;
|
||||
}
|
||||
if(node.classList.contains("editor") && !editor) {
|
||||
if (node.classList.contains("editor") && !editor) {
|
||||
editor = node;
|
||||
// Hack to use keybinds
|
||||
if (!button) {
|
||||
button = node.firstElementChild.firstElementChild
|
||||
}
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
if(!button || !editor) return;
|
||||
if (!button || !editor) return;
|
||||
|
||||
const ta = editor.querySelector("textarea");
|
||||
const ta = editor.querySelector(".editor textarea");
|
||||
return [editor, button, ta];
|
||||
}
|
||||
|
||||
|
@ -47,7 +51,7 @@ function editor_insert_around(event, before="", after=null)
|
|||
let indexStart = ta.selectionStart;
|
||||
let indexEnd = ta.selectionEnd;
|
||||
|
||||
if(after === null) {
|
||||
if (after === null) {
|
||||
after = before;
|
||||
}
|
||||
|
||||
|
@ -55,7 +59,7 @@ function editor_insert_around(event, before="", after=null)
|
|||
before + ta.value.substring(indexStart, indexEnd) + after);
|
||||
|
||||
/* Restore selection */
|
||||
if(indexStart != indexEnd) {
|
||||
if (indexStart != indexEnd) {
|
||||
ta.selectionStart = start;
|
||||
ta.selectionEnd = end;
|
||||
}
|
||||
|
@ -76,13 +80,13 @@ function editor_act_on_lines(event, fn)
|
|||
let indexEnd = ta.selectionEnd;
|
||||
|
||||
let firstLineIndex = ta.value.substring(0, indexStart).lastIndexOf('\n');
|
||||
if(firstLineIndex < 0)
|
||||
if (firstLineIndex < 0)
|
||||
firstLineIndex = 0;
|
||||
else
|
||||
firstLineIndex += 1;
|
||||
|
||||
let lastLineIndex = ta.value.substring(indexEnd).indexOf('\n');
|
||||
if(lastLineIndex < 0)
|
||||
if (lastLineIndex < 0)
|
||||
lastLineIndex = ta.value.length;
|
||||
else
|
||||
lastLineIndex += indexEnd;
|
||||
|
@ -124,7 +128,7 @@ function editor_clear_modals(event, close = true)
|
|||
|
||||
|
||||
/* End-user functions */
|
||||
function editor_inline(event, type)
|
||||
function editor_inline(event, type, enable_preview = true)
|
||||
{
|
||||
tokens = {
|
||||
bold: "**",
|
||||
|
@ -134,11 +138,13 @@ function editor_inline(event, type)
|
|||
inlinecode: "`",
|
||||
};
|
||||
|
||||
if(type in tokens){
|
||||
if (type in tokens) {
|
||||
editor_insert_around(event, tokens[type]);
|
||||
}
|
||||
|
||||
preview();
|
||||
if (enable_preview) {
|
||||
preview();
|
||||
}
|
||||
}
|
||||
|
||||
function editor_display_link_modal(event) {
|
||||
|
@ -148,18 +154,13 @@ function editor_display_link_modal(event) {
|
|||
let selection = ta.value.substring(indexStart, indexEnd);
|
||||
|
||||
// Assuming it's a link
|
||||
if(selection.match(/^https?:\/\/\S+/)) {
|
||||
if (selection.match(/^https?:\/\/\S+/)) {
|
||||
event.currentTarget.querySelector("#link-link-input").value = selection;
|
||||
}
|
||||
// Or text
|
||||
else if(selection != "") {
|
||||
else if (selection != "") {
|
||||
event.currentTarget.querySelector("#link-desc-input").value = selection;
|
||||
}
|
||||
// Or nothing selected
|
||||
else {
|
||||
event.currentTarget.querySelector("#link-desc-input").value = "";
|
||||
event.currentTarget.querySelector("#link-link-input").value = "";
|
||||
}
|
||||
|
||||
event.currentTarget.children[1].style = {'display': 'block'};
|
||||
}
|
||||
|
@ -177,7 +178,7 @@ function editor_insert_link(event, link_id, text_id, media = false)
|
|||
|
||||
const media_selector = document.getElementsByName("media-type");
|
||||
for(i = 0; i < media_selector.length; i++) {
|
||||
if(media_selector[i].checked) {
|
||||
if (media_selector[i].checked) {
|
||||
media_type = `{type=${media_selector[i].value}}`;
|
||||
}
|
||||
}
|
||||
|
@ -188,7 +189,7 @@ function editor_insert_link(event, link_id, text_id, media = false)
|
|||
`${media ? "!" : ""}[${text.length === 0 ? ta.value.substring(indexStart, indexEnd) : text}](${link})${media ? media_type : ""}`);
|
||||
|
||||
/* Restore selection */
|
||||
if(indexStart != indexEnd) {
|
||||
if (indexStart != indexEnd) {
|
||||
ta.selectionStart = start;
|
||||
ta.selectionEnd = end;
|
||||
}
|
||||
|
@ -207,18 +208,18 @@ function editor_title(event, level, diff)
|
|||
while(count < line.length && line[count] == '#') count++;
|
||||
|
||||
let contents_index = count;
|
||||
if(count < line.length && line[count] == ' ') contents_index++;
|
||||
if (count < line.length && line[count] == ' ') contents_index++;
|
||||
let contents = line.slice(contents_index);
|
||||
|
||||
if(level > 0 || count == 1 && diff == -1) {
|
||||
if (level > 0 || count == 1 && diff == -1) {
|
||||
/* Remove the title if the corresponding level is re-requested */
|
||||
if(count == level || count == 1 && diff == -1)
|
||||
if (count == level || count == 1 && diff == -1)
|
||||
return contents;
|
||||
/* Otherwise, add it */
|
||||
else
|
||||
return '#'.repeat(level) + ' ' + contents;
|
||||
}
|
||||
else if(count > 0) {
|
||||
else if (count > 0) {
|
||||
/* Apply the difference */
|
||||
let new_level = Math.max(1, Math.min(6, count + diff));
|
||||
return '#'.repeat(new_level) + ' ' + contents;
|
||||
|
@ -235,7 +236,7 @@ function editor_quote(event)
|
|||
while(count < line.length && line[count] == '>') count++;
|
||||
|
||||
let contents_index = count;
|
||||
if(count < line.length && line[count] == ' ') contents_index++;
|
||||
if (count < line.length && line[count] == ' ') contents_index++;
|
||||
let contents = line.slice(contents_index);
|
||||
|
||||
/* Apply the difference */
|
||||
|
@ -251,7 +252,7 @@ function editor_bullet_list(event)
|
|||
let count = ident.length;
|
||||
|
||||
const contents = line.slice(count);
|
||||
if((count < line.length || count == 0) && line[count] != '-') return '- ' + contents;
|
||||
if ((count < line.length || count == 0) && line[count] != '-') return '- ' + contents;
|
||||
|
||||
return ident + "\t" + contents;
|
||||
});
|
||||
|
@ -265,7 +266,7 @@ function editor_numbered_list(event)
|
|||
let count = ident.length;
|
||||
|
||||
const contents = line.slice(count);
|
||||
if((count < line.length || count == 0) && isNaN(line[count])) return '1. ' + contents;
|
||||
if ((count < line.length || count == 0) && isNaN(line[count])) return '1. ' + contents;
|
||||
|
||||
return ident + "\t" + contents;
|
||||
});
|
||||
|
@ -283,50 +284,38 @@ function editor_separator(event) {
|
|||
editor_insert_around(event, "", "\n---\n");
|
||||
}
|
||||
|
||||
function editor_display_emoji_modal(event) {
|
||||
event.currentTarget.children[1].style = {'display': 'block'};
|
||||
}
|
||||
|
||||
previewTimeout = null;
|
||||
ta = document.querySelector(".editor textarea");
|
||||
ta.addEventListener('keydown', function(e) {
|
||||
// Tab insert some spaces
|
||||
let keyCode = e.keyCode || e.which;
|
||||
if (keyCode == 9) {
|
||||
// TODO Add one tab to selected text without replacing it
|
||||
e.preventDefault();
|
||||
const DISABLE_PREVIEW_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-slash" viewBox="0 0 16 16"><path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/><path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/><path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/></svg>';
|
||||
const ENABLE_PREVIEW_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16"><path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/><path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/></svg>';
|
||||
|
||||
let start = e.target.selectionStart;
|
||||
let end = e.target.selectionEnd;
|
||||
// set textarea value to: text before caret + tab + text after caret
|
||||
e.target.value = e.target.value.substring(0, start) + "\t" + e.target.value.substring(end);
|
||||
e.target.selectionEnd = start + 1;
|
||||
function toggle_auto_preview() {
|
||||
let auto_preview;
|
||||
if (document.cookie.split(";").some((item) => item.trim().startsWith("auto-preview="))) {
|
||||
auto_preview = document.cookie.split(";").some((item) => item.includes("auto-preview=true"));
|
||||
} else {
|
||||
auto_preview = true;
|
||||
}
|
||||
|
||||
// TODO
|
||||
// Ctrl+B adds bold
|
||||
// Ctrl+I adds italic
|
||||
// Ctrl+U adds underline
|
||||
|
||||
// Ctrl+Enter send the form
|
||||
if (e.ctrlKey && keyCode == 13) {
|
||||
let t = e.target;
|
||||
while(! (t instanceof HTMLFormElement)) {
|
||||
t = t.parentNode;
|
||||
}
|
||||
try {
|
||||
t.submit();
|
||||
} catch(exception) {
|
||||
t.submit.click();
|
||||
}
|
||||
document.cookie = `auto-preview=${!auto_preview}; max-age=31536000; SameSite=Strict; Secure`
|
||||
if (!auto_preview) {
|
||||
document.getElementById("toggle_preview").title = "Désactiver la prévisualisation";
|
||||
document.getElementById("toggle_preview").innerHTML = DISABLE_PREVIEW_ICON;
|
||||
document.getElementById("manual_preview").style = "display: none";
|
||||
} else {
|
||||
document.getElementById("toggle_preview").title = "Activer la prévisualisation";
|
||||
document.getElementById("toggle_preview").innerHTML = ENABLE_PREVIEW_ICON;
|
||||
document.getElementById("manual_preview").style = "display: block";
|
||||
}
|
||||
}
|
||||
|
||||
// Set a timeout for refreshing the preview
|
||||
if (previewTimeout != null) {
|
||||
clearTimeout(previewTimeout);
|
||||
/* This request the server to get a complete render of the current text in the textarea */
|
||||
function preview(manual=false) {
|
||||
// If auto-preview is disabled and the preview is not manually requested by the user
|
||||
if (document.cookie.split(";").some((item) => item.includes("auto-preview=false")) && !manual) {
|
||||
return;
|
||||
}
|
||||
previewTimeout = setTimeout(preview, 3000);
|
||||
});
|
||||
|
||||
|
||||
function preview() {
|
||||
const previewArea = document.querySelector("#editor_content_preview");
|
||||
|
||||
const textarea = document.querySelector(".editor textarea");
|
||||
|
@ -351,3 +340,86 @@ function preview() {
|
|||
});
|
||||
}
|
||||
|
||||
if (document.cookie.split(";").some((item) => item.trim().startsWith("auto-preview="))) {
|
||||
if (document.cookie.split(";").some((item) => item.includes("auto-preview=false"))) {
|
||||
document.getElementById("toggle_preview").title = "Activer la prévisualisation";
|
||||
document.getElementById("toggle_preview").innerHTML = ENABLE_PREVIEW_ICON;
|
||||
document.getElementById("manual_preview").style = "display: block";
|
||||
}
|
||||
}
|
||||
|
||||
let previewTimeout = null;
|
||||
let ta = document.querySelector(".editor textarea");
|
||||
ta.addEventListener('keydown', function(e) {
|
||||
// Tab insert some spaces
|
||||
let keyCode = e.keyCode || e.which;
|
||||
if (keyCode == 9) {
|
||||
// TODO Add one tab to selected text without replacing it
|
||||
e.preventDefault();
|
||||
|
||||
let start = e.target.selectionStart;
|
||||
let end = e.target.selectionEnd;
|
||||
// set textarea value to: text before caret + tab + text after caret
|
||||
e.target.value = e.target.value.substring(0, start) + "\t" + e.target.value.substring(end);
|
||||
e.target.selectionEnd = start + 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Keybindings for buttons. The default action of the keybinding is prevented.
|
||||
* Ctrl+B adds bold
|
||||
* Ctrl+I adds italic
|
||||
* Ctrl+U adds underline
|
||||
* Ctrl+S adds strikethrough
|
||||
* Ctrl+H adds Header +1
|
||||
* Ctrl+Enter send the form
|
||||
*/
|
||||
if (e.ctrlKey) {
|
||||
switch (keyCode) {
|
||||
case 13:
|
||||
let t = e.target;
|
||||
while(! (t instanceof HTMLFormElement)) {
|
||||
t = t.parentNode;
|
||||
}
|
||||
try {
|
||||
t.submit();
|
||||
} catch(exception) {
|
||||
t.submit.click();
|
||||
}
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 66: // B
|
||||
editor_inline(e, "bold", false);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 72: // H
|
||||
editor_title(e, 0, +1);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 73: // I
|
||||
editor_inline(e, "italic", false);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 83: // S
|
||||
editor_inline(e, "strike", false);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 85: // U
|
||||
editor_inline(e, "underline", false);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Set a timeout for refreshing the preview
|
||||
if (previewTimeout != null) {
|
||||
clearTimeout(previewTimeout);
|
||||
}
|
||||
previewTimeout = setTimeout(preview, 3000);
|
||||
});
|
||||
|
||||
document.querySelector('emoji-picker').addEventListener('emoji-click', event => {
|
||||
editor_clear_modals(event);
|
||||
editor_insert_around(event, "", event.detail.unicode)
|
||||
|
||||
preview();
|
||||
});
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,972 @@
|
|||
function assertNonEmptyString (str) {
|
||||
if (typeof str !== 'string' || !str) {
|
||||
throw new Error('expected a non-empty string, got: ' + str)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNumber (number) {
|
||||
if (typeof number !== 'number') {
|
||||
throw new Error('expected a number, got: ' + number)
|
||||
}
|
||||
}
|
||||
|
||||
const DB_VERSION_CURRENT = 1;
|
||||
const DB_VERSION_INITIAL = 1;
|
||||
const STORE_EMOJI = 'emoji';
|
||||
const STORE_KEYVALUE = 'keyvalue';
|
||||
const STORE_FAVORITES = 'favorites';
|
||||
const FIELD_TOKENS = 'tokens';
|
||||
const INDEX_TOKENS = 'tokens';
|
||||
const FIELD_UNICODE = 'unicode';
|
||||
const INDEX_COUNT = 'count';
|
||||
const FIELD_GROUP = 'group';
|
||||
const FIELD_ORDER = 'order';
|
||||
const INDEX_GROUP_AND_ORDER = 'group-order';
|
||||
const KEY_ETAG = 'eTag';
|
||||
const KEY_URL = 'url';
|
||||
const KEY_PREFERRED_SKINTONE = 'skinTone';
|
||||
const MODE_READONLY = 'readonly';
|
||||
const MODE_READWRITE = 'readwrite';
|
||||
const INDEX_SKIN_UNICODE = 'skinUnicodes';
|
||||
const FIELD_SKIN_UNICODE = 'skinUnicodes';
|
||||
|
||||
const DEFAULT_DATA_SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
|
||||
const DEFAULT_LOCALE = 'en';
|
||||
|
||||
// like lodash's uniqBy but much smaller
|
||||
function uniqBy (arr, func) {
|
||||
const set = new Set();
|
||||
const res = [];
|
||||
for (const item of arr) {
|
||||
const key = func(item);
|
||||
if (!set.has(key)) {
|
||||
set.add(key);
|
||||
res.push(item);
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
function uniqEmoji (emojis) {
|
||||
return uniqBy(emojis, _ => _.unicode)
|
||||
}
|
||||
|
||||
function initialMigration (db) {
|
||||
function createObjectStore (name, keyPath, indexes) {
|
||||
const store = keyPath
|
||||
? db.createObjectStore(name, { keyPath })
|
||||
: db.createObjectStore(name);
|
||||
if (indexes) {
|
||||
for (const [indexName, [keyPath, multiEntry]] of Object.entries(indexes)) {
|
||||
store.createIndex(indexName, keyPath, { multiEntry });
|
||||
}
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
createObjectStore(STORE_KEYVALUE);
|
||||
createObjectStore(STORE_EMOJI, /* keyPath */ FIELD_UNICODE, {
|
||||
[INDEX_TOKENS]: [FIELD_TOKENS, /* multiEntry */ true],
|
||||
[INDEX_GROUP_AND_ORDER]: [[FIELD_GROUP, FIELD_ORDER]],
|
||||
[INDEX_SKIN_UNICODE]: [FIELD_SKIN_UNICODE, /* multiEntry */ true]
|
||||
});
|
||||
createObjectStore(STORE_FAVORITES, undefined, {
|
||||
[INDEX_COUNT]: ['']
|
||||
});
|
||||
}
|
||||
|
||||
const openIndexedDBRequests = {};
|
||||
const databaseCache = {};
|
||||
const onCloseListeners = {};
|
||||
|
||||
function handleOpenOrDeleteReq (resolve, reject, req) {
|
||||
// These things are almost impossible to test with fakeIndexedDB sadly
|
||||
/* istanbul ignore next */
|
||||
req.onerror = () => reject(req.error);
|
||||
/* istanbul ignore next */
|
||||
req.onblocked = () => reject(new Error('IDB blocked'));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
}
|
||||
|
||||
async function createDatabase (dbName) {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(dbName, DB_VERSION_CURRENT);
|
||||
openIndexedDBRequests[dbName] = req;
|
||||
req.onupgradeneeded = e => {
|
||||
// Technically there is only one version, so we don't need this `if` check
|
||||
// But if an old version of the JS is in another browser tab
|
||||
// and it gets upgraded in the future and we have a new DB version, well...
|
||||
// better safe than sorry.
|
||||
/* istanbul ignore else */
|
||||
if (e.oldVersion < DB_VERSION_INITIAL) {
|
||||
initialMigration(req.result);
|
||||
}
|
||||
};
|
||||
handleOpenOrDeleteReq(resolve, reject, req);
|
||||
});
|
||||
// Handle abnormal closes, e.g. "delete database" in chrome dev tools.
|
||||
// No need for removeEventListener, because once the DB can no longer
|
||||
// fire "close" events, it will auto-GC.
|
||||
// Unfortunately cannot test in fakeIndexedDB: https://github.com/dumbmatter/fakeIndexedDB/issues/50
|
||||
/* istanbul ignore next */
|
||||
db.onclose = () => closeDatabase(dbName);
|
||||
return db
|
||||
}
|
||||
|
||||
function openDatabase (dbName) {
|
||||
if (!databaseCache[dbName]) {
|
||||
databaseCache[dbName] = createDatabase(dbName);
|
||||
}
|
||||
return databaseCache[dbName]
|
||||
}
|
||||
|
||||
function dbPromise (db, storeName, readOnlyOrReadWrite, cb) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use relaxed durability because neither the emoji data nor the favorites/preferred skin tone
|
||||
// are really irreplaceable data. IndexedDB is just a cache in this case.
|
||||
const txn = db.transaction(storeName, readOnlyOrReadWrite, { durability: 'relaxed' });
|
||||
const store = typeof storeName === 'string'
|
||||
? txn.objectStore(storeName)
|
||||
: storeName.map(name => txn.objectStore(name));
|
||||
let res;
|
||||
cb(store, txn, (result) => {
|
||||
res = result;
|
||||
});
|
||||
|
||||
txn.oncomplete = () => resolve(res);
|
||||
/* istanbul ignore next */
|
||||
txn.onerror = () => reject(txn.error);
|
||||
})
|
||||
}
|
||||
|
||||
function closeDatabase (dbName) {
|
||||
// close any open requests
|
||||
const req = openIndexedDBRequests[dbName];
|
||||
const db = req && req.result;
|
||||
if (db) {
|
||||
db.close();
|
||||
const listeners = onCloseListeners[dbName];
|
||||
/* istanbul ignore else */
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
delete openIndexedDBRequests[dbName];
|
||||
delete databaseCache[dbName];
|
||||
delete onCloseListeners[dbName];
|
||||
}
|
||||
|
||||
function deleteDatabase (dbName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// close any open requests
|
||||
closeDatabase(dbName);
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
handleOpenOrDeleteReq(resolve, reject, req);
|
||||
})
|
||||
}
|
||||
|
||||
// The "close" event occurs during an abnormal shutdown, e.g. a user clearing their browser data.
|
||||
// However, it doesn't occur with the normal "close" event, so we handle that separately.
|
||||
// https://www.w3.org/TR/IndexedDB/#close-a-database-connection
|
||||
function addOnCloseListener (dbName, listener) {
|
||||
let listeners = onCloseListeners[dbName];
|
||||
if (!listeners) {
|
||||
listeners = onCloseListeners[dbName] = [];
|
||||
}
|
||||
listeners.push(listener);
|
||||
}
|
||||
|
||||
// list of emoticons that don't match a simple \W+ regex
|
||||
// extracted using:
|
||||
// require('emoji-picker-element-data/en/emojibase/data.json').map(_ => _.emoticon).filter(Boolean).filter(_ => !/^\W+$/.test(_))
|
||||
const irregularEmoticons = new Set([
|
||||
':D', 'XD', ":'D", 'O:)',
|
||||
':X', ':P', ';P', 'XP',
|
||||
':L', ':Z', ':j', '8D',
|
||||
'XO', '8)', ':B', ':O',
|
||||
':S', ":'o", 'Dx', 'X(',
|
||||
'D:', ':C', '>0)', ':3',
|
||||
'</3', '<3', '\\M/', ':E',
|
||||
'8#'
|
||||
]);
|
||||
|
||||
function extractTokens (str) {
|
||||
return str
|
||||
.split(/[\s_]+/)
|
||||
.map(word => {
|
||||
if (!word.match(/\w/) || irregularEmoticons.has(word)) {
|
||||
// for pure emoticons like :) or :-), just leave them as-is
|
||||
return word.toLowerCase()
|
||||
}
|
||||
|
||||
return word
|
||||
.replace(/[)(:,]/g, '')
|
||||
.replace(/’/g, "'")
|
||||
.toLowerCase()
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
const MIN_SEARCH_TEXT_LENGTH = 2;
|
||||
|
||||
// This is an extra step in addition to extractTokens(). The difference here is that we expect
|
||||
// the input to have already been run through extractTokens(). This is useful for cases like
|
||||
// emoticons, where we don't want to do any tokenization (because it makes no sense to split up
|
||||
// ">:)" by the colon) but we do want to lowercase it to have consistent search results, so that
|
||||
// the user can type ':P' or ':p' and still get the same result.
|
||||
function normalizeTokens (str) {
|
||||
return str
|
||||
.filter(Boolean)
|
||||
.map(_ => _.toLowerCase())
|
||||
.filter(_ => _.length >= MIN_SEARCH_TEXT_LENGTH)
|
||||
}
|
||||
|
||||
// Transform emoji data for storage in IDB
|
||||
function transformEmojiData (emojiData) {
|
||||
const res = emojiData.map(({ annotation, emoticon, group, order, shortcodes, skins, tags, emoji, version }) => {
|
||||
const tokens = [...new Set(
|
||||
normalizeTokens([
|
||||
...(shortcodes || []).map(extractTokens).flat(),
|
||||
...tags.map(extractTokens).flat(),
|
||||
...extractTokens(annotation),
|
||||
emoticon
|
||||
])
|
||||
)].sort();
|
||||
const res = {
|
||||
annotation,
|
||||
group,
|
||||
order,
|
||||
tags,
|
||||
tokens,
|
||||
unicode: emoji,
|
||||
version
|
||||
};
|
||||
if (emoticon) {
|
||||
res.emoticon = emoticon;
|
||||
}
|
||||
if (shortcodes) {
|
||||
res.shortcodes = shortcodes;
|
||||
}
|
||||
if (skins) {
|
||||
res.skinTones = [];
|
||||
res.skinUnicodes = [];
|
||||
res.skinVersions = [];
|
||||
for (const { tone, emoji, version } of skins) {
|
||||
res.skinTones.push(tone);
|
||||
res.skinUnicodes.push(emoji);
|
||||
res.skinVersions.push(version);
|
||||
}
|
||||
}
|
||||
return res
|
||||
});
|
||||
return res
|
||||
}
|
||||
|
||||
// helper functions that help compress the code better
|
||||
|
||||
function callStore (store, method, key, cb) {
|
||||
store[method](key).onsuccess = e => (cb && cb(e.target.result));
|
||||
}
|
||||
|
||||
function getIDB (store, key, cb) {
|
||||
callStore(store, 'get', key, cb);
|
||||
}
|
||||
|
||||
function getAllIDB (store, key, cb) {
|
||||
callStore(store, 'getAll', key, cb);
|
||||
}
|
||||
|
||||
function commit (txn) {
|
||||
/* istanbul ignore else */
|
||||
if (txn.commit) {
|
||||
txn.commit();
|
||||
}
|
||||
}
|
||||
|
||||
// like lodash's minBy
|
||||
function minBy (array, func) {
|
||||
let minItem = array[0];
|
||||
for (let i = 1; i < array.length; i++) {
|
||||
const item = array[i];
|
||||
if (func(minItem) > func(item)) {
|
||||
minItem = item;
|
||||
}
|
||||
}
|
||||
return minItem
|
||||
}
|
||||
|
||||
// return an array of results representing all items that are found in each one of the arrays
|
||||
|
||||
function findCommonMembers (arrays, uniqByFunc) {
|
||||
const shortestArray = minBy(arrays, _ => _.length);
|
||||
const results = [];
|
||||
for (const item of shortestArray) {
|
||||
// if this item is included in every array in the intermediate results, add it to the final results
|
||||
if (!arrays.some(array => array.findIndex(_ => uniqByFunc(_) === uniqByFunc(item)) === -1)) {
|
||||
results.push(item);
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function isEmpty (db) {
|
||||
return !(await get(db, STORE_KEYVALUE, KEY_URL))
|
||||
}
|
||||
|
||||
async function hasData (db, url, eTag) {
|
||||
const [oldETag, oldUrl] = await Promise.all([KEY_ETAG, KEY_URL]
|
||||
.map(key => get(db, STORE_KEYVALUE, key)));
|
||||
return (oldETag === eTag && oldUrl === url)
|
||||
}
|
||||
|
||||
async function doFullDatabaseScanForSingleResult (db, predicate) {
|
||||
// This batching algorithm is just a perf improvement over a basic
|
||||
// cursor. The BATCH_SIZE is an estimate of what would give the best
|
||||
// perf for doing a full DB scan (worst case).
|
||||
//
|
||||
// Mini-benchmark for determining the best batch size:
|
||||
//
|
||||
// PERF=1 yarn build:rollup && yarn test:adhoc
|
||||
//
|
||||
// (async () => {
|
||||
// performance.mark('start')
|
||||
// await $('emoji-picker').database.getEmojiByShortcode('doesnotexist')
|
||||
// performance.measure('total', 'start')
|
||||
// console.log(performance.getEntriesByName('total').slice(-1)[0].duration)
|
||||
// })()
|
||||
const BATCH_SIZE = 50; // Typically around 150ms for 6x slowdown in Chrome for above benchmark
|
||||
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
|
||||
let lastKey;
|
||||
|
||||
const processNextBatch = () => {
|
||||
emojiStore.getAll(lastKey && IDBKeyRange.lowerBound(lastKey, true), BATCH_SIZE).onsuccess = e => {
|
||||
const results = e.target.result;
|
||||
for (const result of results) {
|
||||
lastKey = result.unicode;
|
||||
if (predicate(result)) {
|
||||
return cb(result)
|
||||
}
|
||||
}
|
||||
if (results.length < BATCH_SIZE) {
|
||||
return cb()
|
||||
}
|
||||
processNextBatch();
|
||||
};
|
||||
};
|
||||
processNextBatch();
|
||||
})
|
||||
}
|
||||
|
||||
async function loadData (db, emojiData, url, eTag) {
|
||||
try {
|
||||
const transformedData = transformEmojiData(emojiData);
|
||||
await dbPromise(db, [STORE_EMOJI, STORE_KEYVALUE], MODE_READWRITE, ([emojiStore, metaStore], txn) => {
|
||||
let oldETag;
|
||||
let oldUrl;
|
||||
let todo = 0;
|
||||
|
||||
function checkFetched () {
|
||||
if (++todo === 2) { // 2 requests made
|
||||
onFetched();
|
||||
}
|
||||
}
|
||||
|
||||
function onFetched () {
|
||||
if (oldETag === eTag && oldUrl === url) {
|
||||
// check again within the transaction to guard against concurrency, e.g. multiple browser tabs
|
||||
return
|
||||
}
|
||||
// delete old data
|
||||
emojiStore.clear();
|
||||
// insert new data
|
||||
for (const data of transformedData) {
|
||||
emojiStore.put(data);
|
||||
}
|
||||
metaStore.put(eTag, KEY_ETAG);
|
||||
metaStore.put(url, KEY_URL);
|
||||
commit(txn);
|
||||
}
|
||||
|
||||
getIDB(metaStore, KEY_ETAG, result => {
|
||||
oldETag = result;
|
||||
checkFetched();
|
||||
});
|
||||
|
||||
getIDB(metaStore, KEY_URL, result => {
|
||||
oldUrl = result;
|
||||
checkFetched();
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
async function getEmojiByGroup (db, group) {
|
||||
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
|
||||
const range = IDBKeyRange.bound([group, 0], [group + 1, 0], false, true);
|
||||
getAllIDB(emojiStore.index(INDEX_GROUP_AND_ORDER), range, cb);
|
||||
})
|
||||
}
|
||||
|
||||
async function getEmojiBySearchQuery (db, query) {
|
||||
const tokens = normalizeTokens(extractTokens(query));
|
||||
|
||||
if (!tokens.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
|
||||
// get all results that contain all tokens (i.e. an AND query)
|
||||
const intermediateResults = [];
|
||||
|
||||
const checkDone = () => {
|
||||
if (intermediateResults.length === tokens.length) {
|
||||
onDone();
|
||||
}
|
||||
};
|
||||
|
||||
const onDone = () => {
|
||||
const results = findCommonMembers(intermediateResults, _ => _.unicode);
|
||||
cb(results.sort((a, b) => a.order < b.order ? -1 : 1));
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
const range = i === tokens.length - 1
|
||||
? IDBKeyRange.bound(token, token + '\uffff', false, true) // treat last token as a prefix search
|
||||
: IDBKeyRange.only(token); // treat all other tokens as an exact match
|
||||
getAllIDB(emojiStore.index(INDEX_TOKENS), range, result => {
|
||||
intermediateResults.push(result);
|
||||
checkDone();
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// This could have been implemented as an IDB index on shortcodes, but it seemed wasteful to do that
|
||||
// when we can already query by tokens and this will give us what we're looking for 99.9% of the time
|
||||
async function getEmojiByShortcode (db, shortcode) {
|
||||
const emojis = await getEmojiBySearchQuery(db, shortcode);
|
||||
|
||||
// In very rare cases (e.g. the shortcode "v" as in "v for victory"), we cannot search because
|
||||
// there are no usable tokens (too short in this case). In that case, we have to do an inefficient
|
||||
// full-database scan, which I believe is an acceptable tradeoff for not having to have an extra
|
||||
// index on shortcodes.
|
||||
|
||||
if (!emojis.length) {
|
||||
const predicate = _ => ((_.shortcodes || []).includes(shortcode.toLowerCase()));
|
||||
return (await doFullDatabaseScanForSingleResult(db, predicate)) || null
|
||||
}
|
||||
|
||||
return emojis.filter(_ => {
|
||||
const lowerShortcodes = (_.shortcodes || []).map(_ => _.toLowerCase());
|
||||
return lowerShortcodes.includes(shortcode.toLowerCase())
|
||||
})[0] || null
|
||||
}
|
||||
|
||||
async function getEmojiByUnicode (db, unicode) {
|
||||
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => (
|
||||
getIDB(emojiStore, unicode, result => {
|
||||
if (result) {
|
||||
return cb(result)
|
||||
}
|
||||
getIDB(emojiStore.index(INDEX_SKIN_UNICODE), unicode, result => cb(result || null));
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
function get (db, storeName, key) {
|
||||
return dbPromise(db, storeName, MODE_READONLY, (store, txn, cb) => (
|
||||
getIDB(store, key, cb)
|
||||
))
|
||||
}
|
||||
|
||||
function set (db, storeName, key, value) {
|
||||
return dbPromise(db, storeName, MODE_READWRITE, (store, txn) => {
|
||||
store.put(value, key);
|
||||
commit(txn);
|
||||
})
|
||||
}
|
||||
|
||||
function incrementFavoriteEmojiCount (db, unicode) {
|
||||
return dbPromise(db, STORE_FAVORITES, MODE_READWRITE, (store, txn) => (
|
||||
getIDB(store, unicode, result => {
|
||||
store.put((result || 0) + 1, unicode);
|
||||
commit(txn);
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
function getTopFavoriteEmoji (db, customEmojiIndex, limit) {
|
||||
if (limit === 0) {
|
||||
return []
|
||||
}
|
||||
return dbPromise(db, [STORE_FAVORITES, STORE_EMOJI], MODE_READONLY, ([favoritesStore, emojiStore], txn, cb) => {
|
||||
const results = [];
|
||||
favoritesStore.index(INDEX_COUNT).openCursor(undefined, 'prev').onsuccess = e => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) { // no more results
|
||||
return cb(results)
|
||||
}
|
||||
|
||||
function addResult (result) {
|
||||
results.push(result);
|
||||
if (results.length === limit) {
|
||||
return cb(results) // done, reached the limit
|
||||
}
|
||||
cursor.continue();
|
||||
}
|
||||
|
||||
const unicodeOrName = cursor.primaryKey;
|
||||
const custom = customEmojiIndex.byName(unicodeOrName);
|
||||
if (custom) {
|
||||
return addResult(custom)
|
||||
}
|
||||
// This could be done in parallel (i.e. make the cursor and the get()s parallelized),
|
||||
// but my testing suggests it's not actually faster.
|
||||
getIDB(emojiStore, unicodeOrName, emoji => {
|
||||
if (emoji) {
|
||||
return addResult(emoji)
|
||||
}
|
||||
// emoji not found somehow, ignore (may happen if custom emoji change)
|
||||
cursor.continue();
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
// trie data structure for prefix searches
|
||||
// loosely based on https://github.com/nolanlawson/substring-trie
|
||||
|
||||
const CODA_MARKER = ''; // marks the end of the string
|
||||
|
||||
function trie (arr, itemToTokens) {
|
||||
const map = new Map();
|
||||
for (const item of arr) {
|
||||
const tokens = itemToTokens(item);
|
||||
for (const token of tokens) {
|
||||
let currentMap = map;
|
||||
for (let i = 0; i < token.length; i++) {
|
||||
const char = token.charAt(i);
|
||||
let nextMap = currentMap.get(char);
|
||||
if (!nextMap) {
|
||||
nextMap = new Map();
|
||||
currentMap.set(char, nextMap);
|
||||
}
|
||||
currentMap = nextMap;
|
||||
}
|
||||
let valuesAtCoda = currentMap.get(CODA_MARKER);
|
||||
if (!valuesAtCoda) {
|
||||
valuesAtCoda = [];
|
||||
currentMap.set(CODA_MARKER, valuesAtCoda);
|
||||
}
|
||||
valuesAtCoda.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const search = (query, exact) => {
|
||||
let currentMap = map;
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
const char = query.charAt(i);
|
||||
const nextMap = currentMap.get(char);
|
||||
if (nextMap) {
|
||||
currentMap = nextMap;
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (exact) {
|
||||
const results = currentMap.get(CODA_MARKER);
|
||||
return results || []
|
||||
}
|
||||
|
||||
const results = [];
|
||||
// traverse
|
||||
const queue = [currentMap];
|
||||
while (queue.length) {
|
||||
const currentMap = queue.shift();
|
||||
const entriesSortedByKey = [...currentMap.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1);
|
||||
for (const [key, value] of entriesSortedByKey) {
|
||||
if (key === CODA_MARKER) { // CODA_MARKER always comes first; it's the empty string
|
||||
results.push(...value);
|
||||
} else {
|
||||
queue.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
};
|
||||
|
||||
return search
|
||||
}
|
||||
|
||||
const requiredKeys$1 = [
|
||||
'name',
|
||||
'url'
|
||||
];
|
||||
|
||||
function assertCustomEmojis (customEmojis) {
|
||||
const isArray = customEmojis && Array.isArray(customEmojis);
|
||||
const firstItemIsFaulty = isArray &&
|
||||
customEmojis.length &&
|
||||
(!customEmojis[0] || requiredKeys$1.some(key => !(key in customEmojis[0])));
|
||||
if (!isArray || firstItemIsFaulty) {
|
||||
throw new Error('Custom emojis are in the wrong format')
|
||||
}
|
||||
}
|
||||
|
||||
function customEmojiIndex (customEmojis) {
|
||||
assertCustomEmojis(customEmojis);
|
||||
|
||||
const sortByName = (a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
|
||||
//
|
||||
// all()
|
||||
//
|
||||
const all = customEmojis.sort(sortByName);
|
||||
|
||||
//
|
||||
// search()
|
||||
//
|
||||
const emojiToTokens = emoji => (
|
||||
[...new Set((emoji.shortcodes || []).map(shortcode => extractTokens(shortcode)).flat())]
|
||||
);
|
||||
const searchTrie = trie(customEmojis, emojiToTokens);
|
||||
const searchByExactMatch = _ => searchTrie(_, true);
|
||||
const searchByPrefix = _ => searchTrie(_, false);
|
||||
|
||||
// Search by query for custom emoji. Similar to how we do this in IDB, the last token
|
||||
// is treated as a prefix search, but every other one is treated as an exact match.
|
||||
// Then we AND the results together
|
||||
const search = query => {
|
||||
const tokens = extractTokens(query);
|
||||
const intermediateResults = tokens.map((token, i) => (
|
||||
(i < tokens.length - 1 ? searchByExactMatch : searchByPrefix)(token)
|
||||
));
|
||||
return findCommonMembers(intermediateResults, _ => _.name).sort(sortByName)
|
||||
};
|
||||
|
||||
//
|
||||
// byShortcode, byName
|
||||
//
|
||||
const shortcodeToEmoji = new Map();
|
||||
const nameToEmoji = new Map();
|
||||
for (const customEmoji of customEmojis) {
|
||||
nameToEmoji.set(customEmoji.name.toLowerCase(), customEmoji);
|
||||
for (const shortcode of (customEmoji.shortcodes || [])) {
|
||||
shortcodeToEmoji.set(shortcode.toLowerCase(), customEmoji);
|
||||
}
|
||||
}
|
||||
|
||||
const byShortcode = shortcode => shortcodeToEmoji.get(shortcode.toLowerCase());
|
||||
const byName = name => nameToEmoji.get(name.toLowerCase());
|
||||
|
||||
return {
|
||||
all,
|
||||
search,
|
||||
byShortcode,
|
||||
byName
|
||||
}
|
||||
}
|
||||
|
||||
// remove some internal implementation details, i.e. the "tokens" array on the emoji object
|
||||
// essentially, convert the emoji from the version stored in IDB to the version used in-memory
|
||||
function cleanEmoji (emoji) {
|
||||
if (!emoji) {
|
||||
return emoji
|
||||
}
|
||||
delete emoji.tokens;
|
||||
if (emoji.skinTones) {
|
||||
const len = emoji.skinTones.length;
|
||||
emoji.skins = Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
emoji.skins[i] = {
|
||||
tone: emoji.skinTones[i],
|
||||
unicode: emoji.skinUnicodes[i],
|
||||
version: emoji.skinVersions[i]
|
||||
};
|
||||
}
|
||||
delete emoji.skinTones;
|
||||
delete emoji.skinUnicodes;
|
||||
delete emoji.skinVersions;
|
||||
}
|
||||
return emoji
|
||||
}
|
||||
|
||||
function warnETag (eTag) {
|
||||
if (!eTag) {
|
||||
console.warn('emoji-picker-element is more efficient if the dataSource server exposes an ETag header.');
|
||||
}
|
||||
}
|
||||
|
||||
const requiredKeys = [
|
||||
'annotation',
|
||||
'emoji',
|
||||
'group',
|
||||
'order',
|
||||
'tags',
|
||||
'version'
|
||||
];
|
||||
|
||||
function assertEmojiData (emojiData) {
|
||||
if (!emojiData ||
|
||||
!Array.isArray(emojiData) ||
|
||||
!emojiData[0] ||
|
||||
(typeof emojiData[0] !== 'object') ||
|
||||
requiredKeys.some(key => (!(key in emojiData[0])))) {
|
||||
throw new Error('Emoji data is in the wrong format')
|
||||
}
|
||||
}
|
||||
|
||||
function assertStatus (response, dataSource) {
|
||||
if (Math.floor(response.status / 100) !== 2) {
|
||||
throw new Error('Failed to fetch: ' + dataSource + ': ' + response.status)
|
||||
}
|
||||
}
|
||||
|
||||
async function getETag (dataSource) {
|
||||
const response = await fetch(dataSource, { method: 'HEAD' });
|
||||
assertStatus(response, dataSource);
|
||||
const eTag = response.headers.get('etag');
|
||||
warnETag(eTag);
|
||||
return eTag
|
||||
}
|
||||
|
||||
async function getETagAndData (dataSource) {
|
||||
const response = await fetch(dataSource);
|
||||
assertStatus(response, dataSource);
|
||||
const eTag = response.headers.get('etag');
|
||||
warnETag(eTag);
|
||||
const emojiData = await response.json();
|
||||
assertEmojiData(emojiData);
|
||||
return [eTag, emojiData]
|
||||
}
|
||||
|
||||
// TODO: including these in blob-util.ts causes typedoc to generate docs for them,
|
||||
/**
|
||||
* Convert an `ArrayBuffer` to a binary string.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```js
|
||||
* var myString = blobUtil.arrayBufferToBinaryString(arrayBuff)
|
||||
* ```
|
||||
*
|
||||
* @param buffer - array buffer
|
||||
* @returns binary string
|
||||
*/
|
||||
function arrayBufferToBinaryString(buffer) {
|
||||
var binary = '';
|
||||
var bytes = new Uint8Array(buffer);
|
||||
var length = bytes.byteLength;
|
||||
var i = -1;
|
||||
while (++i < length) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
/**
|
||||
* Convert a binary string to an `ArrayBuffer`.
|
||||
*
|
||||
* ```js
|
||||
* var myBuffer = blobUtil.binaryStringToArrayBuffer(binaryString)
|
||||
* ```
|
||||
*
|
||||
* @param binary - binary string
|
||||
* @returns array buffer
|
||||
*/
|
||||
function binaryStringToArrayBuffer(binary) {
|
||||
var length = binary.length;
|
||||
var buf = new ArrayBuffer(length);
|
||||
var arr = new Uint8Array(buf);
|
||||
var i = -1;
|
||||
while (++i < length) {
|
||||
arr[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// generate a checksum based on the stringified JSON
|
||||
async function jsonChecksum (object) {
|
||||
const inString = JSON.stringify(object);
|
||||
const inBuffer = binaryStringToArrayBuffer(inString);
|
||||
// this does not need to be cryptographically secure, SHA-1 is fine
|
||||
const outBuffer = await crypto.subtle.digest('SHA-1', inBuffer);
|
||||
const outBinString = arrayBufferToBinaryString(outBuffer);
|
||||
const res = btoa(outBinString);
|
||||
return res
|
||||
}
|
||||
|
||||
async function checkForUpdates (db, dataSource) {
|
||||
// just do a simple HEAD request first to see if the eTags match
|
||||
let emojiData;
|
||||
let eTag = await getETag(dataSource);
|
||||
if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers
|
||||
const eTagAndData = await getETagAndData(dataSource);
|
||||
eTag = eTagAndData[0];
|
||||
emojiData = eTagAndData[1];
|
||||
if (!eTag) {
|
||||
eTag = await jsonChecksum(emojiData);
|
||||
}
|
||||
}
|
||||
if (await hasData(db, dataSource, eTag)) ; else {
|
||||
if (!emojiData) {
|
||||
const eTagAndData = await getETagAndData(dataSource);
|
||||
emojiData = eTagAndData[1];
|
||||
}
|
||||
await loadData(db, emojiData, dataSource, eTag);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDataForFirstTime (db, dataSource) {
|
||||
let [eTag, emojiData] = await getETagAndData(dataSource);
|
||||
if (!eTag) {
|
||||
// Handle lack of support for ETag or Access-Control-Expose-Headers
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
|
||||
eTag = await jsonChecksum(emojiData);
|
||||
}
|
||||
|
||||
await loadData(db, emojiData, dataSource, eTag);
|
||||
}
|
||||
|
||||
class Database {
|
||||
constructor ({ dataSource = DEFAULT_DATA_SOURCE, locale = DEFAULT_LOCALE, customEmoji = [] } = {}) {
|
||||
this.dataSource = dataSource;
|
||||
this.locale = locale;
|
||||
this._dbName = `emoji-picker-element-${this.locale}`;
|
||||
this._db = undefined;
|
||||
this._lazyUpdate = undefined;
|
||||
this._custom = customEmojiIndex(customEmoji);
|
||||
|
||||
this._clear = this._clear.bind(this);
|
||||
this._ready = this._init();
|
||||
}
|
||||
|
||||
async _init () {
|
||||
const db = this._db = await openDatabase(this._dbName);
|
||||
|
||||
addOnCloseListener(this._dbName, this._clear);
|
||||
const dataSource = this.dataSource;
|
||||
const empty = await isEmpty(db);
|
||||
|
||||
if (empty) {
|
||||
await loadDataForFirstTime(db, dataSource);
|
||||
} else { // offline-first - do an update asynchronously
|
||||
this._lazyUpdate = checkForUpdates(db, dataSource);
|
||||
}
|
||||
}
|
||||
|
||||
async ready () {
|
||||
const checkReady = async () => {
|
||||
if (!this._ready) {
|
||||
this._ready = this._init();
|
||||
}
|
||||
return this._ready
|
||||
};
|
||||
await checkReady();
|
||||
// There's a possibility of a race condition where the element gets added, removed, and then added again
|
||||
// with a particular timing, which would set the _db to undefined.
|
||||
// We *could* do a while loop here, but that seems excessive and could lead to an infinite loop.
|
||||
if (!this._db) {
|
||||
await checkReady();
|
||||
}
|
||||
}
|
||||
|
||||
async getEmojiByGroup (group) {
|
||||
assertNumber(group);
|
||||
await this.ready();
|
||||
return uniqEmoji(await getEmojiByGroup(this._db, group)).map(cleanEmoji)
|
||||
}
|
||||
|
||||
async getEmojiBySearchQuery (query) {
|
||||
assertNonEmptyString(query);
|
||||
await this.ready();
|
||||
const customs = this._custom.search(query);
|
||||
const natives = uniqEmoji(await getEmojiBySearchQuery(this._db, query)).map(cleanEmoji);
|
||||
return [
|
||||
...customs,
|
||||
...natives
|
||||
]
|
||||
}
|
||||
|
||||
async getEmojiByShortcode (shortcode) {
|
||||
assertNonEmptyString(shortcode);
|
||||
await this.ready();
|
||||
const custom = this._custom.byShortcode(shortcode);
|
||||
if (custom) {
|
||||
return custom
|
||||
}
|
||||
return cleanEmoji(await getEmojiByShortcode(this._db, shortcode))
|
||||
}
|
||||
|
||||
async getEmojiByUnicodeOrName (unicodeOrName) {
|
||||
assertNonEmptyString(unicodeOrName);
|
||||
await this.ready();
|
||||
const custom = this._custom.byName(unicodeOrName);
|
||||
if (custom) {
|
||||
return custom
|
||||
}
|
||||
return cleanEmoji(await getEmojiByUnicode(this._db, unicodeOrName))
|
||||
}
|
||||
|
||||
async getPreferredSkinTone () {
|
||||
await this.ready();
|
||||
return (await get(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0
|
||||
}
|
||||
|
||||
async setPreferredSkinTone (skinTone) {
|
||||
assertNumber(skinTone);
|
||||
await this.ready();
|
||||
return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone)
|
||||
}
|
||||
|
||||
async incrementFavoriteEmojiCount (unicodeOrName) {
|
||||
assertNonEmptyString(unicodeOrName);
|
||||
await this.ready();
|
||||
return incrementFavoriteEmojiCount(this._db, unicodeOrName)
|
||||
}
|
||||
|
||||
async getTopFavoriteEmoji (limit) {
|
||||
assertNumber(limit);
|
||||
await this.ready();
|
||||
return (await getTopFavoriteEmoji(this._db, this._custom, limit)).map(cleanEmoji)
|
||||
}
|
||||
|
||||
set customEmoji (customEmojis) {
|
||||
this._custom = customEmojiIndex(customEmojis);
|
||||
}
|
||||
|
||||
get customEmoji () {
|
||||
return this._custom.all
|
||||
}
|
||||
|
||||
async _shutdown () {
|
||||
await this.ready(); // reopen if we've already been closed/deleted
|
||||
try {
|
||||
await this._lazyUpdate; // allow any lazy updates to process before closing/deleting
|
||||
} catch (err) { /* ignore network errors (offline-first) */ }
|
||||
}
|
||||
|
||||
// clear references to IDB, e.g. during a close event
|
||||
_clear () {
|
||||
// We don't need to call removeEventListener or remove the manual "close" listeners.
|
||||
// The memory leak tests prove this is unnecessary. It's because:
|
||||
// 1) IDBDatabases that can no longer fire "close" automatically have listeners GCed
|
||||
// 2) we clear the manual close listeners in databaseLifecycle.js.
|
||||
this._db = this._ready = this._lazyUpdate = undefined;
|
||||
}
|
||||
|
||||
async close () {
|
||||
await this._shutdown();
|
||||
await closeDatabase(this._dbName);
|
||||
}
|
||||
|
||||
async delete () {
|
||||
await this._shutdown();
|
||||
await deleteDatabase(this._dbName);
|
||||
}
|
||||
}
|
||||
|
||||
export { Database as default };
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'الفئات',
|
||||
emojiUnsupportedMessage: 'متصفحك لا يدعم رموز المشاعر الملونة.',
|
||||
favoritesLabel: 'المفضلة',
|
||||
loadingMessage: 'جارٍ التحميل…',
|
||||
networkErrorMessage: 'تعذر تحميل رمز مشاعر.',
|
||||
regionLabel: 'منتقي رموز المشاعر',
|
||||
searchDescription: 'عندما تكون نتائج البحث متاحة، اضغط السهم لأعلى أو لأسفل للتحديد واضغط enter للاختيار.',
|
||||
searchLabel: 'بحث',
|
||||
searchResultsLabel: 'نتائج البحث',
|
||||
skinToneDescription: 'عند توسيع النتائج، اضغط السهم لأعلى أو لأسفل للتحديد واضغط enter للاختيار.',
|
||||
skinToneLabel: 'اختر درجة لون البشرة (حاليًا {skinTone})',
|
||||
skinTonesLabel: 'درجات لون البشرة',
|
||||
skinTones: [
|
||||
'افتراضي',
|
||||
'فاتح',
|
||||
'فاتح متوسط',
|
||||
'متوسط',
|
||||
'داكن متوسط',
|
||||
'داكن'
|
||||
],
|
||||
categories: {
|
||||
custom: 'مخصص',
|
||||
'smileys-emotion': 'الوجوه الضاحكة ورموز المشاعر',
|
||||
'people-body': 'الأشخاص والجسد',
|
||||
'animals-nature': 'الحيوانات والطبيعة',
|
||||
'food-drink': 'الطعام والشراب',
|
||||
'travel-places': 'السفر والأماكن',
|
||||
activities: 'الأنشطة',
|
||||
objects: 'الأشياء',
|
||||
symbols: 'الرموز',
|
||||
flags: 'الأعلام'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Kategorien',
|
||||
emojiUnsupportedMessage: 'Dein Browser unterstützt keine farbigen Emojis.',
|
||||
favoritesLabel: 'Favoriten',
|
||||
loadingMessage: 'Wird geladen…',
|
||||
networkErrorMessage: 'Konnte Emoji nicht laden.',
|
||||
regionLabel: 'Emoji auswählen',
|
||||
searchDescription: 'Wenn Suchergebnisse verfügbar sind, wähle sie mit Pfeil rauf und runter, dann Eingabetaste, aus.',
|
||||
searchLabel: 'Suchen',
|
||||
searchResultsLabel: 'Suchergebnisse',
|
||||
skinToneDescription: 'Wenn angezeigt, nutze Pfeiltasten rauf und runter zum Auswählen, Eingabe zum Akzeptieren.',
|
||||
skinToneLabel: 'Wähle einen Hautton (aktuell {skinTone})',
|
||||
skinTonesLabel: 'Hauttöne',
|
||||
skinTones: [
|
||||
'Standard',
|
||||
'Hell',
|
||||
'Mittel-hell',
|
||||
'Mittel',
|
||||
'Mittel-dunkel',
|
||||
'Dunkel'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Benutzerdefiniert',
|
||||
'smileys-emotion': 'Smileys und Emoticons',
|
||||
'people-body': 'Menschen und Körper',
|
||||
'animals-nature': 'Tiere und Natur',
|
||||
'food-drink': 'Essen und Trinken',
|
||||
'travel-places': 'Reisen und Orte',
|
||||
activities: 'Aktivitäten',
|
||||
objects: 'Objekte',
|
||||
symbols: 'Symbole',
|
||||
flags: 'Flaggen'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Categories',
|
||||
emojiUnsupportedMessage: 'Your browser does not support color emoji.',
|
||||
favoritesLabel: 'Favorites',
|
||||
loadingMessage: 'Loading…',
|
||||
networkErrorMessage: 'Could not load emoji.',
|
||||
regionLabel: 'Emoji picker',
|
||||
searchDescription: 'When search results are available, press up or down to select and enter to choose.',
|
||||
searchLabel: 'Search',
|
||||
searchResultsLabel: 'Search results',
|
||||
skinToneDescription: 'When expanded, press up or down to select and enter to choose.',
|
||||
skinToneLabel: 'Choose a skin tone (currently {skinTone})',
|
||||
skinTonesLabel: 'Skin tones',
|
||||
skinTones: [
|
||||
'Default',
|
||||
'Light',
|
||||
'Medium-Light',
|
||||
'Medium',
|
||||
'Medium-Dark',
|
||||
'Dark'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Custom',
|
||||
'smileys-emotion': 'Smileys and emoticons',
|
||||
'people-body': 'People and body',
|
||||
'animals-nature': 'Animals and nature',
|
||||
'food-drink': 'Food and drink',
|
||||
'travel-places': 'Travel and places',
|
||||
activities: 'Activities',
|
||||
objects: 'Objects',
|
||||
symbols: 'Symbols',
|
||||
flags: 'Flags'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Categorías',
|
||||
emojiUnsupportedMessage: 'El navegador no admite emojis de color.',
|
||||
favoritesLabel: 'Favoritos',
|
||||
loadingMessage: 'Cargando…',
|
||||
networkErrorMessage: 'No se pudo cargar el emoji.',
|
||||
regionLabel: 'Selector de emojis',
|
||||
searchDescription: 'Cuando estén disponibles los resultados, pulsa la tecla hacia arriba o hacia abajo para seleccionar y la tecla intro para elegir.',
|
||||
searchLabel: 'Buscar',
|
||||
searchResultsLabel: 'Resultados de búsqueda',
|
||||
skinToneDescription: 'Cuando se abran las opciones, pulsa la tecla hacia arriba o hacia abajo para seleccionar y la tecla intro para elegir.',
|
||||
skinToneLabel: 'Elige un tono de piel ({skinTone} es el actual)',
|
||||
skinTonesLabel: 'Tonos de piel',
|
||||
skinTones: [
|
||||
'Predeterminado',
|
||||
'Claro',
|
||||
'Claro medio',
|
||||
'Medio',
|
||||
'Oscuro medio',
|
||||
'Oscuro'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Personalizado',
|
||||
'smileys-emotion': 'Emojis y emoticones',
|
||||
'people-body': 'Personas y partes del cuerpo',
|
||||
'animals-nature': 'Animales y naturaleza',
|
||||
'food-drink': 'Comida y bebida',
|
||||
'travel-places': 'Viajes y lugares',
|
||||
activities: 'Actividades',
|
||||
objects: 'Objetos',
|
||||
symbols: 'Símbolos',
|
||||
flags: 'Banderas'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Catégories',
|
||||
emojiUnsupportedMessage: 'Votre navigateur ne soutient pas les emojis en couleur.',
|
||||
favoritesLabel: 'Favoris',
|
||||
loadingMessage: 'Chargement en cours…',
|
||||
networkErrorMessage: 'Impossible de charger les emojis.',
|
||||
regionLabel: 'Choisir un emoji',
|
||||
searchDescription: 'Quand les résultats sont disponisbles, appuyez la fleche vers le haut ou le bas et la touche entrée pour choisir.',
|
||||
searchLabel: 'Rechercher',
|
||||
searchResultsLabel: 'Résultats',
|
||||
skinToneDescription: 'Quand disponible, appuyez la fleche vers le haut ou le bas et la touch entrée pour choisir.',
|
||||
skinToneLabel: 'Choisir une couleur de peau (actuellement {skinTone})',
|
||||
skinTonesLabel: 'Couleurs de peau',
|
||||
skinTones: [
|
||||
'Défaut',
|
||||
'Clair',
|
||||
'Moyennement clair',
|
||||
'Moyen',
|
||||
'Moyennement sombre',
|
||||
'Sombre'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Customisé',
|
||||
'smileys-emotion': 'Les smileyes et les émoticônes',
|
||||
'people-body': 'Les gens et le corps',
|
||||
'animals-nature': 'Les animaux et la nature',
|
||||
'food-drink': 'La nourriture et les boissons',
|
||||
'travel-places': 'Les voyages et les endroits',
|
||||
activities: 'Les activités',
|
||||
objects: 'Les objets',
|
||||
symbols: 'Les symbols',
|
||||
flags: 'Les drapeaux'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'श्रेणियाँ',
|
||||
emojiUnsupportedMessage: 'आपका ब्राउज़र कलर इमोजी का समर्थन नहीं करता।',
|
||||
favoritesLabel: 'पसंदीदा',
|
||||
loadingMessage: 'लोड हो रहा है...',
|
||||
networkErrorMessage: 'इमोजी लोड नहीं हो सके।',
|
||||
regionLabel: 'इमोजी चुननेवाला',
|
||||
searchDescription: 'जब खोज परिणाम उपलब्ध हों तो चयन करने के लिए ऊपर या नीचे दबाएं और चुनने के लिए एंटर दबाएं।',
|
||||
searchLabel: 'खोज',
|
||||
searchResultsLabel: 'खोज के परिणाम',
|
||||
skinToneDescription: 'जब विस्तृत किया जाता है तो चयन करने के लिए ऊपर या नीचे दबाएं और चुनने के लिए एंटर दबाएं।',
|
||||
skinToneLabel: 'त्वचा का रंग चुनें (वर्तमान में {skinTone})',
|
||||
skinTonesLabel: 'त्वचा के रंग',
|
||||
skinTones: [
|
||||
'डिफॉल्ट',
|
||||
'हल्का',
|
||||
'मध्यम हल्का',
|
||||
'मध्यम',
|
||||
'मध्यम गहरा',
|
||||
'गहरा'
|
||||
],
|
||||
categories: {
|
||||
custom: 'कस्टम',
|
||||
'smileys-emotion': 'स्माइली और इमोटिकॉन्स',
|
||||
'people-body': 'लोग और शरीर',
|
||||
'animals-nature': 'पशु और प्रकृति',
|
||||
'food-drink': 'खाद्य और पेय',
|
||||
'travel-places': 'यात्रा और स्थान',
|
||||
activities: 'गतिविधियां',
|
||||
objects: 'वस्तुएं',
|
||||
symbols: 'प्रतीक',
|
||||
flags: 'झंडे'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Kategori',
|
||||
emojiUnsupportedMessage: 'Browser Anda tidak mendukung emoji warna.',
|
||||
favoritesLabel: 'Favorit',
|
||||
loadingMessage: 'Memuat...',
|
||||
networkErrorMessage: 'Tidak dapat memuat emoji.',
|
||||
regionLabel: 'Pemilih emoji',
|
||||
searchDescription: 'Ketika hasil pencarian tersedia, tekan atas atau bawah untuk menyeleksi dan enter untuk memilih.',
|
||||
searchLabel: 'Cari',
|
||||
searchResultsLabel: 'Hasil Pencarian',
|
||||
skinToneDescription: 'Saat diperluas tekan atas atau bawah untuk menyeleksi dan enter untuk memilih.',
|
||||
skinToneLabel: 'Pilih warna skin (saat ini {skinTone})',
|
||||
skinTonesLabel: 'Warna skin',
|
||||
skinTones: [
|
||||
'Default',
|
||||
'Light',
|
||||
'Medium light',
|
||||
'Medium',
|
||||
'Medium dark',
|
||||
'Dark'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Kustom',
|
||||
'smileys-emotion': 'Smiley dan emoticon',
|
||||
'people-body': 'Orang dan bagian tubuh',
|
||||
'animals-nature': 'Hewan dan tumbuhan',
|
||||
'food-drink': 'Makanan dan minuman',
|
||||
'travel-places': 'Rekreasi dan tempat',
|
||||
activities: 'Aktivitas',
|
||||
objects: 'Objek',
|
||||
symbols: 'Simbol',
|
||||
flags: 'Bendera'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Categorie',
|
||||
emojiUnsupportedMessage: 'Il tuo browser non supporta le emoji colorate.',
|
||||
favoritesLabel: 'Preferiti',
|
||||
loadingMessage: 'Caricamento...',
|
||||
networkErrorMessage: 'Impossibile caricare le emoji.',
|
||||
regionLabel: 'Selezione emoji',
|
||||
searchDescription: 'Quando i risultati della ricerca sono disponibili, premi su o giù per selezionare e invio per scegliere.',
|
||||
searchLabel: 'Cerca',
|
||||
searchResultsLabel: 'Risultati di ricerca',
|
||||
skinToneDescription: 'Quando espanso, premi su o giù per selezionare e invio per scegliere.',
|
||||
skinToneLabel: 'Scegli una tonalità della pelle (corrente {skinTone})',
|
||||
skinTonesLabel: 'Tonalità della pelle',
|
||||
skinTones: [
|
||||
'Predefinita',
|
||||
'Chiara',
|
||||
'Medio-Chiara',
|
||||
'Media',
|
||||
'Medio-Scura',
|
||||
'Scura'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Personalizzata',
|
||||
'smileys-emotion': 'Faccine ed emozioni',
|
||||
'people-body': 'Persone e corpi',
|
||||
'animals-nature': 'Animali e natura',
|
||||
'food-drink': 'Cibi e bevande',
|
||||
'travel-places': 'Viaggi e luoghi',
|
||||
activities: 'Attività',
|
||||
objects: 'Oggetti',
|
||||
symbols: 'Simboli',
|
||||
flags: 'Bandiere'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Kategori',
|
||||
emojiUnsupportedMessage: 'Penyemak imbas anda tidak menyokong emoji warna.',
|
||||
favoritesLabel: 'Kegemaran',
|
||||
loadingMessage: 'Memuat…',
|
||||
networkErrorMessage: 'Tidak dapat memuatkan emoji.',
|
||||
regionLabel: 'Pemilih emoji',
|
||||
searchDescription: 'Apabila hasil carian tersedia, tekan atas atau bawah untuk memilih dan tekan masukkan untuk memilih.',
|
||||
searchLabel: 'Cari',
|
||||
searchResultsLabel: 'Hasil carian',
|
||||
skinToneDescription: 'Apabila dikembangkan, tekan atas atau bawah untuk memilih dan tekan masukkan untuk memilih.',
|
||||
skinToneLabel: 'Pilih warna kulit (pada masa ini {skinTone})',
|
||||
skinTonesLabel: 'Warna kulit',
|
||||
skinTones: [
|
||||
'Lalai',
|
||||
'Cerah',
|
||||
'Kuning langsat',
|
||||
'Sederhana cerah',
|
||||
'Sawo matang',
|
||||
'Gelap'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Tersuai',
|
||||
'smileys-emotion': 'Smiley dan emotikon',
|
||||
'people-body': 'Orang dan badan',
|
||||
'animals-nature': 'Haiwan dan alam semula jadi',
|
||||
'food-drink': 'Makanan dan minuman',
|
||||
'travel-places': 'Perjalanan dan tempat',
|
||||
activities: 'Aktiviti',
|
||||
objects: 'Objek',
|
||||
symbols: 'Simbol',
|
||||
flags: 'Bendera'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Categorieën',
|
||||
emojiUnsupportedMessage: 'Uw browser ondersteunt geen kleurenemoji.',
|
||||
favoritesLabel: 'Favorieten',
|
||||
loadingMessage: 'Bezig met laden…',
|
||||
networkErrorMessage: 'Kan emoji niet laden.',
|
||||
regionLabel: 'Emoji-kiezer',
|
||||
searchDescription: 'Als er zoekresultaten beschikbaar zijn, drukt u op omhoog of omlaag om te selecteren en op enter om te kiezen.',
|
||||
searchLabel: 'Zoeken',
|
||||
searchResultsLabel: 'Zoekresultaten',
|
||||
skinToneDescription: 'Wanneer uitgevouwen, druk omhoog of omlaag om te selecteren en enter om te kiezen.',
|
||||
skinToneLabel: 'Kies een huidskleur (momenteel {skinTone})',
|
||||
skinTonesLabel: 'Huidskleuren',
|
||||
skinTones: [
|
||||
'Standaard',
|
||||
'Licht',
|
||||
'Medium-Licht',
|
||||
'Medium',
|
||||
'Middeldonker',
|
||||
'Donker'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Aangepast',
|
||||
'smileys-emotion': 'Smileys en emoticons',
|
||||
'people-body': 'Mensen en lichaam',
|
||||
'animals-nature': 'Dieren en natuur',
|
||||
'food-drink': 'Eten en drinken',
|
||||
'travel-places': 'Reizen en plaatsen',
|
||||
activities: 'Activiteiten',
|
||||
objects: 'Voorwerpen',
|
||||
symbols: 'Symbolen',
|
||||
flags: 'Vlaggen'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Kategorie',
|
||||
emojiUnsupportedMessage: 'Twoja przeglądarka nie wspiera kolorowych emotikon.',
|
||||
favoritesLabel: 'Ulubione',
|
||||
loadingMessage: 'Ładuję…',
|
||||
networkErrorMessage: 'Nie można załadować emoji.',
|
||||
regionLabel: 'Selektor emoji',
|
||||
searchDescription: 'Kiedy wyniki wyszukiwania będą dostępne, wciśnij góra lub dół aby wybrać oraz enter aby zatwierdzić wybór.',
|
||||
searchLabel: 'Wyszukaj',
|
||||
searchResultsLabel: 'Wyniki wyszukiwania',
|
||||
skinToneDescription: 'Po rozwinięciu wciśnij góra lub dół aby wybrać oraz enter aby zatwierdzić wybór.',
|
||||
skinToneLabel: 'Wybierz odcień skóry (aktualnie {skinTone})',
|
||||
skinTonesLabel: 'Odcienie skóry',
|
||||
skinTones: [
|
||||
'Domyślna',
|
||||
'Jasna',
|
||||
'Średnio-jasna',
|
||||
'Średnia',
|
||||
'Średnio-ciemna',
|
||||
'Ciemna'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Własne',
|
||||
'smileys-emotion': 'Uśmiechy',
|
||||
'people-body': 'Ludzie',
|
||||
'animals-nature': 'Zwierzęta i natura',
|
||||
'food-drink': 'Żywność i napoje',
|
||||
'travel-places': 'Podróże i miejsca',
|
||||
activities: 'Aktywności',
|
||||
objects: 'Obiekty',
|
||||
symbols: 'Symbole',
|
||||
flags: 'Flagi'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Categorias',
|
||||
emojiUnsupportedMessage: 'Seu navegador não suporta emojis coloridos.',
|
||||
favoritesLabel: 'Favoritos',
|
||||
loadingMessage: 'Carregando…',
|
||||
networkErrorMessage: 'Não foi possível carregar o emoji.',
|
||||
regionLabel: 'Seletor de emoji',
|
||||
searchDescription: 'Quando os resultados da pesquisa estiverem disponíveis, pressione para cima ou para baixo para selecionar e “enter” para escolher.',
|
||||
searchLabel: 'Procurar',
|
||||
searchResultsLabel: 'Resultados da pesquisa',
|
||||
skinToneDescription: 'Quando expandido, pressione para cima ou para baixo para selecionar e “enter” para escolher.',
|
||||
skinToneLabel: 'Escolha um tom de pele (atualmente {skinTone})',
|
||||
skinTonesLabel: 'Tons de pele',
|
||||
skinTones: [
|
||||
'Padrão',
|
||||
'Claro',
|
||||
'Claro médio',
|
||||
'Médio',
|
||||
'Escuro médio',
|
||||
'Escuro'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Personalizar',
|
||||
'smileys-emotion': 'Carinhas e emoticons',
|
||||
'people-body': 'Pessoas e corpo',
|
||||
'animals-nature': 'Animais e natureza',
|
||||
'food-drink': 'Alimentos e bebidas',
|
||||
'travel-places': 'Viagem e lugares',
|
||||
activities: 'Atividades',
|
||||
objects: 'Objetos',
|
||||
symbols: 'Símbolos',
|
||||
flags: 'Bandeiras'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Categorias',
|
||||
emojiUnsupportedMessage: 'O seu browser não suporta emojis.',
|
||||
favoritesLabel: 'Favoritos',
|
||||
loadingMessage: 'A Carregar…',
|
||||
networkErrorMessage: 'Não foi possível carregar o emoji.',
|
||||
regionLabel: 'Emoji picker',
|
||||
searchDescription: 'Quando os resultados da pesquisa estiverem disponíveis, pressione para cima ou para baixo para selecionar e digite para escolher.',
|
||||
searchLabel: 'Procurar',
|
||||
searchResultsLabel: 'Resultados da procura',
|
||||
skinToneDescription: 'Quando expandido, pressione para cima ou para baixo para selecionar e digite para escolher.',
|
||||
skinToneLabel: 'Escolha um tom de pele (atual {skinTone})',
|
||||
skinTonesLabel: 'Tons de pele',
|
||||
skinTones: [
|
||||
'Pré-definido',
|
||||
'Claro',
|
||||
'Médio-Claro',
|
||||
'Médio',
|
||||
'Médio-Escuro',
|
||||
'Escuro'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Personalizados',
|
||||
'smileys-emotion': 'Smileys e emoticons',
|
||||
'people-body': 'Pessoas e corpo',
|
||||
'animals-nature': 'Animais e natureza',
|
||||
'food-drink': 'Comida e bebida',
|
||||
'travel-places': 'Viagens e locais',
|
||||
activities: 'Atividades',
|
||||
objects: 'Objetos',
|
||||
symbols: 'Símbolos',
|
||||
flags: 'Bandeiras'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Категории',
|
||||
emojiUnsupportedMessage: 'Ваш браузер не поддерживает цветные эмодзи.',
|
||||
favoritesLabel: 'Избранное',
|
||||
loadingMessage: 'Загрузка…',
|
||||
networkErrorMessage: 'Не удалось загрузить эмодзи. Попробуйте перезагрузить страницу.',
|
||||
regionLabel: 'Выберите эмодзи',
|
||||
searchDescription: 'Когда результаты поиска станут доступны, выберите их с помощью стрелок вверх и вниз, затем нажмите для подтверждения.',
|
||||
searchLabel: 'Искать',
|
||||
searchResultsLabel: 'Результаты поиска',
|
||||
skinToneDescription: 'При отображении используйте клавиши со стрелками вверх и вниз для выбора, нажмите для подтверждения.',
|
||||
skinToneLabel: 'Выберите оттенок кожи (текущий {skinTone})',
|
||||
skinTonesLabel: 'Оттенки кожи',
|
||||
skinTones: [
|
||||
'Стандартный',
|
||||
'Светлый',
|
||||
'Средне-светлый',
|
||||
'Средний',
|
||||
'Средне-темный',
|
||||
'Темный'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Пользовательский',
|
||||
'smileys-emotion': 'Смайлики и Эмотиконы',
|
||||
'people-body': 'Люди и Тела',
|
||||
'animals-nature': 'Животные и Природа',
|
||||
'food-drink': 'Еда и Напитки',
|
||||
'travel-places': 'Путешествия и Места',
|
||||
activities: 'Виды деятельности',
|
||||
objects: 'Объекты',
|
||||
symbols: 'Символы',
|
||||
flags: 'Флаги'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export default {
|
||||
categoriesLabel: 'Kategoriler',
|
||||
emojiUnsupportedMessage: 'Tarayıcınız renkli emojiyi desteklemiyor.',
|
||||
favoritesLabel: 'Favoriler',
|
||||
loadingMessage: 'Yükleniyor…',
|
||||
networkErrorMessage: 'Emoji yüklenemedi.',
|
||||
regionLabel: 'Emoji seçici',
|
||||
searchDescription: 'Arama sonuçları mevcut olduğunda seçmek için yukarı veya aşağı basın ve seçmek için girin.',
|
||||
searchLabel: 'Arama',
|
||||
searchResultsLabel: 'Arama sonuçları',
|
||||
skinToneDescription: 'Genişletildiğinde seçmek için yukarı veya aşağı basın ve seçmek için girin.',
|
||||
skinToneLabel: 'Cilt tonu seçin (şu anda {skinTone})',
|
||||
skinTonesLabel: 'Cilt tonları',
|
||||
skinTones: [
|
||||
'Varsayılan',
|
||||
'Işık',
|
||||
'Orta ışık',
|
||||
'Orta',
|
||||
'Orta koyu',
|
||||
'Karanlık'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Gelenek',
|
||||
'smileys-emotion': 'Suratlar ve ifadeler',
|
||||
'people-body': 'İnsanlar ve vücut',
|
||||
'animals-nature': 'Hayvanlar ve doğa',
|
||||
'food-drink': 'Yiyecek ve içecek',
|
||||
'travel-places': 'Seyahat ve yerler',
|
||||
activities: 'Aktiviteler',
|
||||
objects: 'Nesneler',
|
||||
symbols: 'Semboller',
|
||||
flags: 'Bayraklar'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
export default {
|
||||
categoriesLabel: '类别',
|
||||
emojiUnsupportedMessage: '您的浏览器不支持彩色表情符号。',
|
||||
favoritesLabel: '收藏夹',
|
||||
loadingMessage: '正在加载…',
|
||||
networkErrorMessage: '无法加载表情符号。',
|
||||
regionLabel: '表情符号选择器',
|
||||
searchDescription: '当搜索结果可用时,按向上或向下选择并输入选择。',
|
||||
searchLabel: '搜索',
|
||||
searchResultsLabel: '搜索结果',
|
||||
skinToneDescription: '展开时,按向上或向下键进行选择,按回车键进行选择。',
|
||||
skinToneLabel: '选择肤色(当前为 {skinTone})',
|
||||
skinTonesLabel: '肤色',
|
||||
skinTones: ['默认', '明亮', '微亮', '中等', '微暗', '暗'],
|
||||
categories: {
|
||||
custom: '自定义',
|
||||
'smileys-emotion': '笑脸和表情',
|
||||
'people-body': '人物和身体',
|
||||
'animals-nature': '动物与自然',
|
||||
'food-drink': '食品饮料',
|
||||
'travel-places': '旅行和地方',
|
||||
activities: '活动',
|
||||
objects: '物体',
|
||||
symbols: '符号',
|
||||
flags: '旗帜'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import Picker from './picker.js'
|
||||
import Database from './database.js'
|
||||
export { Picker, Database }
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -46,7 +46,7 @@ let mouse_trigger = function(event) {
|
|||
if(!menu.contains(event.target)) {
|
||||
let active = -1;
|
||||
|
||||
for(i = 0; i < buttons.length; i++) {
|
||||
for(let i = 0; i < buttons.length; i++) {
|
||||
if(buttons[i].contains(event.target))
|
||||
active = i;
|
||||
buttons[i].querySelector('a').blur();
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
{% for s in scripts %}
|
||||
<script type="text/javascript" src={{url_for('static', filename=s)}}></script>
|
||||
{% endfor %}
|
||||
<script type="module" src={{url_for('static', filename='scripts/emoji-picker-element/index.js')}}></script>
|
||||
|
|
|
@ -80,8 +80,7 @@
|
|||
</button>
|
||||
|
||||
<span class="separator"></span>
|
||||
<!-- Table, Separators, Images, Link, Upload -->
|
||||
<!-- I need to find a way to replace the LF with \n -->
|
||||
<!-- Table, Separators -->
|
||||
<button type="button" onclick="editor_table(event)" title="Tableau">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M3 3v18h18V3H3zm8 16H5v-6h6v6zm0-8H5V5h6v6zm8 8h-6v-6h6v6zm0-8h-6V5h6v6z"/>
|
||||
|
@ -92,13 +91,17 @@
|
|||
<rect fill-rule="evenodd" height="2" width="16" x="4" y="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- This button is for an upgrade, it will be an emoji selector.
|
||||
<button type="button" onclick="editor_btn_type()" title="Emoji">
|
||||
<!-- Emojis -->
|
||||
<button type="button" onclick="editor_display_emoji_modal(event)" title="Emoji">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/>
|
||||
</svg>
|
||||
|
||||
<div class="modal" style=display:none>
|
||||
<emoji-picker data-source={{url_for('static', filename='scripts/emoji-picker-element/data.json')}}></emoji-picker>
|
||||
</div>
|
||||
</button>
|
||||
-->
|
||||
<!-- Links -->
|
||||
<button type="button" onclick="editor_display_link_modal(event)" title="Lien">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
|
||||
|
@ -116,6 +119,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Images -->
|
||||
<button type="button" onclick="event.currentTarget.children[1].style = {'display': 'block'}" title="Image">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||
|
@ -142,6 +146,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Upload -->
|
||||
<!-- Disabled because we have something similar under the form
|
||||
<button type="button" onclick="editor_btn_type()" title="Joindre un fichier">
|
||||
<svg viewBox="0 0 24 24">
|
||||
|
@ -149,7 +154,20 @@
|
|||
</svg>
|
||||
</button>
|
||||
-->
|
||||
<div class="filler"></div>
|
||||
<div id="filler"></div>
|
||||
<button id="manual_preview" type="button" onclick="preview(manual=true)" style="display: none" title="Rafraichir la prévisualisation">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="toggle_preview" type="button" onclick="toggle_auto_preview()" title="Désactiver la prévisualisation">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-slash" viewBox="0 0 16 16">
|
||||
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
|
||||
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
|
||||
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="#">Aide</a>
|
||||
</div>
|
||||
|
||||
|
@ -157,17 +175,12 @@
|
|||
{{ field.label if label }}
|
||||
{{ field() }}
|
||||
|
||||
<div id="editor_content_preview">
|
||||
</div>
|
||||
<!-- Comment preview -->
|
||||
<div id="editor_content_preview"></div>
|
||||
|
||||
<!-- Display errors -->
|
||||
{% for error in field.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Load the script -->
|
||||
<script>
|
||||
window.addEventListener("load", function(){});
|
||||
</script>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
|
Loading…
Reference in New Issue