|
@@ -20,6 +20,7 @@ import { MatrixClientService } from '../../../core/matrix-client.service';
|
|
|
import { FmodeApi } from '../../../core/api/fmode-api';
|
|
import { FmodeApi } from '../../../core/api/fmode-api';
|
|
|
import { isCloudUrl } from '../../../core/s3-link';
|
|
import { isCloudUrl } from '../../../core/s3-link';
|
|
|
import { downloadAs, needsBlobPreview, toBlobUrl } from '../../../core/attachment-fetch';
|
|
import { downloadAs, needsBlobPreview, toBlobUrl } from '../../../core/attachment-fetch';
|
|
|
|
|
+import { copyLink, openInNewTab } from '../../../core/open-link';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 预览呈现形态。
|
|
* 预览呈现形态。
|
|
@@ -132,6 +133,25 @@ export type PreviewPresentation = 'rail' | 'full' | 'modal';
|
|
|
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
|
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
|
|
</button>
|
|
</button>
|
|
|
</ui-tooltip>
|
|
</ui-tooltip>
|
|
|
|
|
+ <!--
|
|
|
|
|
+ 复制在线链接(2026-11-13):
|
|
|
|
|
+ 部分手机浏览器/容器内置浏览器会拦截或处理不好 window.open,
|
|
|
|
|
+ 给一个"复制链接"的兜底出口 —— 用户粘到系统浏览器里就能打开。
|
|
|
|
|
+ -->
|
|
|
|
|
+ <ui-tooltip [content]="copied() ? '已复制' : '复制链接'">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="fp-icon"
|
|
|
|
|
+ [attr.aria-label]="copied() ? '已复制链接' : '复制链接'"
|
|
|
|
|
+ (click)="copyUrl()"
|
|
|
|
|
+ >
|
|
|
|
|
+ <i
|
|
|
|
|
+ class="fa-solid"
|
|
|
|
|
+ [class]="copied() ? 'fa-check' : 'fa-link'"
|
|
|
|
|
+ aria-hidden="true"
|
|
|
|
|
+ ></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </ui-tooltip>
|
|
|
<ui-tooltip [content]="presentation() === 'full' ? '回到右侧' : '关闭预览'">
|
|
<ui-tooltip [content]="presentation() === 'full' ? '回到右侧' : '关闭预览'">
|
|
|
<button
|
|
<button
|
|
|
#closeBtn
|
|
#closeBtn
|
|
@@ -1045,6 +1065,12 @@ export class FilePreviewComponent {
|
|
|
readonly frameLoading = signal(false);
|
|
readonly frameLoading = signal(false);
|
|
|
/** 「在新标签打开」被浏览器拦截时的提示(3 秒自动消失) */
|
|
/** 「在新标签打开」被浏览器拦截时的提示(3 秒自动消失) */
|
|
|
readonly blockedHint = signal(false);
|
|
readonly blockedHint = signal(false);
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 「复制链接」刚点过(2026-11-13)。
|
|
|
|
|
+ * 用于把图标临时变成对勾,给用户明确反馈 —— 2 秒后复原。
|
|
|
|
|
+ */
|
|
|
|
|
+ readonly copied = signal(false);
|
|
|
|
|
+ private copiedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
private frameTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private frameTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
private frameRetry = 0;
|
|
private frameRetry = 0;
|
|
|
private readonly blobUrl = signal('');
|
|
private readonly blobUrl = signal('');
|
|
@@ -1248,6 +1274,29 @@ export class FilePreviewComponent {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 复制文件的在线链接到剪贴板。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 为什么要有这个按钮(2026-11-13 真机实测)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 部分手机浏览器 / 容器内置浏览器(企微、钉钉等)对 `window.open` 处理不一致,
|
|
|
|
|
+ * 有的直接拦截、有的开出来是空白页。给用户一个稳定的兜底:
|
|
|
|
|
+ * **复制链接 → 粘到系统浏览器打开**,效果等同,且不依赖浏览器的弹窗策略。
|
|
|
|
|
+ */
|
|
|
|
|
+ async copyUrl(): Promise<void> {
|
|
|
|
|
+ const url = this.safeUrl();
|
|
|
|
|
+ if (!url) return;
|
|
|
|
|
+ const ok = await copyLink(url);
|
|
|
|
|
+ if (!ok) {
|
|
|
|
|
+ /* 复制失败(非 https / 权限拒绝):至少让用户能看到并手动选 */
|
|
|
|
|
+ this.textError.set('复制失败,请长按下方链接手动复制');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.copied.set(true);
|
|
|
|
|
+ if (this.copiedTimer) clearTimeout(this.copiedTimer);
|
|
|
|
|
+ this.copiedTimer = setTimeout(() => this.copied.set(false), 2000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 在新标签页打开。
|
|
* 在新标签页打开。
|
|
|
*
|
|
*
|
|
@@ -1281,15 +1330,7 @@ export class FilePreviewComponent {
|
|
|
* 再手动把 `opener` 置空 —— 效果等同 noopener,且不会被误判。
|
|
* 再手动把 `opener` 置空 —— 效果等同 noopener,且不会被误判。
|
|
|
*/
|
|
*/
|
|
|
if (!isMatrixMediaUrl(base)) {
|
|
if (!isMatrixMediaUrl(base)) {
|
|
|
- const direct = window.open(base, '_blank');
|
|
|
|
|
- if (direct) {
|
|
|
|
|
- try {
|
|
|
|
|
- direct.opener = null;
|
|
|
|
|
- } catch {
|
|
|
|
|
- /* 跨域时不可写,仍有 href 层面的保护 */
|
|
|
|
|
- }
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (openInNewTab(base)) return;
|
|
|
/*
|
|
/*
|
|
|
* 真的被拦了(比如用户开了弹窗阻止):退回本地下载,
|
|
* 真的被拦了(比如用户开了弹窗阻止):退回本地下载,
|
|
|
* 不要留一个 about:blank 空页。
|
|
* 不要留一个 about:blank 空页。
|
|
@@ -1298,7 +1339,7 @@ export class FilePreviewComponent {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /* ② Matrix 附件:必须走 blob 中转(见下方原有注释) */
|
|
|
|
|
|
|
+ /* ② Matrix 附件:必须走 blob 中转(服务端强制 attachment 头) */
|
|
|
const authed = this.mx.withAccessToken(base);
|
|
const authed = this.mx.withAccessToken(base);
|
|
|
const w = window.open('about:blank', '_blank');
|
|
const w = window.open('about:blank', '_blank');
|
|
|
try {
|
|
try {
|