dayjs.extend(window.dayjs_plugin_relativeTime) let currentMemoLock = '' function isFullscreenMode() { try { const params = new URLSearchParams(window.location.search || '') return params.get('mode') === 'full' } catch (_) { return false } } function openFullscreenTab() { try { const url = chrome.runtime.getURL('popup.html?mode=full') chrome.tabs.create({ url }) } catch (_) { // best-effort only } } function initProportionalEditorResize() { try { if (isFullscreenMode()) return const editor = document.querySelector('.memo-editor') const tools = document.querySelector('.common-tools-wrapper') const handle = document.getElementById('editor-resize-handle') if (!editor || !tools || !handle) return const safety = 8 const initialRect = editor.getBoundingClientRect() const baseW = Math.ceil(initialRect.width) const baseH = Math.ceil(initialRect.height) // Lock the base size. Scaling will be applied by setting width/height. editor.style.width = `${baseW}px` editor.style.height = `${baseH}px` editor.style.minWidth = `${baseW}px` editor.style.minHeight = `${baseH}px` const storageKey = 'popupEditorScale' const nonEditorHeight = Math.max(0, Math.ceil(document.body.scrollHeight - initialRect.height)) let maxScale = 1 let currentScale = 1 let dragging = false let dragStartX = 0 let dragStartY = 0 let dragStartScale = 1 const clampScale = (scale) => { if (!Number.isFinite(scale)) return 1 return Math.min(Math.max(scale, 1), maxScale) } const applyScale = (scale) => { currentScale = clampScale(scale) editor.style.width = `${Math.round(baseW * currentScale)}px` editor.style.height = `${Math.round(baseH * currentScale)}px` } const persistScale = () => { try { if (window.localStorage) window.localStorage.setItem(storageKey, String(currentScale)) } catch (_) {} try { if (chrome.storage && chrome.storage.sync) { chrome.storage.sync.set({ [storageKey]: currentScale }) } } catch (_) { // ignore } } const computeMaxScale = () => { // In popup mode, allow scaling up to Chrome's max popup size. // Do not clamp by current window.innerWidth/innerHeight, otherwise the popup can't grow to the max. const viewportW = 800 const viewportH = 600 const toolsRect = tools.getBoundingClientRect() const toolsStyle = window.getComputedStyle(tools) const toolsMarginTop = parseFloat(toolsStyle.marginTop || '0') || 0 const extraWidth = safety * 2 const extraHeight = nonEditorHeight + Math.ceil(toolsRect.height + toolsMarginTop) + safety const widthScale = (viewportW - extraWidth) / baseW const heightScale = (viewportH - extraHeight) / baseH maxScale = Math.max(1, Math.min(widthScale, heightScale)) applyScale(currentScale) } const endDrag = () => { if (!dragging) return dragging = false handle.classList.remove('dragging') persistScale() } const onPointerMove = (ev) => { if (!dragging) return const dx = ev.clientX - dragStartX const dy = ev.clientY - dragStartY const widthScale = (baseW * dragStartScale + dx) / baseW const heightScale = (baseH * dragStartScale + dy) / baseH applyScale(Math.max(widthScale, heightScale)) } const startDrag = (ev) => { ev.preventDefault() dragging = true dragStartX = ev.clientX dragStartY = ev.clientY dragStartScale = currentScale handle.classList.add('dragging') if (typeof handle.setPointerCapture === 'function') { try { handle.setPointerCapture(ev.pointerId) } catch (_) { // Ignore capture failures. } } } computeMaxScale() try { const localValue = window.localStorage ? Number(window.localStorage.getItem(storageKey)) : NaN if (Number.isFinite(localValue) && localValue >= 1) { applyScale(localValue) } } catch (_) { // ignore } try { chrome.storage.sync.get({ [storageKey]: 1 }, (items) => { const savedScale = Number(items && items[storageKey]) if (Number.isFinite(savedScale) && savedScale >= 1) { applyScale(savedScale) } }) } catch (_) { // ignore } handle.addEventListener('pointerdown', startDrag) window.addEventListener('pointermove', onPointerMove) handle.addEventListener('pointerup', endDrag) handle.addEventListener('pointercancel', endDrag) window.addEventListener('pointerup', endDrag) window.addEventListener('resize', computeMaxScale) } catch (_) { // best-effort only } } function msg(key) { if (typeof window.t === 'function') return window.t(key) return chrome.i18n.getMessage(key) || '' } function applyDayjsLocaleByUiLanguage(uiLang) { const lang = String(uiLang || 'auto') if (lang === 'zh_CN') { dayjs.locale('zh-cn') return } if (lang === 'ja') { dayjs.locale('ja') return } if (lang === 'ko') { dayjs.locale('ko') return } if (lang === 'en') { dayjs.locale('en') return } // auto: best-effort infer from browser UI language const ui = String(chrome.i18n.getUILanguage ? chrome.i18n.getUILanguage() : '').toLowerCase() if (ui.startsWith('zh')) { dayjs.locale('zh-cn') return } if (ui.startsWith('ja')) { dayjs.locale('ja') return } if (ui.startsWith('ko')) { dayjs.locale('ko') return } dayjs.locale('en') } function updateLockNowText(lockType) { if (lockType === 'PUBLIC') { $('#lock-now').text(msg('lockPublic')) } else if (lockType === 'PRIVATE') { $('#lock-now').text(msg('lockPrivate')) } else if (lockType === 'PROTECTED') { $('#lock-now').text(msg('lockProtected')) } } applyDayjsLocaleByUiLanguage(typeof window.getUiLanguage === 'function' ? window.getUiLanguage() : 'auto') if (isFullscreenMode()) { document.body.classList.add('fullscreen') } window.addEventListener('i18n:changed', (ev) => { applyDayjsLocaleByUiLanguage(ev && ev.detail ? ev.detail.lang : 'auto') updateLockNowText(currentMemoLock) renderUploadList(relistNow) }) let relistNow = [] const API_FLAVOR_V020_V021 = 'v020-v021' const DEFAULT_ATTACHMENT_ONLY_TEXT = '#附件 此为默认填充,如需自定义,请在发送附件前填写你的文本内容或者设置项里自定义.' function getAttachmentOnlyDefaultText(customText) { const value = typeof customText === 'string' ? customText.trim() : '' return value || DEFAULT_ATTACHMENT_ONLY_TEXT } function resolveSendContent(rawContent, resources, customText) { const value = typeof rawContent === 'string' ? rawContent : '' if (value.trim() !== '') return value const items = Array.isArray(resources) ? resources : [] if (items.length === 0) return '' return getAttachmentOnlyDefaultText(customText) } function get_info(callback) { chrome.storage.sync.get( { apiUrl: '', apiTokens: '', apiFlavor: '', hidetag: '', showtag: '', memo_lock: '', open_action: '', open_content: '', userid: '', memoUiPath: 'memos', resourceIdList: [], attachmentOnlyDefaultText: '' }, function (items) { var flag = false var returnObject = {} if (items.apiUrl === '' || items.apiTokens === '') { flag = false } else { flag = true } returnObject.status = flag returnObject.apiUrl = items.apiUrl returnObject.apiTokens = items.apiTokens returnObject.apiFlavor = items.apiFlavor returnObject.hidetag = items.hidetag returnObject.showtag = items.showtag returnObject.memo_lock = items.memo_lock returnObject.open_content = items.open_content returnObject.open_action = items.open_action returnObject.userid = items.userid returnObject.memoUiPath = items.memoUiPath returnObject.resourceIdList = items.resourceIdList returnObject.attachmentOnlyDefaultText = items.attachmentOnlyDefaultText if (callback) callback(returnObject) } ) } function getApiAdapter(info) { if (window.MemosApiAdapter && typeof window.MemosApiAdapter.resolve === 'function') { return window.MemosApiAdapter.resolve(info) } return null } function getMemoUid(memo) { if (!memo) return '' if (memo.uid != null && memo.uid !== '') return String(memo.uid) if (typeof memo.name === 'string' && memo.name) return memo.name.split('/').pop() return '' } get_info(function (info) { if (info.status) { //已经有绑定信息了,折叠 $('#blog_info').hide() } var memoNow = info.memo_lock if (memoNow == '') { chrome.storage.sync.set( { memo_lock: 'PUBLIC' } ) memoNow = 'PUBLIC' } currentMemoLock = memoNow updateLockNowText(memoNow) $('#apiUrl').val(info.apiUrl) $('#apiTokens').val(info.apiTokens) $('#hideInput').val(info.hidetag) $('#showInput').val(info.showtag) $('#attachmentOnlyDefaultText').val(info.attachmentOnlyDefaultText) if (info.open_action === 'upload_image') { //打开的时候就是上传图片 uploadImage(info.open_content) } else { const $textarea = $("textarea[name=text]") $textarea.val(info.open_content) focusTextareaToEnd($textarea) } relistNow = Array.isArray(info.resourceIdList) ? info.resourceIdList : [] renderUploadList(relistNow) initProportionalEditorResize() //从localstorage 里面读取数据 setTimeout(get_info, 1) }) chrome.storage.onChanged.addListener(function (changes, areaName) { if (areaName !== 'sync') return if (!changes.resourceIdList) return relistNow = Array.isArray(changes.resourceIdList.newValue) ? changes.resourceIdList.newValue : [] renderUploadList(relistNow) }) // focus is handled after textarea content is set //监听输入结束,保存未发送内容到本地 $("textarea[name=text]").blur(function () { chrome.storage.sync.set( { open_action: 'save_text', open_content: $("textarea[name=text]").val() } ) }) $("textarea[name=text]").on('keydown', function (ev) { if (ev.code === 'Enter' && (ev.ctrlKey || ev.metaKey)) { $('#content_submit_text').click() } }) $('#fullscreen').on('click', function () { if (isFullscreenMode()) return openFullscreenTab() }) //监听拖拽事件,实现拖拽到窗口上传图片 initDrag() //监听复制粘贴事件,实现粘贴上传图片 document.addEventListener('paste', function (e) { let photo = null if (e.clipboardData.files[0]) { photo = e.clipboardData.files[0] } else if (e.clipboardData.items[0] && e.clipboardData.items[0].getAsFile()) { photo = e.clipboardData.items[0].getAsFile() } if (photo != null) { uploadImage(photo) } }) function initDrag() { var file = null var obj = $("textarea[name=text]")[0] obj.ondragenter = function (ev) { if (ev.target.className === 'common-editor-inputer') { $.message({ message: msg('picDrag'), autoClose: false }) $('body').css('opacity', 0.3) } ev.dataTransfer.dropEffect = 'copy' } obj.ondragover = function (ev) { ev.preventDefault() ev.dataTransfer.dropEffect = 'copy' } obj.ondrop = function (ev) { $('body').css('opacity', 1) ev.preventDefault() var files = ev.dataTransfer.files || ev.target.files for (var i = 0; i < files.length; i++) { file = files[i] } uploadImage(file) } obj.ondragleave = function (ev) { ev.preventDefault() if (ev.target.className === 'common-editor-inputer') { $.message({ message: msg('picCancelDrag') }) $('body').css('opacity', 1) } } } function escapeHtml(input) { return String(input) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function focusTextareaToEnd($textarea) { try { const el = $textarea && $textarea[0] if (!el) return el.focus() const len = typeof el.value === 'string' ? el.value.length : 0 if (typeof el.setSelectionRange === 'function') { el.setSelectionRange(len, len) } } catch (_) { // best-effort only } } function buildV1ResourceStreamUrl(info, resource) { if (!info || !info.apiUrl || !resource) return '' // Use the configured apiUrl as the base (may include a reverse-proxy subpath). // Do NOT reduce to origin-only, otherwise deployments like https://host/memos/ will break. let root = String(info.apiUrl) try { const u = new URL(root) u.hash = '' u.search = '' root = u.toString() } catch (_) { // keep as-is } if (root && !root.endsWith('/')) root += '/' function isImageResource(r) { if (!r) return false const t = typeof r.type === 'string' ? r.type.toLowerCase() : '' if (t.startsWith('image/')) return true const fn = typeof r.filename === 'string' ? r.filename.toLowerCase() : '' return /\.(png|jpe?g|gif|webp|bmp|svg|avif|heic)$/.test(fn) } function isProbablyUid(s) { if (typeof s !== 'string') return false const v = s.trim() if (!v) return false if (v.indexOf('/') !== -1) return false if (/^\d+$/.test(v)) return false // shortuuid v4 typically uses URL-safe base57-ish; allow a conservative charset. return /^[A-Za-z0-9_-]{8,}$/.test(v) } function buildStreamUrl(uid) { const base = root + 'o/r/' + encodeURIComponent(uid) return isImageResource(resource) ? base + '?thumbnail=1' : base } const uidRaw = resource.uid != null ? resource.uid : resource.UID != null ? resource.UID : resource.Uid const uid = typeof uidRaw === 'string' ? uidRaw : uidRaw != null ? String(uidRaw) : '' if (uid.trim() !== '') return buildStreamUrl(uid.trim()) // Legacy versions (e.g. v0.18) may only expose numeric `id` without `uid/name`. const idRaw = resource.id != null ? resource.id : resource.ID != null ? resource.ID : resource.Id const id = typeof idRaw === 'number' && Number.isFinite(idRaw) ? String(Math.floor(idRaw)) : typeof idRaw === 'string' && idRaw.trim() !== '' && !Number.isNaN(Number(idRaw)) ? String(Math.floor(Number(idRaw))) : '' if (id) return buildStreamUrl(id) // Fallback for older resource shapes. const name = typeof resource.name === 'string' ? resource.name : '' // In some memo payloads, the uid may appear as `name` directly. // Example: name="ETU6hjuR..." should map to /o/r/:uid, not /file/:name/:filename. if (isProbablyUid(name)) return buildStreamUrl(name.trim()) const fileId = resource.publicId || resource.filename if (name && fileId) return root + 'file/' + name + '/' + fileId return '' } function normalizeUnixTimeToMs(input) { if (input == null) return null if (typeof input === 'number' && Number.isFinite(input)) { // Heuristic: seconds are typically 10 digits; milliseconds are 13 digits. if (input > 0 && input < 1e12) return input * 1000 return input } if (typeof input === 'string') { const s = input.trim() if (/^\d+$/.test(s)) { const n = Number(s) if (!Number.isFinite(n)) return null if (n > 0 && n < 1e12) return n * 1000 return n } // ISO/RFC3339 etc. return s } return null } function memoFromNow(memo) { if (!memo) return '' const raw = memo.createTime || memo.createdAt || memo.createdTs const normalized = normalizeUnixTimeToMs(raw) if (!normalized) return '' return dayjs(normalized).fromNow() } function hydrateV1PreviewImages(info) { const adapter = getApiAdapter(info) if (!adapter || !adapter.needsAuthenticatedImagePreview()) return if (!info || !info.apiUrl) return const token = info && info.apiTokens != null ? String(info.apiTokens).trim() : '' let root = String(info.apiUrl) let apiOrigin = '' try { const u = new URL(root) u.hash = '' u.search = '' root = u.toString() apiOrigin = u.origin } catch (_) { // keep as-is } if (root && !root.endsWith('/')) root += '/' const nodes = document.querySelectorAll('img.random-image') if (!nodes || nodes.length === 0) return // Revoke blob URLs on popup unload to avoid leaking memory. if (!window.__memosBberObjectUrls) { window.__memosBberObjectUrls = [] window.addEventListener('unload', function () { const list = window.__memosBberObjectUrls || [] for (let i = 0; i < list.length; i++) { try { URL.revokeObjectURL(list[i]) } catch (_) {} } window.__memosBberObjectUrls = [] }) } const transparentPixel = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' function resolveToAbsoluteUrl(url) { const u = String(url || '').trim() if (!u) return '' if (u.startsWith('data:') || u.startsWith('blob:') || u.startsWith('chrome-extension:')) return '' if (u.startsWith('#')) return '' try { return new URL(u, root).toString() } catch (_) { return '' } } function isSameOrigin(url) { if (!apiOrigin) return false try { return new URL(url).origin === apiOrigin } catch (_) { return false } } function looksLikeMemosResourceUrl(absUrl) { const s = String(absUrl || '') return s.indexOf('/o/r/') !== -1 || s.indexOf('/file/') !== -1 } nodes.forEach(function (img) { const hasAuthAttr = img.hasAttribute('data-auth-src') const url = img.getAttribute('data-auth-src') || img.getAttribute('src') if (!url) return if (img.getAttribute('data-auth-loaded') === '1') return const abs = resolveToAbsoluteUrl(url) if (!abs) return // Only hydrate same-origin resources that require Authorization. if (!isSameOrigin(abs)) return // Reduce unnecessary fetches: only hydrate known resource endpoints, // or images explicitly marked as auth-required. if (!hasAuthAttr && !looksLikeMemosResourceUrl(abs)) return img.setAttribute('data-auth-loaded', '1') // Prevent a broken-image icon before hydration completes. // Only do this for images explicitly marked as auth-required. if (hasAuthAttr) { const currentSrc = img.getAttribute('src') if (!currentSrc || currentSrc === abs) { img.setAttribute('src', transparentPixel) } } fetch(abs, { method: 'GET', credentials: 'include', headers: token ? { Authorization: 'Bearer ' + token } : {} }) .then(function (res) { if (!res || !res.ok) throw new Error('HTTP ' + (res ? res.status : '0')) const ct = (res.headers && typeof res.headers.get === 'function') ? (res.headers.get('content-type') || '') : '' if (ct && !ct.toLowerCase().startsWith('image/')) throw new Error('Not an image') return res.blob() }) .then(function (blob) { const objectUrl = URL.createObjectURL(blob) window.__memosBberObjectUrls.push(objectUrl) img.src = objectUrl }) .catch(function () { // Fall back to the original URL so the browser can still try cookie-based auth. if (hasAuthAttr) { try { img.setAttribute('src', abs) } catch (_) {} } }) }) } function renderUploadList(list) { const $wrapper = $('.upload-list-wrapper') const $list = $('#uploadlist') if ($list.length === 0) return const items = Array.isArray(list) ? list : [] if (items.length === 0) { if ($wrapper.length) $wrapper.hide() $list.html('') return } if ($wrapper.length) $wrapper.show() const tipReorder = escapeHtml(msg('tipReorder')) const tipDelete = escapeHtml(msg('tipDeleteAttachment')) let html = '' for (let i = 0; i < items.length; i++) { const att = items[i] || {} const name = att.name || '' const id = att.id != null ? String(att.id) : '' const filename = att.filename || name html += '