Compare commits

...

3 Commits

Author SHA1 Message Date
b6818bc0d0 fix: edit_file diff 显示修复 — 多行替换排序 & 行间间隙 2026-06-07 23:48:08 +08:00
84336b1802 fix(input): 重构消息队列动画 — 去掉容器外框、纯位移FLIP同步动画 2026-06-07 23:20:57 +08:00
d639278182 fix(overlay): 修复手机端图片/视频本地选择兼容性问题
- 将 local-files 事件类型由 FileList 改为 File[],避免 WebView 中 FileList 在 input 清空后失效
- 屏蔽文件选择器关闭后 500ms 内的 backdrop click,防止 touch 穿透误关 overlay
- 延迟 200ms 清空 input value,避免国产手机 WebView 异步填充 files 被截断
2026-06-07 17:19:53 +08:00
7 changed files with 163 additions and 188 deletions

View File

@ -1228,7 +1228,7 @@ function renderEasterEgg(result: any, args: any): string {
grid-template-columns: 5ch 1ch minmax(0, 1fr);
column-gap: 8px;
padding: 2px 4px;
margin: 1px 0;
margin: 0;
align-items: start;
}

View File

@ -386,6 +386,14 @@ function renderEditFile(result: any, args: any): string {
return true;
})
.sort((a: any, b: any) => {
const aIsChange = a.marker === '-' || a.marker === '+';
const bIsChange = b.marker === '-' || b.marker === '+';
// 仅变更行之间优先按 marker 分组(- 在前 + 在后),再按行号
if (aIsChange && bIsChange) {
const markerDelta = (markerOrder[a.marker] ?? 9) - (markerOrder[b.marker] ?? 9);
return markerDelta || (a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER);
}
// 上下文行或跨类型:按行号优先
const lineDelta = (a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER);
return lineDelta || (markerOrder[a.marker] ?? 9) - (markerOrder[b.marker] ?? 9);
})

View File

@ -1143,20 +1143,16 @@ const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)';
const runtimeQueueList = ref<HTMLElement | null>(null);
const runtimeQueueItemRefs = new Map<string, HTMLElement>();
const runtimeQueuePrevIds: string[] = [];
const runtimeQueueRenderedIds: string[] = [];
const runtimeQueuePrevRects = new Map<string, DOMRect>();
const runtimeQueuePrevElements = new Map<string, HTMLElement>();
const runtimeQueueGhosts = new Map<string, HTMLElement>();
const runtimeQueueActiveAnimations = new WeakMap<HTMLElement, Animation>();
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
const runtimeQueueTransitioning = ref(false);
let runtimeQueueAnimationReady = false;
let runtimeQueueTransitionTimer: number | null = null;
const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
if (!id) {
return;
}
if (!id) return;
if (el instanceof HTMLElement) {
runtimeQueueItemRefs.set(id, el);
} else {
@ -1164,31 +1160,21 @@ const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
}
};
const stopRuntimeQueueAnimation = (el: HTMLElement) => {
const anim = runtimeQueueActiveAnimations.get(el);
if (anim) {
try {
anim.cancel();
} catch {
// ignore
}
runtimeQueueActiveAnimations.delete(el);
}
const timer = runtimeQueueFallbackTimers.get(el);
if (timer) {
clearTimeout(timer);
runtimeQueueFallbackTimers.delete(el);
}
el.style.removeProperty('transition');
el.style.removeProperty('transform');
el.style.removeProperty('pointer-events');
const resolveRuntimeQueueOffset = (el: HTMLElement): number => {
const rectHeight = Math.round(el.getBoundingClientRect().height);
if (rectHeight > 0) return rectHeight;
if (el.offsetHeight > 0) return el.offsetHeight;
return 34;
};
const snapshotRuntimeQueueIds = (): string[] =>
runtimeQueuedMessagesForRender.value
.map((entry) => String(entry?.id || '').trim())
.filter((id) => !!id);
const keepRuntimeQueueTransitionCollapsed = () => {
runtimeQueueTransitioning.value = true;
if (runtimeQueueTransitionTimer !== null) {
window.clearTimeout(runtimeQueueTransitionTimer);
}
if (runtimeQueueTransitionTimer !== null) window.clearTimeout(runtimeQueueTransitionTimer);
runtimeQueueTransitionTimer = window.setTimeout(() => {
runtimeQueueTransitioning.value = false;
runtimeQueueTransitionTimer = null;
@ -1196,197 +1182,158 @@ const keepRuntimeQueueTransitionCollapsed = () => {
}, RUNTIME_QUEUE_ANIM_DURATION + 40);
};
const playRuntimeQueueTransform = (
const cancelAnim = (el: HTMLElement) => {
const timer = runtimeQueueFallbackTimers.get(el);
if (timer) { clearTimeout(timer); runtimeQueueFallbackTimers.delete(el); }
el.style.removeProperty('transition');
el.style.removeProperty('transform');
el.style.removeProperty('opacity');
};
/**
* Schedule a CSS transition on an element.
* All calls in the same synchronous batch share one RAF callback,
* so all animations start in the same frame.
*/
const scheduleTransition = (
el: HTMLElement,
fromY: number,
toY: number,
props: string[],
from: Record<string, string>,
to: Record<string, string>,
done?: () => void
) => {
if (Math.abs(fromY - toY) < 0.5) {
stopRuntimeQueueAnimation(el);
el.style.removeProperty('transition');
el.style.removeProperty('transform');
done?.();
return;
}
keepRuntimeQueueTransitionCollapsed();
stopRuntimeQueueAnimation(el);
const from = `translateY(${fromY}px)`;
const to = `translateY(${toY}px)`;
const finish = () => {
el.style.removeProperty('transition');
el.style.removeProperty('transform');
props.forEach((p) => el.style.removeProperty(p));
done?.();
};
// Apply from state immediately (no transition)
el.style.transition = 'none';
el.style.transform = from;
if (typeof el.animate === 'function') {
//
void el.offsetHeight;
const animation = el.animate([{ transform: from }, { transform: to }], {
duration: RUNTIME_QUEUE_ANIM_DURATION,
easing: RUNTIME_QUEUE_ANIM_EASING,
fill: 'both'
});
runtimeQueueActiveAnimations.set(el, animation);
animation.addEventListener(
'finish',
() => {
if (runtimeQueueActiveAnimations.get(el) === animation) {
runtimeQueueActiveAnimations.delete(el);
}
finish();
},
{ once: true }
);
animation.addEventListener(
'cancel',
() => {
if (runtimeQueueActiveAnimations.get(el) === animation) {
runtimeQueueActiveAnimations.delete(el);
}
},
{ once: true }
);
return;
}
Object.entries(from).forEach(([k, v]) => { el.style.setProperty(k, v); });
// Defer to next frame: enable transition + apply to state
// All scheduleTransition calls in the same microtask share this RAF
requestAnimationFrame(() => {
el.style.transition = `transform ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`;
el.style.transform = to;
const timer = window.setTimeout(() => {
runtimeQueueFallbackTimers.delete(el);
finish();
}, RUNTIME_QUEUE_ANIM_DURATION + 24);
el.style.transition = props
.map((p) => `${p} ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`)
.join(', ');
Object.entries(to).forEach(([k, v]) => { el.style.setProperty(k, v); });
const timer = window.setTimeout(finish, RUNTIME_QUEUE_ANIM_DURATION + 40);
runtimeQueueFallbackTimers.set(el, timer);
});
};
const resolveRuntimeQueueOffset = (el: HTMLElement): number => {
const rectHeight = Math.round(el.getBoundingClientRect().height);
if (rectHeight > 0) {
return rectHeight;
}
if (el.offsetHeight > 0) {
return el.offsetHeight;
}
return 34;
};
/* ═══════════════════ FLIP 动画 ═══════════════════ */
const snapshotRuntimeQueueIds = (): string[] => {
return runtimeQueuedMessagesForRender.value
.map((entry) => String(entry?.id || '').trim())
.filter((id) => !!id);
};
const spawnRuntimeQueueLeaveGhost = (id: string) => {
const sourceEl = runtimeQueuePrevElements.get(id);
const sourceRect = runtimeQueuePrevRects.get(id);
const listEl = runtimeQueueList.value;
if (!sourceEl || !sourceRect || !listEl || !listEl.isConnected) {
return;
}
const existingGhost = runtimeQueueGhosts.get(id);
if (existingGhost) {
stopRuntimeQueueAnimation(existingGhost);
existingGhost.remove();
runtimeQueueGhosts.delete(id);
}
const ghost = sourceEl.cloneNode(true) as HTMLElement;
const listRect = listEl.getBoundingClientRect();
const top = sourceRect.top - listRect.top;
const width = sourceRect.width > 0 ? sourceRect.width : listRect.width;
ghost.style.position = 'absolute';
ghost.style.left = '0';
ghost.style.top = `${Math.round(top)}px`;
ghost.style.width = `${Math.round(width)}px`;
ghost.style.pointerEvents = 'none';
ghost.style.zIndex = '0';
ghost.style.margin = '0';
ghost.querySelectorAll('button').forEach((button) => {
if (button instanceof HTMLButtonElement) {
button.disabled = true;
}
});
listEl.appendChild(ghost);
runtimeQueueGhosts.set(id, ghost);
const offset = resolveRuntimeQueueOffset(ghost);
playRuntimeQueueTransform(ghost, 0, offset, () => {
runtimeQueueGhosts.delete(id);
ghost.remove();
});
};
const animateRuntimeQueueEnter = (el: HTMLElement) => {
const offset = resolveRuntimeQueueOffset(el);
playRuntimeQueueTransform(el, offset, 0);
};
// IDonBeforeUpdate
let runtimeQueueLastRenderedIds: string[] = [];
onBeforeUpdate(() => {
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length, ...runtimeQueueRenderedIds);
// ID snapshotRuntimeQueueIds()
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length, ...runtimeQueueLastRenderedIds);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
runtimeQueueRenderedIds.forEach((id) => {
runtimeQueueLastRenderedIds.forEach((id) => {
const el = runtimeQueueItemRefs.get(id);
if (!el?.isConnected) {
return;
}
if (!el?.isConnected) return;
runtimeQueuePrevRects.set(id, el.getBoundingClientRect());
runtimeQueuePrevElements.set(id, el);
});
});
onUpdated(() => {
const list = runtimeQueueList.value;
const currentIds = snapshotRuntimeQueueIds();
// First render: just bootstrap tracking state
if (!runtimeQueueAnimationReady) {
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
runtimeQueueAnimationReady = true;
runtimeQueueLastRenderedIds = [...currentIds];
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
return;
}
keepRuntimeQueueTransitionCollapsed();
const previousIdSet = new Set(runtimeQueuePrevIds);
const currentIdSet = new Set(currentIds);
const enteringIdSet = new Set(currentIds.filter((id) => !previousIdSet.has(id)));
const enteringIds = currentIds.filter((id) => !previousIdSet.has(id));
const leavingIds = runtimeQueuePrevIds.filter((id) => !currentIdSet.has(id));
if (!enteringIds.length && !leavingIds.length) return;
// Leave: create ghost clones at old positions
leavingIds.forEach((id) => {
spawnRuntimeQueueLeaveGhost(id);
const src = runtimeQueuePrevElements.get(id);
const rect = runtimeQueuePrevRects.get(id);
if (!src || !rect || !list?.isConnected) return;
cancelAnim(src);
// Clean up any stale ghost with same id
const existing = runtimeQueueGhosts.get(id);
if (existing) { cancelAnim(existing); existing.remove(); runtimeQueueGhosts.delete(id); }
const ghost = src.cloneNode(true) as HTMLElement;
const lr = list.getBoundingClientRect();
ghost.style.position = 'absolute';
ghost.style.left = '0';
ghost.style.top = `${Math.round(rect.top - lr.top)}px`;
ghost.style.width = `${Math.round(rect.width > 0 ? rect.width : lr.width)}px`;
ghost.style.pointerEvents = 'none';
ghost.style.zIndex = '0';
ghost.style.margin = '0';
ghost.style.opacity = '1';
ghost.querySelectorAll('button').forEach((b) => {
if (b instanceof HTMLButtonElement) b.disabled = true;
});
list.appendChild(ghost);
runtimeQueueGhosts.set(id, ghost);
// Schedule ghost sink shares RAF with item animations below
const offset = resolveRuntimeQueueOffset(ghost);
scheduleTransition(ghost, ['transform'],
{ transform: 'translateY(0)' },
{ transform: `translateY(${offset}px)` },
() => { runtimeQueueGhosts.delete(id); ghost.remove(); }
);
runtimeQueueItemRefs.delete(id);
});
// Remaining items: apply FLIP transforms
currentIds.forEach((id) => {
const el = runtimeQueueItemRefs.get(id);
if (!el?.isConnected) {
if (!el?.isConnected) return;
cancelAnim(el);
if (enteringIds.includes(id)) {
// New item: rise from below
const offset = resolveRuntimeQueueOffset(el);
scheduleTransition(el, ['transform'],
{ transform: `translateY(${offset}px)` },
{ transform: 'translateY(0)' }
);
return;
}
const prevRect = runtimeQueuePrevRects.get(id);
if (!prevRect || enteringIdSet.has(id)) {
animateRuntimeQueueEnter(el);
return;
}
const nextRect = el.getBoundingClientRect();
const deltaY = prevRect.top - nextRect.top;
if (Math.abs(deltaY) < 0.5) {
return;
}
playRuntimeQueueTransform(el, deltaY, 0);
// Existing item: invert the position delta
const prev = runtimeQueuePrevRects.get(id);
if (!prev) return;
const next = el.getBoundingClientRect();
const deltaY = prev.top - next.top;
if (Math.abs(deltaY) < 0.5) return;
scheduleTransition(el, ['transform'],
{ transform: `translateY(${deltaY}px)` },
{ transform: 'translateY(0)' }
);
});
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
// ID onBeforeUpdate 使
runtimeQueueLastRenderedIds = [...currentIds];
});
const hasComposerContent = computed(() => {
@ -1839,20 +1786,20 @@ onBeforeUnmount(() => {
editor.value?.destroy();
runtimeQueueItemRefs.forEach((el) => {
if (el) {
stopRuntimeQueueAnimation(el);
cancelAnim(el);
}
});
runtimeQueueGhosts.forEach((ghost) => {
stopRuntimeQueueAnimation(ghost);
cancelAnim(ghost);
ghost.remove();
});
runtimeQueueGhosts.clear();
runtimeQueueItemRefs.clear();
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
runtimeQueueAnimationReady = false;
runtimeQueueLastRenderedIds = [];
if (runtimeQueueTransitionTimer !== null) {
window.clearTimeout(runtimeQueueTransitionTimer);
runtimeQueueTransitionTimer = null;

View File

@ -79,12 +79,14 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'close'): void;
(e: 'confirm', list: string[]): void;
(e: 'local-files', files: FileList | null): void;
(e: 'local-files', files: File[] | null): void;
}>();
const selectedSet = ref<Set<string>>(new Set(props.initialSelected || []));
const localInput = ref<HTMLInputElement | null>(null);
let ignoreBackdropClickUntil = 0;
watch(
() => props.initialSelected,
(val) => {
@ -106,25 +108,35 @@ const toggle = (path: string) => {
selectedSet.value = set;
};
const close = () => emit('close');
const close = () => {
if (Date.now() < ignoreBackdropClickUntil) return;
emit('close');
};
const confirm = () => emit('confirm', Array.from(selectedSet.value));
const triggerLocal = () => {
if (props.uploading) return;
// 500ms backdrop click touch 穿 overlay
ignoreBackdropClickUntil = Date.now() + 500;
localInput.value?.click();
};
const onLocalChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const files = target?.files || null;
const rawFiles = target?.files;
// WebView input value FileList
const files = rawFiles && rawFiles.length ? Array.from(rawFiles) : null;
if (!files || files.length === 0) {
console.warn('[ImagePicker] change fired but files empty — likely WebView compat issue');
emit('local-files', null);
} else {
emit('local-files', files);
}
emit('local-files', files);
if (target) {
target.value = '';
}
// WebView files files
setTimeout(() => {
if (target) target.value = '';
}, 200);
};
const previewUrl = (path: string) => `/api/gui/files/download?path=${encodeURIComponent(path)}`;

View File

@ -764,7 +764,7 @@ const formatTime = (value: string) => {
.diff-line {
padding: 2px 4px;
margin: 1px 0;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}

View File

@ -81,12 +81,14 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'close'): void;
(e: 'confirm', list: string[]): void;
(e: 'local-files', files: FileList | null): void;
(e: 'local-files', files: File[] | null): void;
}>();
const selectedSet = ref<Set<string>>(new Set(props.initialSelected || []));
const localInput = ref<HTMLInputElement | null>(null);
let ignoreBackdropClickUntil = 0;
watch(
() => props.initialSelected,
(val) => {
@ -108,18 +110,25 @@ const toggle = (path: string) => {
selectedSet.value = set;
};
const close = () => emit('close');
const close = () => {
if (Date.now() < ignoreBackdropClickUntil) return;
emit('close');
};
const confirm = () => emit('confirm', Array.from(selectedSet.value));
const triggerLocal = () => {
if (props.uploading) return;
// 500ms backdrop click touch 穿 overlay
ignoreBackdropClickUntil = Date.now() + 500;
localInput.value?.click();
};
const onLocalChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const files = target?.files || null;
const rawFiles = target?.files;
// WebView input value FileList
const files = rawFiles && rawFiles.length ? Array.from(rawFiles) : null;
if (!files || files.length === 0) {
console.warn('[VideoPicker] change fired but files empty — likely WebView compat issue');
}

View File

@ -39,13 +39,9 @@
max-width: 90%;
margin: 0;
overflow: visible;
border-top: 1px solid var(--runtime-queue-border);
border-right: 1px solid var(--runtime-queue-border);
border-bottom: none;
border-left: 1px solid var(--runtime-queue-border);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
pointer-events: auto;
}
@ -70,7 +66,10 @@
align-items: center;
gap: 10px;
padding: 6px 10px 6px 12px;
border: none;
border-left: 1px solid var(--runtime-queue-border);
border-right: 1px solid var(--runtime-queue-border);
border-top: 1px solid var(--runtime-queue-border);
border-bottom: none;
border-radius: 0;
background: var(--runtime-queue-bg);
overflow: hidden;