/** * @name Copy as Markdown * @description Adds a copy button to every post: click copies it as a Markdown quote (text, images, author, date and link), Shift+click copies plain text. With a post focused (j/k), press m / Shift+M. * @version 1.2.1 * @author wayBetter * @permissions ui, clipboard, network:none */ // i18n:begin // This plugin's English words. Translations live in the app's catalogs (plugin..*, // docs/TRANSLATING.md); a plugin of your own can also pass { en: {...}, tr: {...} } to register. const MESSAGES = { "en": { "button": "Copy as Markdown (Shift+click: plain text)", "copiedText": "Copied as text", "copiedMarkdown": "Copied as Markdown", "copyFailed": "Copy failed", "link": "link" } }; // i18n:end /** The words in the app's language (api.i18n); English before start() and outside the app. */ let i18n = null; const t = (key, params = {}) => (i18n ? i18n.t(key, params) : String(MESSAGES.en[key] ?? key) .replace(/\{(\w+), plural, one \{([^{}]*)\} other \{([^{}]*)\}\}/g, (m, name, one, other) => (Number(params[name]) === 1 ? one : other).replace('#', String(params[name]))) .replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m))); module.exports = class CopyAsMarkdown { start() { const api = window.wayBetter.api; this.scope = api.scope(); i18n = api.i18n || null; if (i18n) i18n.register(MESSAGES); // The buttons' tooltips follow the app's language. if (i18n) this.scope.defer(i18n.onChange(() => { for (const button of document.querySelectorAll('.bt-copy-md')) { button.title = t('button'); button.setAttribute('aria-label', button.title); } })); this.api = api; this.scope.css('copy-as-markdown', ` .bt-copy-md { display: inline-flex; flex: none; align-self: center; align-items: center; justify-content: center; min-width: 28px; height: 20px; padding: 0 5px; margin-right: 4px; box-sizing: border-box; border: 0; border-radius: 999px; background: transparent; color: inherit; opacity: .55; cursor: pointer; font: 700 11px/1 system-ui, sans-serif; } .bt-copy-md:hover { opacity: 1; background: rgba(29,155,240,.12); color: rgb(29,155,240); } /* A phone-narrow pane or deck column has no room for a third header button: the post's time would slide under it. m / Shift+M on a focused post still copy. */ @media (max-width: 420px) { .bt-copy-md { display: none; } } `); this.scope.observe('article[data-testid="tweet"] [data-testid="caret"]', caret => this.addButton(caret)); this.scope.onKey((event, article) => { if (event.key.toLowerCase() !== 'm' || !article) return; event.preventDefault(); this.copy(article, event.shiftKey); }); } stop() { this.scope.dispose(); for (const button of document.querySelectorAll('.bt-copy-md')) button.remove(); } addButton(caret) { const article = caret.closest('article[data-testid="tweet"]'); if (!article || !caret.parentElement) return; // The button sits in the row with X's Grok and ··· buttons. Where ··· has a wrapper of its own // that stacks its children (a column), it goes before that wrapper instead of on top of ···. const wrapper = caret.parentElement; const anchor = wrapper.parentElement && wrapper.childElementCount === 1 && getComputedStyle(wrapper).display.includes('flex') && getComputedStyle(wrapper).flexDirection.startsWith('column') ? wrapper : caret; if (anchor.previousElementSibling?.classList.contains('bt-copy-md')) return; const button = document.createElement('button'); button.type = 'button'; button.className = 'bt-copy-md'; button.textContent = 'MD'; button.title = t('button'); button.setAttribute('aria-label', button.title); button.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); this.copy(article, event.shiftKey); }); anchor.parentElement.insertBefore(button, anchor); } async copy(article, plain) { const tweet = this.api.tweet(article); if (!tweet) return; const text = plain ? this.plainText(tweet) : this.markdown(tweet); this.api.toast(await this.api.copyText(text) ? (plain ? t('copiedText') : t('copiedMarkdown')) : t('copyFailed')); } date(tweet) { if (!tweet.time) return ''; return new Intl.DateTimeFormat(i18n ? i18n.locale.split('-x-')[0] : undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(tweet.time)); } plainText(tweet) { return `${tweet.text}\n\n— ${tweet.name} (@${tweet.handle}) ${this.date(tweet)}\n${tweet.url}`.trim(); } markdown(tweet) { const body = tweet.text ? tweet.text.split('\n').map(line => `> ${line}`.trimEnd()) : []; for (const src of tweet.media) body.push('>', `> ![](${src})`); body.push('>', `> — ${tweet.name} ([@${tweet.handle}](https://x.com/${tweet.handle})) [${this.date(tweet) || t('link')}](${tweet.url})`); return body.join('\n'); } };