Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 91 additions & 7 deletions components/preview/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,41 @@
cursor: pointer;
}

/* 键盘用户可见的焦点环(无障碍) */
r-preview:focus-visible {
outline: 2px solid #1e90ff;
outline-offset: 2px;
}

.r-preview-mask {
position: fixed;
top: 0;
left: 0;
inset: 0;
width: 100vw;
width: 100dvw;
height: 100vh;
/* 移动端浏览器地址栏收缩时用动态视口高度,避免底部被裁剪 */
height: 100dvh;
background-color: rgba(0, 0, 0, 0.45);
z-index: 1081;
/* 适配刘海屏 / 手势区安全边距 */
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
box-sizing: border-box;

.r-preview-loading {
position: absolute;
top: 30vh;
left: 50vw;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-flow: column;
align-items: center;
&-text {
color: #fff;
font-size: 16px;
gap: 16px;
r-progress {
/* 小屏不溢出 */
width: min(240px, 80vw);
}
}

.r-preview-options {
width: 100%;
pointer-events: auto;
Expand All @@ -40,12 +54,30 @@

&-close {
margin: 14px;
/* 原生 button 样式重置 */
border: none;
background: transparent;
padding: 0;
color: inherit;
font: inherit;
cursor: pointer;
transition: all 0.3s;
transition: transform 0.3s;
user-select: none;
/* 保证足够的可点击/可触摸区域 */
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border-radius: 50%;

&:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
}
}

.r-preview-contain {
max-width: calc(100vw - 20px);
margin: 0 auto;
Expand All @@ -56,21 +88,73 @@
.r-preview-context {
margin: 0 auto;
height: calc(100vh - 68px);
height: calc(100dvh - 68px);
display: flex;
flex-flow: column nowrap;
justify-content: flex-start;
align-items: center;
overflow: auto;
/* 移动端惯性滚动 + 防止滚动穿透到 body */
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.r-preview-context-pdf {
width: 100%;
margin: 34px auto;
height: 100vh;
height: 100dvh;
display: flex;
flex-flow: column nowrap;
justify-content: flex-start;
align-items: center;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
}
}

/* 平板 / iPad:收窄留白,内容尽量占满 */
@media (max-width: 1024px) {
.r-preview-mask .r-preview-contain {
max-width: 100vw;
.r-preview-context-pdf {
margin: 20px auto;
}
}
}

/* 手机:全屏铺满、更小的边距 */
@media (max-width: 640px) {
.r-preview-mask {
.r-preview-options-close {
margin: 8px;
}
.r-preview-contain {
max-width: 100vw;
.r-preview-context {
height: calc(100vh - 60px);
height: calc(100dvh - 60px);
width: 100%;
}
.r-preview-context-pdf {
margin: 12px auto;
}
}
}
}

/* 触摸设备:放大关闭按钮的可点区域(≥48px) */
@media (hover: none) and (pointer: coarse) {
.r-preview-mask .r-preview-options-close {
min-width: 48px;
min-height: 48px;
}
}

/* 尊重「减少动态效果」偏好 */
@media (prefers-reduced-motion: reduce) {
.r-preview-mask .r-preview-options-close {
transition: none;
}
}
150 changes: 90 additions & 60 deletions components/preview/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Client, MessageCodec, create, noop } from 'ranuts/utils';
import { Client, MessageCodec, noop } from 'ranuts/utils';
import { DOCX, PDF, PPTX, XLS, XLSX } from '@/components/preview/constant';
import type { BaseReturn, RenderOptions } from '@/components/preview/types';
import less from './index.less?inline';
import { ButtonBuilder, Div, Style, View } from 'ranui/builder';
import 'ranui/message';
import 'ranui/icon';
import 'ranui/loading';
import 'ranui/progress';

const message = window.message;

Expand Down Expand Up @@ -119,7 +122,7 @@ async function Custom() {
return ['src', 'closeable'];
}
previewContextPdf?: HTMLDivElement;
_loadingText?: HTMLDivElement;
_loadingProgress?: HTMLElement;
previewIframe?: HTMLIFrameElement;
preview?: HTMLElement | null;
previewContext?: HTMLDivElement;
Expand All @@ -128,6 +131,8 @@ async function Custom() {
_loadingElement?: HTMLDivElement;
_iframe?: HTMLIFrameElement;
_editorWindow?: Window;
_closeButton?: HTMLElement;
_lastFocused?: HTMLElement | null;
constructor() {
super();
this._div = document.createElement('div');
Expand Down Expand Up @@ -164,32 +169,28 @@ async function Custom() {
if (value) this.setAttribute('baseUrl', value);
}
createLoading = () => {
this._loadingElement = document.createElement('div');
this._loadingElement.setAttribute('class', 'r-preview-loading');
const icon = document.createElement('r-icon');
icon.setAttribute('name', 'loading');
icon.setAttribute('size', '100');
icon.setAttribute('color', '#1E90FF');
icon.setAttribute('spin', '');
this._loadingText = document.createElement('div');
this._loadingElement.appendChild(icon);
this._loadingText.setAttribute('class', 'r-preview-loading-text');
this._loadingElement.appendChild(this._loadingText);
this._loadingProgress = View('r-progress')
.attr('percent', '0')
.attr('type', 'line')
.role('progressbar')
.aria('valuemin', '0')
.aria('valuemax', '100')
.aria('valuenow', '0')
.build();
this._loadingElement = Div()
.class('r-preview-loading')
.role('status')
.aria('live', 'polite')
.label('Loading file preview')
.children(View('r-loading').attr('name', 'circle').ariaHidden(true), this._loadingProgress)
.build();
return this._loadingElement;
};
onProgress = (event: ProgressEvent<EventTarget>) => {
const num = (event.loaded / event.total) * 100;
const progress = Math.min(99, num).toFixed(2) + '%';
if (this._loadingText && this._loadingElement) {
this._loadingText.innerText = `Loading ${progress}`;
if (num >= 100) {
setTimeout(() => {
if (this._loadingText) {
this._loadingText.innerText = `Loading...`;
}
}, 300);
}
}
if (!event.total) return;
const num = Math.min(99, (event.loaded / event.total) * 100);
this._loadingProgress?.setAttribute('percent', num.toFixed(2));
this._loadingProgress?.setAttribute('aria-valuenow', String(Math.round(num)));
};
onError = () => {
this.preview?.removeChild(this._loadingElement!);
Expand Down Expand Up @@ -242,8 +243,28 @@ async function Custom() {
if (this.previewContextPdf && this.previewContext) {
this.previewContext.removeChild(this.previewContextPdf);
}
document.removeEventListener('keydown', this.handleKeydown);
// 关闭后把焦点交还给触发元素,符合无障碍规范
this._lastFocused?.focus?.();
}
};
// Esc 关闭预览(模态框无障碍要求)
handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') this.closePreview();
};
// 键盘(Enter/Space)触发预览
handleTriggerKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.showPreview();
}
};
// 打开预览时:记录当前焦点、监听 Esc、把焦点移入对话框
openA11y = () => {
this._lastFocused = document.activeElement as HTMLElement | null;
document.addEventListener('keydown', this.handleKeydown);
requestAnimationFrame(() => this._closeButton?.focus());
};
showPreview = () => {
if (this.src) {
if (this.preview) {
Expand All @@ -252,59 +273,68 @@ async function Custom() {
if (this.previewContextPdf && this.previewContext?.contains(this.previewContextPdf)) {
this.previewContext.removeChild(this.previewContextPdf);
}
this.previewContextPdf = document.createElement('div');
this.previewContextPdf.setAttribute('class', 'r-preview-context-pdf');
this.previewContextPdf = Div().class('r-preview-context-pdf').build();
this.previewContext?.appendChild(this.previewContextPdf);
this._loadingElement?.style.setProperty('display', 'block');
} else {
this.preliminary();
}
this.openA11y();
this.handleFile(this.src);
}
};
preliminary = () => {
this.preview = document.createElement('div');
this.preview.setAttribute('class', 'r-preview-mask');
this.preview.setAttribute('id', 'r-preview-mask');
const previewOption = document.createElement('div');
previewOption.setAttribute('class', 'r-preview-options');
const previewOption = Div().class('r-preview-options');
if (this.closeable !== 'false') {
const previewCloseButton = document.createElement('r-icon');
previewCloseButton.setAttribute('class', 'r-preview-options-close');
previewCloseButton.setAttribute('name', 'close-circle-fill');
previewCloseButton.setAttribute('size', '40');
previewCloseButton.addEventListener('click', this.closePreview);
previewOption.appendChild(previewCloseButton);
// 用原生 button 包裹图标:天然可聚焦、Enter/Space 可激活、语义为按钮
this._closeButton = ButtonBuilder()
.class('r-preview-options-close')
.attr('type', 'button')
.label('Close preview')
.on('click', this.closePreview)
.children(View('r-icon').attr('name', 'close-circle-fill').attr('size', '40').ariaHidden(true))
.build();
previewOption.children(this._closeButton);
}
const previewContain = document.createElement('div');
previewContain.setAttribute('class', 'r-preview-contain');
this.previewContext = document.createElement('div');
this.previewContext.setAttribute('class', 'r-preview-context');
this.previewContextPdf = document.createElement('div');
this.previewContextPdf.setAttribute('class', 'r-preview-context-pdf');
this.previewContext.appendChild(this.previewContextPdf);
this.previewIframe = document.createElement('iframe');
this.previewIframe.setAttribute('src', this.baseUrl);
this.previewIframe.setAttribute('style', 'display: none;');
this.previewIframe.style.setProperty('margin-bottom', '30px');
// 添加权限策略设置,避免权限策略违规警告
this.previewIframe.setAttribute('allow', 'autoplay; camera; fullscreen; microphone; clipboard-write; display-capture; geolocation');
this.previewIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups allow-modals');
this.previewContext.appendChild(this.previewIframe);
previewContain.appendChild(this.previewContext);
this.preview.appendChild(previewOption);
this.previewContextPdf = Div().class('r-preview-context-pdf').build();
this.previewIframe = View<HTMLIFrameElement>('iframe')
.attr('src', this.baseUrl)
.attr('title', 'Document preview')
.style({ display: 'none', 'margin-bottom': '30px' })
// 添加权限策略设置,避免权限策略违规警告
.attr('allow', 'autoplay; camera; fullscreen; microphone; clipboard-write; display-capture; geolocation')
.attr('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups allow-modals')
.build();
this.previewContext = Div()
.class('r-preview-context')
.children(this.previewContextPdf, this.previewIframe)
.build();
this._loadingElement = this.createLoading();
this.preview.appendChild(this._loadingElement);
this.preview?.appendChild(previewContain);
this.preview.style.setProperty('display', 'none');
this.preview = Div()
.class('r-preview-mask')
.id('r-preview-mask')
.role('dialog')
.attr('aria-modal', 'true')
.label('File preview')
.style('display', 'none')
.children(previewOption, this._loadingElement, Div().class('r-preview-contain').children(this.previewContext))
.build();
document.body.appendChild(this.preview);
};
connectedCallback() {
this.preliminary();
// 让宿主元素可聚焦并支持键盘触发,符合无障碍规范
if (!this.hasAttribute('role')) this.setAttribute('role', 'button');
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', this.label || 'Preview file');
this.setAttribute('aria-haspopup', 'dialog');
this.addEventListener('click', this.showPreview);
this.addEventListener('keydown', this.handleTriggerKeydown);
}
disconnectedCallback() {
this.removeEventListener('click', this.showPreview);
this.removeEventListener('keydown', this.handleTriggerKeydown);
document.removeEventListener('keydown', this.handleKeydown);
this.preview && document.body.removeChild(this.preview);
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
Expand All @@ -318,8 +348,8 @@ async function Custom() {
}
if (typeof document !== 'undefined' && !customElements.get('r-preview')) {
customElements.define('r-preview', CustomElement);
const style = create('style').setTextContent(less);
document.body.appendChild(style.element);
const style = Style().text(less).build();
document.body.appendChild(style);
}
}
}
Expand Down
Loading