您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Set your own keyboard shortcuts for entity view page on Kanka.
当前为
// ==UserScript== // @name Kanka Keybinds // @namespace https://greatest.deepsurf.us/en/users/1029479-infinitegeek // @version 0.9.5 // @description Set your own keyboard shortcuts for entity view page on Kanka. // @author InfiniteGeek // @supportURL Infinite @ https://discord.gg/rhsyZJ4 // @license MIT // @match https://app.kanka.io/w/*/entities/* // @icon https://www.google.com/s2/favicons?domain=kanka.io // @keywords kanka,keybinds,keyboard,shortcuts,hotkeys,tag,location // @run-at document-idle // @grant none // @require https://craig.global.ssl.fastly.net/js/mousetrap/mousetrap.min.js?a4098 // ==/UserScript== /******/ (() => { // webpackBootstrap /******/ "use strict"; /******/ var __webpack_modules__ = ({ /***/ 519: /***/ (function(__unused_webpack_module, exports, __webpack_require__) { var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; var _a, _b, _c, _d, _e, _f; Object.defineProperty(exports, "__esModule", ({ value: true })); /* ==================================== You can change these keybinds ==================================== */ const keybinds = { LABEL: 'l', MOVE: 'm', HELP: '?', }; /* ## Combination of keys - generic mod helper sets cross platform shortcuts 'mod+s' => command+s / ctrl+s ## Sequence of keys - keys separated by a space will be considered a sequence 'g i' ## Shift key - handled magically '?' instead of 'shift+/' ## Text fields - keyboard events will not fire in textarea, input, or select enable on an element with [class='mousetrap'] */ /* ======================================================= You probably shouldn't edit below... probably. Here there be `Dragons : Reptile<Mythical>[]` ======================================================= */ const mousetrap_1 = __importDefault(__webpack_require__(802)); // import tippy from 'tippy'; const emit_debug = console.log; // this is a jQuery 'plugin' to make an element blink $.prototype.blink = function (times, duration) { for (let i = 0; i < times; i++) { this.animate({ opacity: 0 }, duration) .animate({ opacity: 1 }, duration); } return this; }; /** * Extract metadata from the classes on the <body> */ function parseBodyClasses(body) { const classes = Array.from(body.classList); const entity = { id: '', entityType: 'default', type: '' }; const tags = []; const regex = /^kanka-(\w+)-(\w+)$/; let tempTag = null; classes.forEach(className => { const match = className.match(regex); if (match) { const [, key, value] = match; const isValueNumeric = !isNaN(Number(value)); switch (key) { case 'entity': entity[isValueNumeric ? 'id' : 'entityType'] = value; break; case 'type': entity.type = value; break; case 'tag': if (isValueNumeric) { tempTag = value; } else { tags.push({ id: tempTag, entityType: value, }); tempTag = null; } break; default: emit_debug("what's this?", match); break; } } }); return { entity, tags }; } const route = window.location.pathname; // using the edit button is necessary to get the typedID and the plural :\ const editButtonLink = (_a = $('div#entity-submenu a[href$="edit"]').attr('href')) !== null && _a !== void 0 ? _a : $('div.header-buttons a[href$="edit"]').attr('href'); /** * This contains "all" the Kanka-specific data */ const kanka = { rootUri: 'https://app.kanka.io', getUri: (...segments) => [kanka.rootUri, 'w', kanka.campaignID, ...segments].join('/'), /** * Ye olde CSRF token */ csrfToken: (_b = document.head.querySelector('meta[name="csrf-token"]')) === null || _b === void 0 ? void 0 : _b.getAttribute('content'), route, campaignID: ((_c = route.match(/w\/(\d+)\//)) !== null && _c !== void 0 ? _c : [null, '0'])[1], /** * this is the plural, not values from EntityType */ entityType: ((_d = editButtonLink === null || editButtonLink === void 0 ? void 0 : editButtonLink.match(/\/(\w+)\/\d+\/edit$/)) !== null && _d !== void 0 ? _d : [null, '0'])[1], /** * this is the 'larger' ID: entities/__[5328807]__ === characters/1357612 */ entityID: ((_e = route.match(/w\/\d+\/entities\/(\d+)/)) !== null && _e !== void 0 ? _e : [null, '0'])[1], /** * this is the 'smaller' ID: entities/5328807 === characters/__[1357612]__ */ typedID: ((_f = editButtonLink === null || editButtonLink === void 0 ? void 0 : editButtonLink.match(/\/(\d+)\/edit$/)) !== null && _f !== void 0 ? _f : [null, '0'])[1], meta: parseBodyClasses(document.body), /** * this encapsulates the definitions from the system * - some entities have a location, some don't * - some entities have a link in the header, some use the sidebar * - some entities can have multiple locations, some can't */ entityTypeHasLocation: ({ default: {}, character: { headerLink: true }, location: { headerLink: true }, map: { headerLink: true }, organisation: { sidebarLink: true }, family: { headerLink: true }, creature: { sidebarLink: true, multiple: true }, race: { sidebarLink: true, multiple: true }, event: { sidebarLink: true }, journal: { sidebarLink: true }, item: { sidebarLink: true }, tag: {}, note: {}, quest: {}, }), }; const identifiers = { Sidebar: { Class: '.entity-sidebar', ProfileClass: '.sidebar-section-profile', ProfileElementsID: '#sidebar-profile-elements', }, }; const templates = { SIDEBAR_PROFILE: () => ` <div class="sidebar-section-box ${identifiers.Sidebar.ProfileClass.slice(1)} overflow-hidden flex flex-col gap-2"> <div class="sidebar-section-title cursor-pointer text-lg user-select border-b element-toggle" data-animate="collapse" data-target="#sidebar-profile-elements"> <i class="fa-solid fa-chevron-up icon-show"></i> <i class="fa-solid fa-chevron-down icon-hide"></i> Profile </div> <div class="sidebar-elements grid overflow-hidden" id="${identifiers.Sidebar.ProfileElementsID.slice(1)}"> </div> </div>`.trim(), SELECT_ELEMENT: (dataUrl, placeholder) => ` <select class="form-tags select2" style="width: 100%" data-url="${dataUrl}" data-allow-new="false" data-allow-clear="true" data-placeholder="${placeholder}" data-dropdown-parent="#app" </select>`.trim(), SELECT_ITEM: (text, image) => { if (!!image) { return $(` <span class="flex gap-2 items-center text-left"> <img src="${image}" class="rounded-full flex-none w-6 h-6" /> <span class="grow">${text}</span> </span>`.trim()); } return $(`<span>${text}</span>`); }, TAG_SELECT: () => templates.SELECT_ELEMENT(kanka.getUri('search/tags'), 'Apply Tag'), TAG_URL: (tagID) => kanka.getUri('tags', tagID), TAG_LINK: (tagID, text) => ` <a href="${templates.TAG_URL(tagID)}" title="Refresh to get full tooltip functionality"> <span class="badge color-tag rounded-sm px-2 py-1">${text}</span> </a>`.trim(), LOCATION_SELECT: () => templates.SELECT_ELEMENT(kanka.getUri('search/locations'), 'Move to...'), LOCATION_URL: (locationID) => kanka.getUri('entities', locationID), LOCATION_LINK: (locationID, text) => `<a class="name" href="${templates.LOCATION_URL(locationID)}" title="Refresh to get full tooltip functionality">${text}</a>`, // TODO - get popper/tippy working to enable preview tooltips // data-toggle="tooltip-ajax" data-id="${locationID}" data-url="${templates.LOCATION_URL(locationID)}/tooltip"> }; /// making my own container for the select to avoid any interference function createFloatingElement(template) { let floatingDiv = document.getElementById('#infinite-select2'); if (!floatingDiv) { floatingDiv = document.createElement('div'); floatingDiv.id = 'infinite-select2'; // Add styles to make it float and position it as needed floatingDiv.style.position = 'absolute'; floatingDiv.style.top = '5%'; floatingDiv.style.left = '41%'; floatingDiv.style.minWidth = '200px'; floatingDiv.style.width = '18%'; floatingDiv.style.maxWidth = '400px'; } floatingDiv.innerHTML = ''; $(template()).appendTo(floatingDiv); document.body.appendChild(floatingDiv); return floatingDiv; } function createPostParams() { const params = new URLSearchParams(); params.append('_token', kanka.csrfToken); params.append('datagrid-action', 'batch'); // this needs the plural params.append('entity', kanka.entityType); params.append('mode', 'table'); // typedID is different from entityID params.append('models', kanka.typedID); params.append('undefined', ''); return params; } async function fetch_success(response) { var _a; emit_debug('Success:', response); return { ok: response.ok, document: (_a = $.parseHTML(await response.text())) !== null && _a !== void 0 ? _a : [] }; } function post(url, body) { return fetch(url, { method: 'POST', redirect: 'follow', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }) .then(fetch_success) .catch((error) => { console.error('Error:', error); return { ok: false, document: [], error }; }); } async function edit(body) { // wat da faq emit_debug({ edit_data: [...body.entries()] }); var xhr = new XMLHttpRequest(); xhr.withCredentials = true; xhr.open('POST', kanka.getUri(kanka.entityType, kanka.typedID), false); xhr.setRequestHeader('x-csrf-token', kanka.csrfToken); xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest'); xhr.send(body); emit_debug({ req: xhr }); return { ok: xhr.status == 200, document: $.parseHTML(xhr.responseText), }; return fetch(kanka.getUri(kanka.entityType, kanka.typedID), { method: 'POST', headers: { "x-csrf-token": kanka.csrfToken, "x-requested-with": "XMLHttpRequest" }, redirect: 'follow', body, }) .then(fetch_success) .catch((error) => { console.error('Error:', error); return { ok: false, document: [], error }; }); } /** * Reacts when Location is selected via floaty dropdown. Sets the Location of the entity. * * @param event - The Select2 event object. * @returns A promise that resolves to a boolean indicating whether the processing was successful. */ async function processLocationSelection(event) { const { id: locationID, text } = event.params.data; const thisEntityTypeHasLocation = kanka.entityTypeHasLocation[kanka.meta.entity.entityType]; if (thisEntityTypeHasLocation.multiple) { alert('This entity type can have multiple locations. This feature is not yet implemented.'); /** * For the curious, it's because the edit endpoint needs: * - the list of typed IDs (which we don't have) * - some weird voodoo with XHR that I can't replicate (I get a 405 Method Not Allowed) */ return false; const data = new FormData(); data.append('_token', kanka.csrfToken); // this is kinda BS, but it's the cleanest way to get // - the list of typed IDs // - the other stuff const editable = await fetch(kanka.getUri('creatures', kanka.typedID, 'edit'), { method: 'GET', headers: { 'Content-Type': 'text/html' } }) .then(fetch_success); if (!editable.ok) { emit_debug('Error:', editable); return false; } $(editable.document) .find('form#entity-form') .serializeArray() // .filter(kvp => { // if (kvp.value == '') return false; // if (kvp.value == '0') return false; // if (kvp.value == 'inherit') return false; // }) .forEach(kvp => data.append(kvp.name, kvp.value)); data.append('locations[]', locationID); const response = await edit(data); if (response.ok) { const doc = $(response.document); emit_debug({ header: doc.find('.entity-header'), sidebar: doc.find('#sidebar-profile-elements'), }); } return response.ok; } const params = createPostParams(); params.append('location_id', locationID); const response = await post(kanka.getUri('bulk/process'), params); if (!response.ok) { emit_debug('Error:', response); return false; } const sub = (selector) => { $(selector).replaceWith($(response.document).find(selector)); return $(selector); }; const ensure = (parent, selector, defaultValue) => { if ($(selector).length == 0) { emit_debug(`adding ${selector} to ${parent}`); parent.append(defaultValue); } }; if (thisEntityTypeHasLocation.headerLink) { // TODO [2024-03-06] - reduce the replacement scope to keep more functionality sub('.entity-header') .find('.entity-header-sub') .blink(3, 125); } if (thisEntityTypeHasLocation.sidebarLink) { const sidebar = $(identifiers.Sidebar.Class); // make sure the sidebar has the relevant childrens ensure(sidebar, identifiers.Sidebar.ProfileClass, templates.SIDEBAR_PROFILE()); // an entity might have the sidebar, but not the Profile block ensure(sidebar, identifiers.Sidebar.ProfileElementsID, `<div id="${identifiers.Sidebar.ProfileElementsID.slice(1)}"></div>`); // and the Profile block may or may not have the Location ensure(sidebar.find(identifiers.Sidebar.ProfileElementsID), '.profile-location', `<div class="profile-location"></div>`); sub(identifiers.Sidebar.ProfileElementsID) .find('.profile-location') .blink(3, 125); } return true; } /** * Reacts when a Tag is selected via floaty dropdown. Toggles the presence of the tag on the entity. * * @param event - The Select2 event object. * @returns A promise that resolves to a boolean indicating whether the processing was successful. */ async function processTagSelection(event) { const { id: tagID, text } = event.params.data; const params = createPostParams(); params.append('save-tags', '1'); params.append('tags[]', tagID); const header = $('.entity-header .entity-header-text'); if (header.has('.entity-tags').length == 0) { $('<div class="entity-tags entity-header-line text-xs flex flex-wrap gap-2"></div>') .insertBefore(header.find('.header-buttons')); } const hasTag = !!kanka.meta.tags.find(tag => tag.id == tagID); params.append('bulk-tagging', hasTag ? 'remove' : 'add'); const result = await post(`/w/${kanka.campaignID}/bulk/process`, params); const tagBar = header.find('.entity-tags'); if (result.ok) { (hasTag ? tagBar.children().remove(`[href="${templates.TAG_URL(tagID)}"]`) : tagBar.append($(templates.TAG_LINK(tagID, text)))) .blink(3, 125); } return result.ok; /* // was doing it using the simple 'add entity under tag' API // but why not consolidate? params.append('entities[]', kanka.meta.entity.id); params.append('tag_id', tagID); post(kanka.getUri('tags', tagID, 'entity-add'), params) .then((ok) => ok && tagBar.append($(templates.TAG_LINK(tagID, text)))); */ } function initSelector(template, processSelection) { const floatingDiv = createFloatingElement(template); $(floatingDiv).find('select.select2') .each(function () { const me = $(this); me.select2({ tags: false, placeholder: me.data('placeholder'), allowClear: me.data('allowClear') || true, language: me.data('language'), minimumInputLength: 0, dropdownParent: $(me.data('dropdownParent')) || '', width: '100%', sorter: (data) => { const term = $('input.select2-search__field').val().toLowerCase(); return data.sort(byMatchiness(term)); }, ajax: { delay: 500, // quiet ms url: me.data('url'), dataType: 'json', data: (params) => { var _a; return ({ q: (_a = params.term) === null || _a === void 0 ? void 0 : _a.trim() }); }, processResults: (data) => ({ results: data }), error: function (jqXHR, textStatus, errorThrown) { if (textStatus === 'abort') { // it does this for the empty field, I think? return; } if (jqXHR.status === 503) { window.showToast(jqXHR.responseJSON.message, 'error'); } emit_debug('error', jqXHR, textStatus, errorThrown); return { results: [] }; }, cache: true }, templateResult: (item) => templates.SELECT_ITEM(item.text, item.image), }) .on('select2:select', processSelection) .on('select2:close', () => { setTimeout(() => { $(floatingDiv).remove(); }, 100); }); setTimeout(() => { me.select2('open'); }, 0); }); } function byMatchiness(term) { return (a, b) => { const textA = a.text.toLowerCase(); const textB = b.text.toLowerCase(); // Assign a score based on how well the option matches the search term const scoreA = textA === term ? 3 : textA.startsWith(term) ? 2 : textA.includes(term) ? 1 : 0; const scoreB = textB === term ? 3 : textB.startsWith(term) ? 2 : textB.includes(term) ? 1 : 0; // Sort by score. If the scores are equal, sort alphabetically return scoreB - scoreA || textA.localeCompare(textB); }; } /** * Map the keybinds to the handlers */ const handlers = { [keybinds.LABEL]: function (evt, combo) { initSelector(templates.TAG_SELECT, processTagSelection); }, [keybinds.MOVE]: function (evt, combo) { initSelector(templates.LOCATION_SELECT, processLocationSelection); }, [keybinds.HELP]: function (evt, combo) { // TODO show a modal describing the keybinds }, }; (function () { if (!document.body.className.includes('kanka-entity-')) { return; } for (const key in handlers) { mousetrap_1.default.bind(key, handlers[key]); } emit_debug({ kanka }); })(); /***/ }), /***/ 802: /***/ ((module) => { module.exports = Mousetrap; /***/ }) /******/ }); /************************************************************************/ /******/ // The module cache /******/ var __webpack_module_cache__ = {}; /******/ /******/ // The require function /******/ function __webpack_require__(moduleId) { /******/ // Check if module is in cache /******/ var cachedModule = __webpack_module_cache__[moduleId]; /******/ if (cachedModule !== undefined) { /******/ return cachedModule.exports; /******/ } /******/ // Create a new module (and put it into the cache) /******/ var module = __webpack_module_cache__[moduleId] = { /******/ // no module.id needed /******/ // no module.loaded needed /******/ exports: {} /******/ }; /******/ /******/ // Execute the module function /******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__); /******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } /******/ /************************************************************************/ /******/ /******/ // startup /******/ // Load entry module and return exports /******/ // This entry module is referenced by other modules so it can't be inlined /******/ var __webpack_exports__ = __webpack_require__(519); /******/ /******/ })() ;