// ==UserScript== // @name Perplexity helper // @namespace Tiartyos // @match https://www.perplexity.ai/* // @grant none // @version 3.0 // @author Tiartyos, monnef // @description Simple script that adds buttons to Perplexity website for repeating request using Copilot. // @require https://code.jquery.com/jquery-3.6.0.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jquery-modal/0.9.1/jquery.modal.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/lodash-fp/0.10.4/lodash-fp.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.8/clipboard.min.js // @require https://cdn.jsdelivr.net/npm/color2k@2.0.2/dist/index.unpkg.umd.js // @require https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js // @homepageURL https://www.perplexity.ai/ // @license GPL-3.0-or-later // @downloadURL none // ==/UserScript== const jq = $.noConflict(); const $c = (cls, parent) => jq(`.${cls}`, parent); const $i = (id, parent) => jq(`#${id}`, parent); const takeStr = n => str => str.slice(0, n); const dropStr = n => str => str.slice(n); const nl = '\n'; const markdownConverter = new showdown.Converter(); let debugMode = false; const enableDebugMode = () => { debugMode = true; }; const logPrefix = '[Perplexity helper]'; const debugLog = (...args) => { if (debugMode) { console.debug(logPrefix, ...args); } } let debugTags = false; const debugLogTags = (...args) => { if (debugTags) { console.debug(logPrefix, '[tags]', ...args); } } const enableTagsDebugging = () => { debugTags = true; } ($ => { $.fn.nthParent = function (n) { let $p = $(this); if (!(n > -0)) { return $() } let p = 1 + n; while (p--) { $p = $p.parent(); } return $p; }; })(jq); const button = (id, icoName, title, extraClass) => ``; const upperButton = (id, icoName, title) => `
${icoName}
` const textButton = (id, text, title) => ` ` const icoColor = '#1F1F1F'; const robotIco = ``; const robotRepeatIco = ` `; const cogIco = ` \t `; const perplexityHelperModalId = 'perplexityHelperModal'; const getPerplexityHelperModal = () => $i(perplexityHelperModalId); const modalHTML = ` `; const genCssName = x => `perplexity-helper--${x}`; const tagsContainerCls = genCssName('tags-container'); const tagContainerCompactCls = genCssName('tag-container-compact'); const tagContainerWiderCls = genCssName('tag-container-wider'); const tagContainerWideCls = genCssName('tag-container-wide'); const tagContainerExtraWideCls = genCssName('tag-container-extra-wide'); const threadTagContainerCls = genCssName('thread-tag-container'); const newTagContainerCls = genCssName('new-tag-container'); const newTagContainerInCollectionCls = genCssName('new-tag-container-in-collection'); const tagCls = genCssName('tag'); const tagDarkTextCls = genCssName('tag-dark-text'); const tagIconCls = genCssName('tag-icon'); const tagPaletteCls = genCssName('tag-palette'); const tagPaletteItemCls = genCssName('tag-palette-item'); const tagTweakNoBorderCls = genCssName('tag-tweak-no-border'); const tagTweakSlimPaddingCls = genCssName('tag-tweak-slim-padding'); const tagsPreviewCls = genCssName('tags-preview'); const tagsPreviewNewCls = genCssName('tags-preview-new'); const tagsPreviewThreadCls = genCssName('tags-preview-thread'); const tagTweakTextShadowCls = genCssName('tag-tweak-text-shadow'); const tagFenceCls = genCssName('tag-fence'); const tagAllFencesWrapperCls = genCssName('tag-all-fences-wrapper'); const tagRestOfTagsWrapperCls = genCssName('tag-rest-of-tags-wrapper'); const tagFenceContentCls = genCssName('tag-fence-content'); const tagDirectoryCls = genCssName('tag-directory'); const tagDirectoryContentCls = genCssName('tag-directory-content'); const helpTextCls = genCssName('help-text'); const queryBoxCls = genCssName('query-box'); const controlsAreaCls = genCssName('controls-area'); const textAreaCls = genCssName('text-area'); const standardButtonCls = genCssName('standard-button'); const roundedMD = genCssName('rounded-md'); const topSettingsButtonId = genCssName('settings-button-top'); const leftSettingsButtonId = genCssName('settings-button-left'); const leftSettingsButtonWrapperId = genCssName('settings-button-left-wrapper'); const styles = ` .checkbox_label { color: white; } .textarea_wrapper { display: flex; flex-direction: column; } .textarea_wrapper > textarea { width: 100%; background-color: rgba(0, 0, 0, 0.8); padding: 0 5px; } .textarea_label { margin-right: auto; } .${helpTextCls} { max-width: 580px; background-color: #225; padding: 0.3em 0.7em; border-radius: 0.5em; margin: 1em 0; } .${helpTextCls} { cursor: text; } .${helpTextCls} code { font-size: 80%; background-color: rgba(255, 255, 255, 0.1); border-radius: 0.3em; padding: 0.1em; } .${helpTextCls} pre > code { background: none; } .${helpTextCls} pre { font-size: 80%; overflow: auto; background-color: rgba(255, 255, 255, 0.1); border-radius: 0.3em; padding: 0.1em 1em; } .${helpTextCls} li { list-style: circle; margin-left: 1em; } .${helpTextCls} hr { margin: 1em 0 0.5em 0; border-color: rgba(255, 255, 255, 0.1); } .btn-helper { margin-left: 20px } .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0, 0, 0, 0.8) } .modal-content { display: flex; margin: 1em auto; width: calc(100vw - 2em); padding: 20px; border: 1px solid #888; background-color: #202025; border-radius: 6px; color: rgb(206, 206, 210); flex-direction: column; position: relative; row-gap: 10px; overflow-y: auto; cursor: default; } .modal-content label { padding-right: 10px; } .modal-content h1 { font-size: 1.5em; } .modal-content hr { height: 1px; margin: 1em 0; border-color: rgba(255, 255, 255, 0.1); } .modal-content h1 + hr { margin-top: 0.5em; } .close { color: rgb(206, 206, 210); float: right; font-size: 28px; font-weight: bold; position: absolute; right: 20px; top: 5px; } .close:hover, .close:focus { color: white; text-decoration: none; cursor: pointer; } #copied-modal,#copied-modal-2 { padding: 5px 5px; background:gray; position:absolute; display: none; color: white; font-size: 15px; } label > div.select-none { user-select: text; cursor: initial; } .${tagsContainerCls} { display: flex; flex-direction: row; margin: 5px 0; } .${tagsContainerCls}.${threadTagContainerCls} { margin-left: 0.5em; margin-right: 0.5em; margin-bottom: 2px; } .${tagContainerCompactCls} { margin-top: -2em; margin-bottom: 1px; } .${tagContainerCompactCls} .${tagFenceCls} { margin: 0; padding: 1px; } .${tagContainerCompactCls} .${tagCls} { } .${tagContainerCompactCls} .${tagAllFencesWrapperCls} { gap: 1px; } .${tagContainerCompactCls} .${tagRestOfTagsWrapperCls} { margin: 1px; } .${tagContainerCompactCls} .${tagRestOfTagsWrapperCls}, .${tagContainerCompactCls} .${tagFenceContentCls}, .${tagContainerCompactCls} .${tagDirectoryContentCls} { gap: 1px; } .${tagContainerWiderCls} { margin-left: -6em; margin-right: -6em; margin-bottom: 2em; } .${tagContainerWiderCls} .${tagCls} { } .${tagContainerWideCls} { margin-left: -12em; margin-right: -12em; margin-bottom: 3em; } .${tagContainerExtraWideCls} { margin-left: -16em; margin-right: -16em; margin-bottom: 3em; } .${tagCls} { border: 1px solid #3b3b3b; background-color: #282828; /*color: rgba(255, 255, 255, 0.482);*/ /* equivalent of #909090; when on #282828 background */ padding: 0px 8px 0 8px; border-radius: 4px; cursor: pointer; transition: background-color 0.2s, color 0.2s; display: inline-block; color: #E8E8E6 } .${tagCls}.${tagDarkTextCls} { color: #171719; } .${tagCls}.${tagTweakNoBorderCls} { border: none; } .${tagCls}.${tagTweakSlimPaddingCls} { padding: 0px 4px 0 4px; } .${tagCls} .${tagIconCls} { width: 16px; height: 16px; margin-right: 2px; margin-left: -4px; margin-top: -4px; vertical-align: middle; display: inline-block; filter: invert(1); } .${tagCls}.${tagDarkTextCls} .${tagIconCls} { filter: none; } .${tagCls}.${tagTweakSlimPaddingCls} .${tagIconCls} { margin-left: -2px; } .${tagCls} span { position: relative; top: 1.5px; } .${tagCls}.${tagTweakTextShadowCls} span { text-shadow: 1px 0 0.5px black, -1px 0 0.5px black, 0 1px 0.5px black, 0 -1px 0.5px black; } .${tagCls}.${tagTweakTextShadowCls}.${tagDarkTextCls} span { text-shadow: 1px 0 0.5px white, -1px 0 0.5px white, 0 1px 0.5px white, 0 -1px 0.5px white; } .${tagCls}:hover { background-color: #333; color: #fff; } .${tagCls}.${tagDarkTextCls}:hover { /* color: #171717; */ color: #2f2f2f; } .${tagPaletteCls} { display: flex; flex-wrap: wrap; gap: 1px; } .${tagPaletteCls} .${tagPaletteItemCls} { text-shadow: 1px 0 1px black, -1px 0 1px black, 0 1px 1px black, 0 -1px 1px black; width: 40px; height: 25px; display: inline-block; text-align: center; padding: 0 2px; transition: color 0.2s, border 0.1s; border: 2px solid transparent; } .${tagPaletteItemCls}:hover { cursor: pointer; color: white; border: 2px solid white; } .${tagsPreviewCls} { background-color: #191a1a; padding: 0.5em 1em; border-radius: 1em; } .${tagsPreviewNewCls}:before { content: 'Target New: '; } .${tagsPreviewThreadCls}:before { content: 'Target Thread: '; } .${tagAllFencesWrapperCls} { display: flex; flex-direction: row; gap: 5px; } .${tagRestOfTagsWrapperCls} { display: flex; flex-direction: row; flex-wrap: wrap; align-content: flex-start; gap: 5px; margin: 8px; } .${tagFenceCls} { display: flex; margin: 5px 0; padding: 5px; border-radius: 4px; } .${tagFenceContentCls} { display: flex; flex-direction: column; flex-wrap: wrap; gap: 5px; } .${tagDirectoryCls} { position: relative; display: flex; z-index: 100; } .${tagDirectoryCls}:hover .${tagDirectoryContentCls} { display: flex; } .${tagDirectoryContentCls} { position: absolute; display: none; flex-direction: column; gap: 5px; top: 0px; padding-bottom: 1px; left: -5px; transform: translateY(-100%); background: rgba(0, 0, 0, 0.5); padding: 5px; border-radius: 4px; flex-wrap: nowrap; width: max-content; } .${tagDirectoryContentCls} .${tagCls} { white-space: nowrap; width: fit-content; } .${queryBoxCls} { flex-wrap: wrap; } .${controlsAreaCls} { grid-template-columns: repeat(4,minmax(0,1fr)) } .${textAreaCls} { grid-column-end: 5; } .${standardButtonCls} { grid-column-start: 4; } .${roundedMD} { border-radius: 0.375rem!important; } #${leftSettingsButtonId} svg { transition: fill 0.2s; } #${leftSettingsButtonId}:hover svg { fill: #fff !important; } .w-collapsedSideBarWidth #${leftSettingsButtonId} span { display: none; } .w-collapsedSideBarWidth #${leftSettingsButtonId} { width: 100%; border-radius: 0.25rem; height: 40px; } #${leftSettingsButtonWrapperId} { display: flex; padding: 0.1em 0.4em; justify-content: flex-end; } .w-collapsedSideBarWidth #${leftSettingsButtonWrapperId} { justify-content: center; } `; const TAG_POSITION = { BEFORE: 'before', AFTER: 'after', CARET: 'caret', }; const TAG_CONTAINER_TYPE = { NEW: 'new', NEW_IN_COLLECTION: 'new-in-collection', THREAD: 'thread', ALL: 'all', } const tagsHelpText = ` Each line is one tag. Non-field text is what will be inserted into prompt. Field is denoted by \`<\` and \`>\`, field name is before \`:\`, field value after \`:\`. Supported fields: - \`label\`: tag label shown on tag "box" (new items around prompt input area) - \`position\`: where the tag text will be inserted, default is \`before\`; valid values are \`before\`/\`after\` (existing text) or \`caret\` (at cursor position) - \`color\`: tag color; CSS colors supported, you can use colors from a pre-generated palette via \`%\` syntax, e.g. \`\`. See palette bellow. - \`tooltip\`: shown on hover (aka title); (default) tooltip can be disabled when this field is set to empty string - \`\` - \`target\`: where the tag will be inserted, default is \`new\`; valid values are \`new\` (on home page or when clicking on "New Thread" button) / \`thread\` (on thread page) / \`all\` (everywhere) - \`hide\`: hide the tag from the tag list - \`link\`: link to a URL, e.g. \`\`, can be used for collections. only one link per tag is supported. - \`link-target\`: target of the link, e.g. \`\` (opens in new tab), default is \`_self\` (same tab). - \`icon\`: lucide icon name, e.g. \`\`. see [lucide icons](https://lucide.dev/icons) - \`dir\`: unique identifier for a directory tag (it will not insert text into prompt) - \`in-dir\`: identifier of the parent directory this tag belongs to - \`fence\`: unique identifier for a fence definition (hidden by default) - \`in-fence\`: identifier of the fence this tag belongs to - \`fence-width\`: CSS width for a fence, e.g. \`\` - \`fence-border-style\`: CSS border style for a fence (e.g., solid, dashed, dotted) - \`fence-border-color\`: CSS color or a palette \`%\` syntax for a fence border - \`fence-border-width\`: CSS width for a fence border --- Examples: \`\`\` stable diffusion web ui - , prefer concise modern syntax and style, tell me a joke \`\`\` Directory example: \`\`\` Games FFXIV: Vintage Story - \`\`\` Fence example: \`\`\` Shounen Seinen Shoujo \`\`\` Another fence example: \`\`\` Haskell Raku \`\`\` `.trim(); const defaultTagColor = '#282828'; const changeValueUsingEvent = (selector, value) => { debugLog('changeValueUsingEvent', value, selector); const nativeTextareaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeTextareaValueSetter.call(selector, value); const inputEvent = new Event('input', {bubbles: true}); selector.dispatchEvent(inputEvent); } const cyanButtonPerplexityColor = '#1fb8cd'; const TAGS_PALETTE_COLORS_NUM = 16; const TAGS_PALETTE_CLASSIC = Object.freeze((() => { const step = 360 / TAGS_PALETTE_COLORS_NUM; const [startH, startS, startL, startA] = color2k.parseToHsla(cyanButtonPerplexityColor); return _.flow( _.map(x => startH + x * step, _), _.map(h => color2k.hsla(h, startS, startL, startA), _), _.sortBy(x => color2k.parseToHsla(x)[0], _) )(_.range(0, TAGS_PALETTE_COLORS_NUM)); })()); const TAGS_PALETTE_PASTEL = Object.freeze((() => { const step = 360 / TAGS_PALETTE_COLORS_NUM; const [startH, startS, startL, startA] = color2k.parseToHsla(cyanButtonPerplexityColor); return _.flow( _.map(x => startH + x * step, _), _.map(h => color2k.hsla(h, startS - 0.2, startL + 0.2, startA), _), _.sortBy(x => color2k.parseToHsla(x)[0], _) )(_.range(0, TAGS_PALETTE_COLORS_NUM)); })()); const TAGS_PALETTE_GRIM = Object.freeze((() => { const step = 360 / TAGS_PALETTE_COLORS_NUM; const [startH, startS, startL, startA] = color2k.parseToHsla(cyanButtonPerplexityColor); return _.flow( _.map(x => startH + x * step, _), _.map(h => color2k.hsla(h, startS - 0.6, startL - 0.3, startA), _), _.sortBy(x => color2k.parseToHsla(x)[0], _) )(_.range(0, TAGS_PALETTE_COLORS_NUM)); })()); const TAGS_PALETTE_DARK = Object.freeze((() => { const step = 360 / TAGS_PALETTE_COLORS_NUM; const [startH, startS, startL, startA] = color2k.parseToHsla(cyanButtonPerplexityColor); return _.flow( _.map(x => startH + x * step, _), _.map(h => color2k.hsla(h, startS, startL - 0.4, startA), _), _.sortBy(x => color2k.parseToHsla(x)[0], _) )(_.range(0, TAGS_PALETTE_COLORS_NUM)); })()); const TAGS_PALETTE_GRAY = Object.freeze((() => { const step = 1 / TAGS_PALETTE_COLORS_NUM; return _.range(0, TAGS_PALETTE_COLORS_NUM).map(x => color2k.hsla(0, 0, step * x, 1)); })()); const TAGS_PALETTE_CYAN = Object.freeze((() => { const step = 1 / TAGS_PALETTE_COLORS_NUM; const [startH, startS, startL, startA] = color2k.parseToHsla(cyanButtonPerplexityColor); return _.range(0, TAGS_PALETTE_COLORS_NUM).map(x => color2k.hsla(startH, startS, step * x, 1)); })()); const TAGS_PALETTE_TRANSPARENT = Object.freeze((() => { const step = 1 / TAGS_PALETTE_COLORS_NUM; return _.range(0, TAGS_PALETTE_COLORS_NUM).map(x => color2k.hsla(0, 0, 0, step * x)); })()); const TAGS_PALETTE_HACKER = Object.freeze((() => { const step = 1 / TAGS_PALETTE_COLORS_NUM; return _.range(0, TAGS_PALETTE_COLORS_NUM).map(x => color2k.hsla(120, step * x, step * x * 0.5, 1)); })()); const TAGS_PALETTES = Object.freeze({ CLASSIC: TAGS_PALETTE_CLASSIC, PASTEL: TAGS_PALETTE_PASTEL, GRIM: TAGS_PALETTE_GRIM, DARK: TAGS_PALETTE_DARK, GRAY: TAGS_PALETTE_GRAY, CYAN: TAGS_PALETTE_CYAN, TRANSPARENT: TAGS_PALETTE_TRANSPARENT, HACKER: TAGS_PALETTE_HACKER, }); const convertColorInPaletteFormat = currentPalette => value => currentPalette[parseInt(dropStr(1)(value), 10)] ?? defaultTagColor; const TAG_HOME_PAGE_LAYOUT = { DEFAULT: 'default', COMPACT: 'compact', WIDER: 'wider', WIDE: 'wide', EXTRA_WIDE: 'extra-wide', } const processTagField = currentPalette => name => value => { if (name === 'color' && value.startsWith('%')) return convertColorInPaletteFormat(currentPalette)(value); if (name === 'hide') return true; return value; }; const tagLineRegex = /<(label|position|color|tooltip|target|hide|link|link-target|icon|dir|in-dir|fence|in-fence|fence-border-style|fence-border-color|fence-border-width|fence-width)(?::([^<>]*))?>/g; const parseOneTagLine = currentPalette => line => Array.from(line.matchAll(tagLineRegex)).reduce( (acc, match) => { const [fullMatch, field, value] = match; const processedValue = processTagField(currentPalette)(field)(value); return { ...acc, [_.camelCase(field)]: processedValue, text: acc.text.replace(fullMatch, '').replace(/\\n/g, '\n'), }; }, { text: line, color: defaultTagColor, target: TAG_CONTAINER_TYPE.NEW, hide: false, 'link-target': '_self', } ); const parseTagsText = text => { const lines = text.split('\n').filter(tag => tag.trim().length > 0); const palette = getPalette(loadConfig()?.tagPalette); return lines.map(parseOneTagLine(palette)).map((x, i) => ({...x, originalIndex: i})); }; const getTagsContainer = () => $c(tagsContainerCls); const promptAreaOfNewThreadSelector = 'textarea[placeholder="Ask anything..."]'; const getPromptAreaOfNewThread = () => jq(promptAreaOfNewThreadSelector); const getPromptAreaWrapperOfNewThread = () => getPromptAreaOfNewThread().nthParent(5); const promptAreaOnThreadSelector = 'textarea[placeholder="Ask follow-up"]'; const getPromptAreaOnThread = () => jq(promptAreaOnThreadSelector); const getPromptAreaWrapperOnThread = () => getPromptAreaOnThread().parent().parent().parent().parent(); const promptAreaOnCollectionSelector = 'textarea[placeholder="New Thread"]'; const getPromptAreaOnCollection = () => jq(promptAreaOnCollectionSelector); const getPromptAreaWrapperOnCollection = () => getPromptAreaOnCollection().nthParent(4); const anyPromptAreaSelector = `${promptAreaOfNewThreadSelector},${promptAreaOnThreadSelector},${promptAreaOnCollectionSelector}`; const getAnyPromptArea = () => jq(anyPromptAreaSelector); const posFromTag = tag => Object.values(TAG_POSITION).includes(tag.position) ? tag.position : TAG_POSITION.BEFORE; const applyTagToString = (tag, val, caretPos) => { const {text} = tag; switch (posFromTag(tag)) { case TAG_POSITION.BEFORE: return `${text}${val}`; case TAG_POSITION.AFTER: return `${val}${text}`; case TAG_POSITION.CARET: return `${takeStr(caretPos)(val)}${text}${dropStr(caretPos)(val)}`; default: throw new Error(`Invalid position: ${tag.position}`); } }; const getPromptAreaFromTagsContainer = tagsContainerEl => tagsContainerEl.parent().find(anyPromptAreaSelector); const getPalette = paletteName => TAGS_PALETTES[paletteName] ?? TAGS_PALETTES.CLASSIC; const createTag = containerEl => isPreview => tag => { if (tag.hide) return null; const labelString = tag.label ?? tag.text; const isTagLight = color2k.getLuminance(tag.color) > loadConfig().tagLuminanceThreshold; const colorMod = isTagLight ? color2k.darken : color2k.lighten; const hoverBgColor = color2k.toRgba(colorMod(tag.color, 0.1)); const borderColor = color2k.toRgba(colorMod(tag.color, loadConfig().tagTweakRichBorderColor ? 0.2 : 0.1)); const clickHandler = evt => { debugLog('clicked', tag, evt); if (tag.link) return; const el = jq(evt.currentTarget); const tagsContainer = el.closest(`.${tagsContainerCls}`); if (!tagsContainer.length) { debugLogTags('[clickHandler] no tags container found'); return; } const promptArea = getPromptAreaFromTagsContainer(tagsContainer); if (!promptArea.length) { debugLogTags('[clickHandler] no prompt area found', promptArea); return; } const promptAreaRaw = promptArea[0]; const newText = applyTagToString(tag, promptArea.val(), promptAreaRaw.selectionStart); changeValueUsingEvent(promptAreaRaw, newText); promptAreaRaw.focus(); }; const tagFont = loadConfig().tagFont; const defaultTooltip = tag.link? `${logPrefix} Open link: ${tag.link}` : `${logPrefix} Insert \`${tag.text}\` at position \`${posFromTag(tag)}\``; const tagEl = jq(`
`) .addClass(tagCls) .prop('title', tag.tooltip ?? defaultTooltip) .attr('data-tag', JSON.stringify(tag)) .css({ backgroundColor: tag.color, borderColor, fontFamily: tagFont, }) .attr('data-color', color2k.toHex(tag.color)) .attr('data-hoverBgColor', color2k.toHex(hoverBgColor)) .attr('data-font', tagFont) .on('mouseenter', event => { jq(event.currentTarget).css('background-color', hoverBgColor); }) .on('mouseleave', event => { jq(event.currentTarget).css('background-color', tag.color); }); if (isTagLight) { tagEl.addClass(tagDarkTextCls); } if (loadConfig()?.tagTweakNoBorder) { tagEl.addClass(tagTweakNoBorderCls); } if (loadConfig()?.tagTweakSlimPadding) { tagEl.addClass(tagTweakSlimPaddingCls); } if (loadConfig()?.tagTweakTextShadow) { tagEl.addClass(tagTweakTextShadowCls); } const textEl = jq('').text(labelString); if (tag.icon) { const iconEl = jq('') .attr('src', `https://unpkg.com/lucide-static@latest/icons/${tag.icon}.svg`) .addClass(tagIconCls); textEl.prepend(iconEl); } tagEl.append(textEl); if (tag.link) { const linkEl = jq('') .attr('href', tag.link) .attr('target', tag.linkTarget) .css({ textDecoration: 'none', color: 'inherit' }); textEl.wrap(linkEl); } if (!isPreview && !tag.link && !tag.dir) { tagEl.click(clickHandler); } containerEl.append(tagEl); return tagEl; }; const genDebugFakeTags = () => _.times(TAGS_PALETTE_COLORS_NUM, x => `Fake ${x} ${_.times(x / 3).map(() => 'x').join('')}`) .join('\n'); const getTagContainerType = containerEl => { if (containerEl.hasClass(threadTagContainerCls) || containerEl.hasClass(tagsPreviewThreadCls)) return TAG_CONTAINER_TYPE.THREAD; if (containerEl.hasClass(newTagContainerCls) || containerEl.hasClass(tagsPreviewNewCls)) return TAG_CONTAINER_TYPE.NEW; if (containerEl.hasClass(newTagContainerInCollectionCls) || containerEl.hasClass(tagsPreviewNewInCollectionCls)) return TAG_CONTAINER_TYPE.NEW_IN_COLLECTION; return null; } const isTagRelevantForContainer = containerType => tag => containerType === tag.target || (containerType === TAG_CONTAINER_TYPE.NEW_IN_COLLECTION && tag.target === TAG_CONTAINER_TYPE.NEW) || tag.target === TAG_CONTAINER_TYPE.ALL const refreshTags = ({force = false} = {}) => { const promptWrapper = getPromptAreaWrapperOfNewThread().add(getPromptAreaWrapperOnThread()).add(getPromptAreaWrapperOnCollection()); if (!promptWrapper.length) { debugLogTags('no prompt area found'); } const allTags = _.flow( x => x + (unsafeWindow.phFakeTags ? `${nl}${genDebugFakeTags()}${nl}` : ''), parseTagsText, )(loadConfig()?.tagsText ?? defaultConfig.tagsText); debugLogTags('refreshing allTags', allTags); const createContainer = (promptWrapper) => { const el = jq(`
`).addClass(tagsContainerCls); if (promptWrapper.find(promptAreaOnThreadSelector).length) { el.addClass(threadTagContainerCls); } if (promptWrapper.find(promptAreaOfNewThreadSelector).length) { el.addClass(newTagContainerCls); } if (promptWrapper.find(promptAreaOnCollectionSelector).length) { el.addClass(newTagContainerInCollectionCls); } return el; } promptWrapper.each((_, rEl) => { const el = jq(rEl); if (el.parent().find(`.${tagsContainerCls}`).length) { el.parent().addClass(queryBoxCls); return; } el.before(createContainer(el)); }); const currentPalette = getPalette(loadConfig().tagPalette); const createFence = (fence) => { const fenceEl = jq('
') .addClass(tagFenceCls) .css({ 'border-style': fence.fenceBorderStyle ?? 'solid', 'border-color': fence.fenceBorderColor?.startsWith('%') ? convertColorInPaletteFormat(currentPalette)(fence.fenceBorderColor) : fence.fenceBorderColor ?? defaultTagColor, 'border-width': fence.fenceBorderWidth ?? '1px', }) .attr('data-tag', JSON.stringify(fence)) ; const fenceContentEl = jq('
') .addClass(tagFenceContentCls) .css({ 'width': fence.fenceWidth ?? '', }) ; fenceEl.append(fenceContentEl); return { fenceEl, fenceContentEl }; }; const createDirectory = () => { const directoryEl = jq('
').addClass(tagDirectoryCls); const directoryContentEl = jq('
').addClass(tagDirectoryContentCls); directoryEl.append(directoryContentEl); return { directoryEl, directoryContentEl }; }; const containerEls = getTagsContainer(); containerEls.each((_i, rEl) => { const containerEl = jq(rEl); const isPreview = Boolean(containerEl.attr('data-preview')); const currentTags = _.flow( _.map(el => JSON.parse(el.dataset.tag)), _.sortBy('originalIndex') )(containerEl.find(`.${tagCls}, .${tagFenceCls}`).toArray()); const tagContainerType = getTagContainerType(containerEl); const tagsForThisContainer = _.flow( _.filter(isTagRelevantForContainer(tagContainerType)), _.filter(tag => !tag.hide), _.sortBy('originalIndex') )(allTags); debugLogTags('tagContainerType', tagContainerType, 'current tags', currentTags, 'tagsForThisContainer', tagsForThisContainer); if (_.isEqual(currentTags, tagsForThisContainer) && !force) { debugLogTags('no tags changed'); return; } const changedTags = [ ..._.filter(tag => !_.some(t => _.isEqual(t, tag), tagsForThisContainer), currentTags), ..._.filter(tag => !_.some(t => _.isEqual(t, tag), currentTags), tagsForThisContainer) ]; debugLogTags('changedTags', changedTags); containerEl.empty(); const tagHomePageLayout = loadConfig()?.tagHomePageLayout; if (!isPreview) { if ((tagContainerType === TAG_CONTAINER_TYPE.NEW || tagContainerType === TAG_CONTAINER_TYPE.NEW_IN_COLLECTION)) { if (tagContainerType === TAG_CONTAINER_TYPE.NEW_IN_COLLECTION) { // only compact layout is supported for new in collection if (tagHomePageLayout === TAG_HOME_PAGE_LAYOUT.COMPACT) { containerEl.addClass(tagContainerCompactCls); } } else if (tagHomePageLayout === TAG_HOME_PAGE_LAYOUT.COMPACT) { containerEl.addClass(tagContainerCompactCls); } else if (tagHomePageLayout === TAG_HOME_PAGE_LAYOUT.WIDER) { containerEl.addClass(tagContainerWiderCls); } else if (tagHomePageLayout === TAG_HOME_PAGE_LAYOUT.WIDE) { containerEl.addClass(tagContainerWideCls); } else if (tagHomePageLayout === TAG_HOME_PAGE_LAYOUT.EXTRA_WIDE) { containerEl.addClass(tagContainerExtraWideCls); } else { containerEl.removeClass(`${tagContainerCompactCls} ${tagContainerWiderCls} ${tagContainerWideCls} ${tagContainerExtraWideCls}`); } } } const fences = {}; const directories = {}; const fencesWrapperEl = jq('
').addClass(tagAllFencesWrapperCls); const restWrapperEl = jq('
').addClass(tagRestOfTagsWrapperCls); tagsForThisContainer.forEach(tag => { const { fence, dir, inFence, inDir } = tag; const getOrCreateDirectory = dirName => { if (!directories[dirName]) directories[dirName] = createDirectory(); return directories[dirName]; }; const getTagContainer = () => { if (fence) { if (!fences[fence]) fences[fence] = createFence(tag); return fences[fence].fenceContentEl; } else if (dir && inFence) { if (!fences[inFence]) { console.error(`fence ${inFence} for tag not found`, tag); return null; } const { directoryEl } = getOrCreateDirectory(dir); fences[inFence].fenceContentEl.append(directoryEl); return directoryEl; } else if (dir) { const { directoryEl } = getOrCreateDirectory(dir); restWrapperEl.append(directoryEl); return directoryEl; } else if (inFence) { if (!fences[inFence]) { console.error(`fence ${inFence} for tag not found`, tag); return null; } return fences[inFence].fenceContentEl; } else if (inDir) { if (!directories[inDir]) { console.error(`directory ${inDir} for tag not found`, tag); return null; } return directories[inDir].directoryContentEl; } else { return restWrapperEl; } }; const tagContainer = getTagContainer(); if (tagContainer && !fence) { createTag(tagContainer)(isPreview)(tag); } }); Object.values(fences).forEach(({ fenceEl }) => fencesWrapperEl.append(fenceEl)); containerEl.append(fencesWrapperEl).append(restWrapperEl); }); } const setupTags = () => { debugLog('setting up tags'); setInterval(refreshTags, 500); } const defaultConfig = Object.freeze({ showCopilot: true, showCopilotNewThread: true, showCopilotRepeatLast: true, showCopilotCopyPlaceholder: true, tagsText: '', debugMode: false, debugTagsMode: false, tagPalette: 'CLASSIC', tagHomePageLayout: TAG_HOME_PAGE_LAYOUT.DEFAULT, tagLuminanceThreshold: 0.35, }); // TODO: if still using local storage, at least it should be prefixed with user script name const storageKey = 'checkBoxStates'; const loadConfig = () => { try { // TODO: use storage from GM API const val = JSON.parse(localStorage.getItem(storageKey)); // debugLog('loaded config', val); return val; } catch (e) { console.error('Failed to load config, using default', e); return defaultConfig; } } const loadConfigOrDefault = () => loadConfig() ?? defaultConfig const saveConfig = cfg => { debugLog('saving config', cfg); localStorage.setItem(storageKey, JSON.stringify(cfg)); }; const createCheckbox = (id, labelText, onChange) => { debugLog("createCheckbox", id); const checkbox = jq(``); const label = jq(``); const checkboxWithLabel = jq('
').append(label).append(checkbox); debugLog('checkboxwithlabel', checkboxWithLabel); getSettingsModalContent().append(checkboxWithLabel); checkbox.on('change', onChange); return checkbox; }; const createTextArea = (id, labelText, onChange, helpText) => { debugLog("createTextArea", id); const textarea = jq(``); const label = jq(``); const textareaWithLabel = jq('
').append(label); if (helpText) { const help = jq(`
`).addClass(helpTextCls).html(markdownConverter.makeHtml(helpText)).append(jq('
')); help.append(jq('