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'
let maxScale = 1
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 editorRect = editor.getBoundingClientRect()
const toolsRect = tools.getBoundingClientRect()
const toolsStyle = window.getComputedStyle(tools)
const gap = parseFloat(toolsStyle.marginTop || '0') || 0
const availW = Math.max(0, viewportW - safety - editorRect.left)
const availH = Math.max(0, viewportH - safety - toolsRect.height - editorRect.top - gap)
const scaleW = baseW > 0 ? availW / baseW : 1
const scaleH = baseH > 0 ? availH / baseH : 1
maxScale = Math.max(1, Math.min(scaleW, scaleH))
}
computeMaxScale()
window.addEventListener('resize', computeMaxScale)
let dragging = false
let startX = 0
let startY = 0
let startScale = 1
let rafId = 0
let pendingScale = null
const parseScale = (raw) => {
const s = typeof raw === 'number' && Number.isFinite(raw)
? raw
: typeof raw === 'string' && raw.trim() !== '' && !Number.isNaN(Number(raw))
? Number(raw)
: 1
return s > 0 ? s : 1
}
const readCurrentScale = () => {
const w = parseFloat(editor.style.width || '')
const h = parseFloat(editor.style.height || '')
const sw = baseW > 0 && Number.isFinite(w) ? w / baseW : 1
const sh = baseH > 0 && Number.isFinite(h) ? h / baseH : 1
return Math.max(1, sw, sh)
}
const applyScale = (scale) => {
const s = Math.max(1, Math.min(maxScale, scale))
editor.style.width = `${Math.round(baseW * s)}px`
editor.style.height = `${Math.round(baseH * s)}px`
}
const applyScaleInstant = (scale) => {
// In case CSS transitions exist (or get reintroduced), keep restores immediate.
const prevTransition = editor.style.transition
editor.style.transition = 'none'
applyScale(scale)
window.requestAnimationFrame(function () {
editor.style.transition = prevTransition
})
}
// Restore previously saved scale synchronously (localStorage) first.
// This makes the popup *feel* synchronous because it can apply before async chrome.storage returns.
let restoredFromLocal = false
let localScale = 1
try {
const raw = window.localStorage ? window.localStorage.getItem(storageKey) : null
const s = parseScale(raw)
if (s && s !== 1) {
localScale = s
restoredFromLocal = true
applyScaleInstant(s)
}
} catch (_) {
// ignore
}
// Restore from chrome.storage.sync (best-effort) and keep localStorage in sync.
try {
chrome.storage.sync.get({ [storageKey]: 1 }, function (items) {
const raw = items ? items[storageKey] : 1
const s = parseScale(raw)
const shouldApply = !restoredFromLocal || Math.abs(s - localScale) > 1e-6
if (shouldApply) applyScaleInstant(s)
try {
if (window.localStorage) window.localStorage.setItem(storageKey, String(s))
} catch (_) {}
})
} catch (_) {
// ignore
}
const scheduleApply = () => {
if (rafId) return
rafId = window.requestAnimationFrame(() => {
rafId = 0
if (pendingScale == null) return
const s = pendingScale
pendingScale = null
applyScale(s)
})
}
handle.addEventListener('pointerdown', (ev) => {
dragging = true
startX = ev.clientX
startY = ev.clientY
startScale = readCurrentScale()
computeMaxScale()
try { handle.setPointerCapture(ev.pointerId) } catch (_) {}
ev.preventDefault()
})
handle.addEventListener('pointermove', (ev) => {
if (!dragging) return
const dx = ev.clientX - startX
const dy = ev.clientY - startY
// Proportional scale based on diagonal length for smoother, more linear feel.
const diag0 = Math.hypot(baseW, baseH)
const targetW = baseW * startScale + dx
const targetH = baseH * startScale + dy
const diag1 = Math.hypot(targetW, targetH)
const next = diag0 > 0 ? diag1 / diag0 : startScale
pendingScale = next
scheduleApply()
})
const endDrag = () => {
dragging = false
// Flush any pending RAF update before persisting.
if (pendingScale != null) {
applyScale(pendingScale)
pendingScale = null
}
// Persist current scale (best-effort).
try {
const s = readCurrentScale()
if (typeof s === 'number' && Number.isFinite(s)) {
try {
if (window.localStorage) window.localStorage.setItem(storageKey, String(s))
} catch (_) {}
chrome.storage.sync.set({ [storageKey]: s })
}
} catch (_) {
// ignore
}
}
handle.addEventListener('pointerup', endDrag)
handle.addEventListener('pointercancel', endDrag)
} 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 = []
function get_info(callback) {
chrome.storage.sync.get(
{
apiUrl: '',
apiTokens: '',
apiFlavor: '',
hidetag: '',
showtag: '',
memo_lock: '',
open_action: '',
open_content: '',
userid: '',
memoUiPath: 'memos',
resourceIdList: []
},
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
if (callback) callback(returnObject)
}
)
}
function isV023Flavor(info) {
return info && info.apiFlavor === 'v023' && window.MemosApiV023
}
function isV1Flavor(info) {
return info && info.apiFlavor === 'v1' && window.MemosApiV1
}
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)
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) {
if (!isV1Flavor(info)) return
if (!info || !info.apiUrl || !info.apiTokens) return
const token = String(info.apiTokens)
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',
headers: {
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 () {
// Don't break previews for modern versions where plain may already work.
if (hasAuthAttr) {
try { img.removeAttribute('src') } 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 +=
'