From 9a2959d3b35b0efc509c17ff976898fa5eaf33f9 Mon Sep 17 00:00:00 2001 From: hoaitx Date: Sat, 18 Jul 2026 13:32:16 +0000 Subject: [PATCH 01/23] feat: implement toast notifications for user feedback & improvement image sync --- app.vue | 1 + assets/css/crepe.css | 100 ++- assets/svg/alert-square-rounded.svg | 1 + assets/svg/cloud-upload.svg | 1 + assets/svg/cloud.svg | 1 + assets/svg/fingerprint.svg | 1 + components/AppToast.vue | 82 +++ components/editor/crepe/index.vue | 388 ++++++++++- components/editor/tiptap/index.vue | 10 +- components/modal/alert-set-password/index.vue | 7 +- components/modal/change-folder-name/index.vue | 14 +- .../modal/confirm-change-adapter/index.vue | 8 +- .../modal/confirm-delete-folder/index.vue | 8 +- components/modal/confirm-e2ee-key/index.vue | 12 +- .../modal/confirm-image-sync-change/index.vue | 36 + .../modal/export-notes-confirm/index.vue | 9 +- components/modal/image-sync-config/index.vue | 100 +++ components/modal/import-notes/index.vue | 11 +- components/modal/insert-image/index.vue | 8 +- components/modal/insert-link/index.vue | 7 +- components/modal/menu-editor/index.vue | 4 +- components/modal/menu-folder/index.vue | 4 +- components/modal/menu-note/index.vue | 4 +- components/modal/menu-sidebar/index.vue | 4 +- components/modal/notes-detail/index.vue | 7 +- components/modal/set-password/index.vue | 10 +- components/modal/setting/index.vue | 655 ++++++++---------- components/modal/setting/setting-row.vue | 15 + components/modal/setting/setting-select.vue | 42 ++ components/modal/sort-note/index.vue | 4 +- components/modal/sync-config/index.vue | 38 + components/modal/unlock-notes/index.vue | 9 +- components/navbar/top/index.vue | 501 ++++++++------ composables/useToast.ts | 38 + locales/en.json | 30 +- locales/vi.json | 26 +- locales/zhtw.json | 24 +- pages/index/index.vue | 16 +- services/image.ts | 11 + utils/snackbar.ts | 25 +- 40 files changed, 1592 insertions(+), 680 deletions(-) create mode 100644 assets/svg/alert-square-rounded.svg create mode 100644 assets/svg/cloud-upload.svg create mode 100644 assets/svg/cloud.svg create mode 100644 assets/svg/fingerprint.svg create mode 100644 components/AppToast.vue create mode 100644 components/modal/confirm-image-sync-change/index.vue create mode 100644 components/modal/image-sync-config/index.vue create mode 100644 components/modal/setting/setting-row.vue create mode 100644 components/modal/setting/setting-select.vue create mode 100644 components/modal/sync-config/index.vue create mode 100644 composables/useToast.ts diff --git a/app.vue b/app.vue index e09851b..23d91b7 100644 --- a/app.vue +++ b/app.vue @@ -12,4 +12,5 @@ onMounted(() => { diff --git a/assets/css/crepe.css b/assets/css/crepe.css index d2a3e82..280a271 100644 --- a/assets/css/crepe.css +++ b/assets/css/crepe.css @@ -22,12 +22,14 @@ --crepe-color-selected: oklch(var(--p)); --crepe-color-inline-area: #2b2b2b; - --crepe-font-title: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; - --crepe-font-default: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; - --crepe-font-code: "Space Mono", Fira Code, Menlo, Monaco, "Courier New", - Courier, monospace; + --crepe-font-title: + system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --crepe-font-default: + system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --crepe-font-code: + "Space Mono", Fira Code, Menlo, Monaco, "Courier New", Courier, monospace; /* --crepe-shadow-1: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), @@ -122,6 +124,59 @@ height: 100px !important; } +/* image sync badge */ +.milkdown .milkdown-image-block { + position: relative; +} +.image-sync-badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 20; + cursor: pointer; + width: 26px; + height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: + transform 0.15s, + opacity 0.15s; + opacity: 0.9; + border: none; + padding: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} +.image-sync-badge:hover { + opacity: 1; + transform: scale(1.12); +} +.image-sync-badge--pending { + background: rgba(0, 0, 0, 0.55); +} +.image-sync-badge--failed { + background: rgba(0, 0, 0, 0.55); +} +.image-sync-badge--syncing { + background: rgba(0, 0, 0, 0.55); + cursor: default; + animation: sync-badge-spin 1s linear infinite; +} +.image-sync-badge svg { + width: 18px; + height: 18px; + color: #f59e0b; +} +@keyframes sync-badge-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + /* image */ .milkdown .milkdown-image-block .image-edit { border-radius: 0.5rem !important; @@ -141,11 +196,44 @@ align-items: center; justify-content: center; } +.milkdown .milkdown-image-block .caption-input { + font-size: 0.875rem !important; + font-weight: 600 !important; + font-style: italic !important; +} /* Prevent scroll when resizing image on mobile */ .milkdown .milkdown-image-block .image-resize-handle { touch-action: none; } +/* Image loading placeholder — hide broken icon while blob URL is resolving */ +.milkdown .milkdown-image-block img { + transition: opacity 0.3s ease; +} +.milkdown .milkdown-image-block img[src^="opennotas://"] { + opacity: 0; +} +.milkdown .milkdown-image-block > .image-wrapper:has(img[src^="opennotas://"]) { + min-height: 120px; + border-radius: 8px; + background-image: linear-gradient( + 90deg, + oklch(var(--b2)) 25%, + oklch(var(--b3)) 50%, + oklch(var(--b2)) 75% + ); + background-size: 200% 100%; + animation: img-skeleton-shimmer 1.5s ease-in-out infinite; +} +@keyframes img-skeleton-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + /* Link preview */ .milkdown .milkdown-link-preview .link-preview { height: auto !important; diff --git a/assets/svg/alert-square-rounded.svg b/assets/svg/alert-square-rounded.svg new file mode 100644 index 0000000..1726515 --- /dev/null +++ b/assets/svg/alert-square-rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/svg/cloud-upload.svg b/assets/svg/cloud-upload.svg new file mode 100644 index 0000000..300583e --- /dev/null +++ b/assets/svg/cloud-upload.svg @@ -0,0 +1 @@ + diff --git a/assets/svg/cloud.svg b/assets/svg/cloud.svg new file mode 100644 index 0000000..06fb5f1 --- /dev/null +++ b/assets/svg/cloud.svg @@ -0,0 +1 @@ + diff --git a/assets/svg/fingerprint.svg b/assets/svg/fingerprint.svg new file mode 100644 index 0000000..4ea0b87 --- /dev/null +++ b/assets/svg/fingerprint.svg @@ -0,0 +1 @@ + diff --git a/components/AppToast.vue b/components/AppToast.vue new file mode 100644 index 0000000..00bb699 --- /dev/null +++ b/components/AppToast.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/components/editor/crepe/index.vue b/components/editor/crepe/index.vue index c8855fd..8ed445c 100644 --- a/components/editor/crepe/index.vue +++ b/components/editor/crepe/index.vue @@ -2,8 +2,8 @@ import { Crepe } from "@milkdown/crepe"; import { editorViewCtx } from "@milkdown/core"; import { undoCommand, redoCommand } from "@milkdown/plugin-history"; -import { replaceAll, getMarkdown } from '@milkdown/utils' -import { TextSelection } from '@milkdown/prose/state' +import { replaceAll, getMarkdown, $prose } from '@milkdown/utils' +import { TextSelection, Plugin } from '@milkdown/prose/state' import * as Diff from 'diff' const { $i18n } = useNuxtApp(); @@ -15,7 +15,12 @@ import { saveImage, resolveImageUrl, isOpenNotasImageUrl, + parseImageUrl, + getImageMeta, } from '~/services/image'; +import { syncImages } from '~/utils/image-sync'; +import cloudUploadSvgRaw from '~/assets/svg/cloud-upload.svg?raw'; +import alertSquareSvgRaw from '~/assets/svg/alert-square-rounded.svg?raw'; const props = defineProps([ 'value', @@ -34,6 +39,20 @@ const isLoading = ref(true); let editor: Crepe; +// Track resolved blob URLs → imageId for badge lookup +const blobUrlToImageId = new Map(); +// Track injected badge elements per imageId +const imageBadgeMap = new Map(); +let domObserver: MutationObserver | null = null; +let refreshInterval: ReturnType | null = null; +let isRefreshingBadges = false; +let imagePasteHandler: ((e: Event) => void) | null = null; +let imageResizeHandler: ((e: PointerEvent) => void) | null = null; +let isReplacingAll = false; + +const toolbarPos = ref<{ x: number; y: number } | null>(null); +const toolbarWrapRef = ref(null); + const handleImageUpload = async (file: File): Promise => { if (!props.noteId) { emit('alertMessage', $i18n.t('app.message_image_sync_failed')); @@ -57,7 +76,12 @@ const handleImageUpload = async (file: File): Promise => { const handleProxyDomURL = async (src: string): Promise => { if (isOpenNotasImageUrl(src)) { try { - return await resolveImageUrl(src, props.settings); + const imageId = parseImageUrl(src); + const resolved = await resolveImageUrl(src, props.settings); + if (imageId && resolved.startsWith('blob:')) { + blobUrlToImageId.set(resolved, imageId); + } + return resolved; } catch (error) { console.error('Failed to resolve image URL:', error); return src; @@ -66,6 +90,151 @@ const handleProxyDomURL = async (src: string): Promise => { return src; }; +const createBadgeSvg = (type: 'upload' | 'warning' | 'spinner'): string => { + if (type === 'upload') return cloudUploadSvgRaw; + if (type === 'warning') return alertSquareSvgRaw; + return ''; +}; + +const handleManualSync = async () => { + // Mark all visible pending/failed badges as syncing + imageBadgeMap.forEach((badge) => { + if (!badge.classList.contains('image-sync-badge--syncing')) { + badge.className = 'image-sync-badge image-sync-badge--syncing'; + badge.innerHTML = createBadgeSvg('spinner'); + badge.title = $i18n.t('app.image_sync_badge_syncing'); + } + }); + + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 15_000) + ); + + try { + const result = await Promise.race([syncImages(props.settings), timeout]); + await refreshSyncBadges(); + if (result.synced > 0) { + showSuccess($i18n.t('app.message_image_sync_success') as string); + } else if (result.failed > 0) { + showError($i18n.t('app.message_image_sync_failed') as string); + } + } catch { + await refreshSyncBadges(); + showError($i18n.t('app.message_image_sync_retry') as string); + } +}; + +const refreshSyncBadges = async () => { + if (isRefreshingBadges) return; + isRefreshingBadges = true; + + // Disconnect observer to prevent re-entrant mutations from badge DOM changes + domObserver?.disconnect(); + + try { + if (!props.settings?.imageSync?.enabled) { + imageBadgeMap.forEach((badge) => badge.remove()); + imageBadgeMap.clear(); + stopRefreshInterval(); + return; + } + + const editorEl = document.querySelector('#crepe-editor'); + if (!editorEl) return; + + const imgs = editorEl.querySelectorAll('img'); + const activeImageIds = new Set(); + let hasPendingOrFailed = false; + + for (const img of imgs) { + const imageId = blobUrlToImageId.get(img.src); + if (!imageId) continue; + activeImageIds.add(imageId); + + const meta = await getImageMeta(imageId); + if (!meta || meta.syncStatus === 'synced') { + const existing = imageBadgeMap.get(imageId); + if (existing) { + existing.remove(); + imageBadgeMap.delete(imageId); + } + continue; + } + + hasPendingOrFailed = true; + const status = meta.syncStatus; + let badge = imageBadgeMap.get(imageId); + + if (!badge) { + badge = document.createElement('button'); + badge.className = `image-sync-badge image-sync-badge--${status}`; + badge.innerHTML = createBadgeSvg(status === 'failed' ? 'warning' : 'upload'); + badge.title = $i18n.t(status === 'failed' ? 'app.image_sync_badge_failed' : 'app.image_sync_badge_pending'); + badge.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + handleManualSync(); + }); + + const container = img.closest('.milkdown-image-block') ?? img.parentElement; + if (container) { + container.appendChild(badge); + imageBadgeMap.set(imageId, badge); + } + } else if (!badge.classList.contains('image-sync-badge--syncing')) { + badge.className = `image-sync-badge image-sync-badge--${status}`; + badge.innerHTML = createBadgeSvg(status === 'failed' ? 'warning' : 'upload'); + badge.title = $i18n.t(status === 'failed' ? 'app.image_sync_badge_failed' : 'app.image_sync_badge_pending'); + } + } + + // Remove stale badges for images no longer in the editor + for (const [imageId, badge] of imageBadgeMap) { + if (!activeImageIds.has(imageId)) { + badge.remove(); + imageBadgeMap.delete(imageId); + } + } + + if (hasPendingOrFailed) { + startRefreshInterval(); + } else { + stopRefreshInterval(); + } + } finally { + isRefreshingBadges = false; + // Reconnect observer after DOM changes are done + const el = document.querySelector('#crepe-editor'); + if (el && domObserver) { + domObserver.observe(el, { childList: true, subtree: true }); + } + } +}; + +const startRefreshInterval = () => { + if (refreshInterval) return; + refreshInterval = setInterval(refreshSyncBadges, 10_000); +}; + +const stopRefreshInterval = () => { + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + } +}; + +const setupDomObserver = () => { + const editorEl = document.querySelector('#crepe-editor'); + if (!editorEl) return; + + let debounceTimer: ReturnType | null = null; + domObserver = new MutationObserver(() => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(refreshSyncBadges, 300); + }); + domObserver.observe(editorEl, { childList: true, subtree: true }); +}; + onMounted(() => { editor = new Crepe({ root: document.querySelector("#crepe-editor")!, @@ -111,7 +280,145 @@ onMounted(() => { editor.setReadonly(true); } + editor.editor.use( + $prose(() => new Plugin({ + appendTransaction(transactions, oldState, newState) { + if (isReplacingAll) return null; + if (!transactions.some(t => t.docChanged)) return null; + + const oldSrcs = new Set(); + oldState.doc.descendants((node) => { + if (node.type.name === 'image-block') oldSrcs.add(node.attrs.src); + }); + + let tr = newState.tr; + let changed = false; + newState.doc.descendants((node, pos) => { + if ( + node.type.name === 'image-block' && + node.attrs.ratio === 1 && + !oldSrcs.has(node.attrs.src) + ) { + tr = tr.setNodeMarkup(pos, null, { ...node.attrs, ratio: 0.7 }); + changed = true; + } + }); + return changed ? tr : null; + }, + })) + ); + await editor.create(); + + // Intercept image paste before ProseMirror (capture phase runs first) + imagePasteHandler = (e: Event) => { + const event = e as ClipboardEvent; + const items = Array.from(event.clipboardData?.items ?? []); + const imageItem = items.find( + (item) => item.kind === 'file' && item.type.startsWith('image/') + ); + if (!imageItem) return; + + const file = imageItem.getAsFile(); + if (!file) return; + + e.stopPropagation(); + e.preventDefault(); + + // Capture insert position synchronously before async upload + let insertPos = -1; + editor.editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + try { + insertPos = view.state.selection.$from.after(1); + } catch (_) { + insertPos = view.state.doc.content.size; + } + }); + + handleImageUpload(file).then((url) => { + editor.editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + const schema = view.state.schema; + const imageBlockType = Object.entries(schema.nodes).find( + ([name, t]) => name.toLowerCase().includes('image') && !t.spec.inline && (t.spec as any).atom + )?.[1]; + if (!imageBlockType) return; + + const imageNode = imageBlockType.create({ src: url }); + const pos = insertPos >= 0 ? insertPos : view.state.doc.content.size; + view.dispatch(view.state.tr.insert(pos, imageNode)); + }); + }); + }; + + document.querySelector('#crepe-editor') + ?.addEventListener('paste', imagePasteHandler, true); + + // Dampen image resize speed — Milkdown uses absolute 1:1 position, we use delta × damping + const RESIZE_DAMPING = 0.4; + imageResizeHandler = (e: PointerEvent) => { + const handle = (e.target as Element).closest('.image-resize-handle'); + if (!handle) return; + + const imgEl = handle.closest('.milkdown-image-block')?.querySelector('img'); + if (!imgEl) return; + + e.preventDefault(); + e.stopPropagation(); + + const startY = e.clientY; + const startHeight = imgEl.getBoundingClientRect().height; + let lastHeight = startHeight; + + const onMove = (me: PointerEvent) => { + me.stopPropagation(); + me.preventDefault(); + const newHeight = Math.max(100, startHeight + (me.clientY - startY) * RESIZE_DAMPING); + lastHeight = newHeight; + imgEl.dataset.height = newHeight.toFixed(2); + imgEl.style.height = `${newHeight}px`; + }; + + const onUp = (ue: PointerEvent) => { + ue.stopPropagation(); + window.removeEventListener('pointermove', onMove, true); + window.removeEventListener('pointerup', onUp, true); + + const originHeight = parseFloat(imgEl.dataset.origin ?? '0'); + if (!originHeight) return; + + const ratio = parseFloat((lastHeight / originHeight).toFixed(2)); + if (isNaN(ratio)) return; + + editor.editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + let targetPos = -1; + view.state.doc.descendants((node, pos) => { + if (targetPos !== -1) return false; + if (node.isAtom && node.attrs?.src !== undefined) { + const domNode = view.nodeDOM(pos); + if (domNode instanceof Element && domNode.contains(imgEl)) { + targetPos = pos; + } + } + return targetPos === -1; + }); + if (targetPos === -1) return; + const node = view.state.doc.nodeAt(targetPos); + if (!node) return; + view.dispatch(view.state.tr.setNodeMarkup(targetPos, null, { ...node.attrs, ratio })); + }); + }; + + window.addEventListener('pointermove', onMove, true); + window.addEventListener('pointerup', onUp, true); + }; + + document.querySelector('#crepe-editor') + ?.addEventListener('pointerdown', imageResizeHandler as EventListener, true); + + setupDomObserver(); setTimeout(() => { focus(); }, 100); @@ -156,6 +463,20 @@ const focusState = () => { } onUnmounted(() => { + domObserver?.disconnect(); + stopRefreshInterval(); + imageBadgeMap.clear(); + blobUrlToImageId.clear(); + if (imagePasteHandler) { + document.querySelector('#crepe-editor') + ?.removeEventListener('paste', imagePasteHandler, true); + imagePasteHandler = null; + } + if (imageResizeHandler) { + document.querySelector('#crepe-editor') + ?.removeEventListener('pointerdown', imageResizeHandler as EventListener, true); + imageResizeHandler = null; + } editor.destroy(); }); @@ -167,6 +488,7 @@ const silent = ref(false); const slientUpdateValue = async (value: string) => { if (!editor) return silent.value = true + isReplacingAll = true // try catch to avoid error try { @@ -248,9 +570,65 @@ const slientUpdateValue = async (value: string) => { }) } catch (_) { } + isReplacingAll = false silent.value = false } +watch(() => props.noteId, () => { + toolbarPos.value = null; +}); + +const startToolbarDrag = (e: PointerEvent) => { + const el = toolbarWrapRef.value; + if (!el) return; + + const startX = e.clientX; + const startY = e.clientY; + const initRect = el.getBoundingClientRect(); + const offsetX = startX - initRect.left; + const offsetY = startY - initRect.top; + const DRAG_THRESHOLD = 5; + let dragging = false; + + const onMove = (me: PointerEvent) => { + if (!dragging) { + if (Math.hypot(me.clientX - startX, me.clientY - startY) < DRAG_THRESHOLD) return; + dragging = true; + if (!toolbarPos.value) { + toolbarPos.value = { x: initRect.left, y: initRect.top }; + } + } + + me.preventDefault(); + const container = document.querySelector('#form-editors'); + const cr = container?.getBoundingClientRect() ?? { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }; + toolbarPos.value = { + x: Math.max(cr.left, Math.min(me.clientX - offsetX, cr.right - el.offsetWidth)), + y: Math.max(cr.top, Math.min(me.clientY - offsetY, cr.bottom - el.offsetHeight)), + }; + }; + + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); +}; + +const toolbarStyle = computed(() => { + if (!toolbarPos.value) return {}; + return { + position: 'fixed' as const, + left: `${toolbarPos.value.x}px`, + top: `${toolbarPos.value.y}px`, + bottom: 'auto', + zIndex: 50, + margin: 0, + }; +}); + defineExpose({ focus, readonly, @@ -274,7 +652,9 @@ defineExpose({ -
+
diff --git a/components/editor/tiptap/index.vue b/components/editor/tiptap/index.vue index b7c58ae..c04a770 100644 --- a/components/editor/tiptap/index.vue +++ b/components/editor/tiptap/index.vue @@ -514,14 +514,14 @@ const openLink = () => { } } -button { +.ProseMirror button { @apply bg-base-300 rounded border-none text-sm font-medium py-1 px-2 transition; } -button.is-active, -input.is-active, -select.is-active, -textarea.is-active { +.ProseMirror button.is-active, +.ProseMirror input.is-active, +.ProseMirror select.is-active, +.ProseMirror textarea.is-active { @apply bg-primary text-primary-content; } diff --git a/components/modal/alert-set-password/index.vue b/components/modal/alert-set-password/index.vue index 85119eb..663afc1 100644 --- a/components/modal/alert-set-password/index.vue +++ b/components/modal/alert-set-password/index.vue @@ -7,8 +7,9 @@ const handleClickClose = () => {