core-document.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832
  1. import { afterEach, describe, expect, it, vi } from 'vitest';
  2. import { parseHTML } from 'linkedom';
  3. import {
  4. applyFileViewerSearchState,
  5. applyFileViewerZoomState,
  6. buildFileViewerDocumentTextChunks,
  7. clearFileViewerZoomControllerProvider,
  8. collectFileViewerDocumentAnchors,
  9. cloneFileViewerSearchState,
  10. createFileViewerZoomChangeState,
  11. createFileViewerDocumentChangeSnapshot,
  12. createFileViewerDocumentFeatureActions,
  13. createFileViewerDocumentFeatureControllerActionHandlers,
  14. createEmptyFileViewerSearchState,
  15. createFileViewerDomSearchController,
  16. createFileViewerDomSearchControllerActionHandlers,
  17. createFileViewerSearchChangeState,
  18. createFileViewerZoomController,
  19. createFileViewerZoomControllerActionHandlers,
  20. createFileViewerZoomState,
  21. destroyFileViewerDomSearchController,
  22. dispatchFileViewerLocationChange,
  23. dispatchFileViewerSearchChange,
  24. findFileViewerSearchProvider,
  25. findFileViewerZoomProvider,
  26. getCurrentFileViewerDocumentAnchor,
  27. getFileViewerScrollableRange,
  28. isFileViewerScrollableElement,
  29. normalizeFileViewerAiOptions,
  30. normalizeFileViewerSearchOptions,
  31. observeFileViewerDomSearchController,
  32. observeFileViewerZoomController,
  33. registerFileViewerSearchProvider,
  34. registerFileViewerZoomProvider,
  35. refreshFileViewerZoomControllerProvider,
  36. resolveFileViewerLocationChangeAnchor,
  37. resolveFileViewerScrollContainer,
  38. runFileViewerDomSearchControllerAction,
  39. runFileViewerZoomControllerAction,
  40. syncFileViewerDomSearchControllerState,
  41. syncFileViewerZoomControllerState,
  42. unregisterFileViewerSearchProvider,
  43. unregisterFileViewerZoomProvider,
  44. type FileViewerDocumentAnchor,
  45. type FileViewerSearchProvider,
  46. type FileViewerZoomProvider,
  47. } from '../packages/core/src';
  48. const anchor = (id: string, text: string, line = 1): FileViewerDocumentAnchor => ({
  49. id,
  50. index: line - 1,
  51. line,
  52. type: 'line',
  53. label: text.slice(0, 12),
  54. text,
  55. top: line * 10,
  56. left: 0,
  57. width: 100,
  58. height: 20,
  59. });
  60. const setRect = (element: Element, rect: Partial<DOMRect>) => {
  61. Object.defineProperty(element, 'getBoundingClientRect', {
  62. configurable: true,
  63. value: () => ({
  64. bottom: rect.bottom ?? (rect.top || 0) + (rect.height || 0),
  65. height: rect.height ?? 0,
  66. left: rect.left ?? 0,
  67. right: rect.right ?? (rect.left || 0) + (rect.width || 0),
  68. top: rect.top ?? 0,
  69. width: rect.width ?? 0,
  70. x: rect.x ?? rect.left ?? 0,
  71. y: rect.y ?? rect.top ?? 0,
  72. toJSON: () => ({}),
  73. } as DOMRect),
  74. });
  75. };
  76. const setScrollMetrics = (element: HTMLElement, metrics: {
  77. clientHeight: number;
  78. scrollHeight: number;
  79. }) => {
  80. Object.defineProperty(element, 'clientHeight', { configurable: true, value: metrics.clientHeight });
  81. Object.defineProperty(element, 'scrollHeight', { configurable: true, value: metrics.scrollHeight });
  82. };
  83. describe('@file-viewer/core document helpers', () => {
  84. afterEach(() => {
  85. vi.unstubAllGlobals();
  86. });
  87. it('normalizes zoom state for wrapper toolbars', () => {
  88. expect(createFileViewerZoomState()).toEqual({
  89. scale: 1,
  90. label: '100%',
  91. canZoomIn: false,
  92. canZoomOut: false,
  93. canReset: false,
  94. minScale: undefined,
  95. maxScale: undefined,
  96. });
  97. expect(createFileViewerZoomState({ scale: 1.25, canZoomIn: true })).toMatchObject({
  98. scale: 1.25,
  99. label: '125%',
  100. canZoomIn: true,
  101. });
  102. });
  103. it('normalizes search and AI option shorthands', () => {
  104. expect(normalizeFileViewerSearchOptions(false)).toEqual({ enabled: false });
  105. expect(normalizeFileViewerSearchOptions(true)).toEqual({});
  106. expect(createEmptyFileViewerSearchState('pdf')).toEqual({
  107. query: 'pdf',
  108. total: 0,
  109. currentIndex: -1,
  110. current: null,
  111. matches: [],
  112. });
  113. expect(normalizeFileViewerAiOptions(false)).toEqual({ enabled: false, collectText: false });
  114. expect(normalizeFileViewerAiOptions({ chunkSize: 256 })).toEqual({ chunkSize: 256 });
  115. });
  116. it('clones search state snapshots without sharing match or anchor references', () => {
  117. const source = createEmptyFileViewerSearchState('pdf');
  118. source.total = 1;
  119. source.currentIndex = 0;
  120. source.current = {
  121. id: 'match-1',
  122. index: 0,
  123. text: 'PDF',
  124. anchor: anchor('intro', 'PDF intro'),
  125. line: 1,
  126. page: 2,
  127. };
  128. source.matches = [source.current];
  129. const cloned = cloneFileViewerSearchState(source);
  130. const eventState = createFileViewerSearchChangeState(source);
  131. expect(cloned).toEqual(source);
  132. expect(cloned).not.toBe(source);
  133. expect(cloned.current).not.toBe(source.current);
  134. expect(cloned.current?.anchor).not.toBe(source.current.anchor);
  135. expect(cloned.matches[0]).not.toBe(source.matches[0]);
  136. expect(cloned.matches[0].anchor).not.toBe(source.matches[0].anchor);
  137. expect(eventState).toEqual(source);
  138. expect(eventState).not.toBe(source);
  139. expect(eventState.current).not.toBe(source.current);
  140. const target = createEmptyFileViewerSearchState('old');
  141. expect(applyFileViewerSearchState(target, source)).toBe(target);
  142. expect(target).toEqual(source);
  143. expect(target.current).not.toBe(source.current);
  144. expect(target.current?.anchor).not.toBe(source.current.anchor);
  145. expect(target.matches[0]).not.toBe(source.matches[0]);
  146. });
  147. it('dispatches document search and location notifications in wrapper event order', () => {
  148. const events: string[] = [];
  149. const source = createEmptyFileViewerSearchState('PDF');
  150. const currentAnchor = anchor('intro', 'PDF intro', 2);
  151. source.total = 1;
  152. source.currentIndex = 0;
  153. source.current = {
  154. id: 'match-1',
  155. index: 0,
  156. text: 'PDF',
  157. anchor: currentAnchor,
  158. line: 2,
  159. };
  160. source.matches = [source.current];
  161. const locationAnchor = anchor('chapter-1', 'Chapter 1', 4);
  162. let emittedSearchState: typeof source | null = null;
  163. let emittedLocationAnchor: FileViewerDocumentAnchor | null | undefined;
  164. expect(dispatchFileViewerSearchChange({
  165. state: source,
  166. onChange: state => {
  167. events.push('emit:search-change');
  168. emittedSearchState = state;
  169. state.query = 'mutated';
  170. },
  171. })).toBe(true);
  172. expect(dispatchFileViewerLocationChange({
  173. anchor: locationAnchor,
  174. onChange: nextAnchor => {
  175. events.push('emit:location-change');
  176. emittedLocationAnchor = nextAnchor;
  177. },
  178. })).toBe(true);
  179. expect(events).toEqual([
  180. 'emit:search-change',
  181. 'emit:location-change',
  182. ]);
  183. expect(emittedSearchState).not.toBe(source);
  184. expect(emittedSearchState?.current).not.toBe(source.current);
  185. expect(emittedSearchState?.current?.anchor).not.toBe(source.current.anchor);
  186. expect(source.query).toBe('PDF');
  187. expect(emittedLocationAnchor).toBe(locationAnchor);
  188. });
  189. it('creates framework-neutral document feature actions for wrappers', async () => {
  190. const { document } = parseHTML(`
  191. <main id="root" style="overflow-y:auto">
  192. <p data-viewer-anchor-id="a1" data-viewer-line="1">First PDF line</p>
  193. <p data-viewer-anchor-id="a2" data-viewer-line="2">Second PDF line</p>
  194. </main>
  195. `);
  196. const root = document.getElementById('root') as HTMLElement;
  197. const secondLine = root.querySelector('[data-viewer-line="2"]') as HTMLElement;
  198. const scrollIntoView = vi.fn();
  199. const calls: string[] = [];
  200. const emitted: string[] = [];
  201. let anchors: FileViewerDocumentAnchor[] = [];
  202. const searchState = createEmptyFileViewerSearchState();
  203. Object.defineProperty(root, 'scrollTop', { configurable: true, writable: true, value: 65 });
  204. Object.defineProperty(root, 'scrollLeft', { configurable: true, writable: true, value: 0 });
  205. setScrollMetrics(root, { clientHeight: 100, scrollHeight: 360 });
  206. Object.defineProperty(secondLine, 'scrollIntoView', {
  207. configurable: true,
  208. value: scrollIntoView,
  209. });
  210. const actions = createFileViewerDocumentFeatureActions({
  211. root: () => root,
  212. searchController: {
  213. getAnchors: () => anchors,
  214. getSearchState: () => searchState,
  215. observe: () => {
  216. calls.push('observe');
  217. },
  218. refreshAnchors: async () => {
  219. calls.push('refresh');
  220. anchors = [
  221. anchor('a1', 'First PDF line', 1),
  222. { ...anchor('a2', 'Second PDF line', 2), top: 95 },
  223. ];
  224. return anchors;
  225. },
  226. search: async query => {
  227. calls.push(`search:${query}`);
  228. searchState.query = query;
  229. searchState.total = 1;
  230. searchState.currentIndex = 0;
  231. searchState.current = {
  232. id: 'match-a2',
  233. index: 0,
  234. text: query,
  235. anchor: anchors[1],
  236. line: 2,
  237. };
  238. searchState.matches = [searchState.current];
  239. },
  240. clear: async () => {
  241. calls.push('clear');
  242. searchState.query = '';
  243. searchState.total = 0;
  244. searchState.currentIndex = -1;
  245. searchState.current = null;
  246. searchState.matches = [];
  247. },
  248. next: async () => {
  249. calls.push('next');
  250. },
  251. previous: async () => {
  252. calls.push('previous');
  253. },
  254. },
  255. getAiOptions: () => ({ chunkSize: 200 }),
  256. onSearchChange: state => {
  257. emitted.push(`search:${state.query}:${state.total}`);
  258. },
  259. onLocationChange: nextAnchor => {
  260. emitted.push(`location:${nextAnchor?.line ?? 'none'}`);
  261. },
  262. });
  263. await expect(actions.collectDocumentAnchors()).resolves.toHaveLength(2);
  264. expect(actions.getCurrentDocumentAnchor()?.line).toBe(2);
  265. expect(emitted).toEqual(['location:2']);
  266. await expect(actions.collectDocumentAnchors({ notify: false })).resolves.toHaveLength(2);
  267. expect(emitted).toEqual(['location:2']);
  268. await expect(actions.searchDocument('PDF')).resolves.toMatchObject({
  269. query: 'PDF',
  270. total: 1,
  271. currentIndex: 0,
  272. });
  273. expect(emitted).toEqual(['location:2', 'search:PDF:1']);
  274. await expect(actions.nextSearchResult()).resolves.toMatchObject({ query: 'PDF' });
  275. expect(emitted).toEqual(['location:2', 'search:PDF:1', 'location:2', 'search:PDF:1']);
  276. await expect(actions.scrollToLine(2)).resolves.toBe(true);
  277. expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest' });
  278. expect(emitted[emitted.length - 1]).toBe('location:2');
  279. expect(actions.getDocumentTextChunks()).toEqual([
  280. expect.objectContaining({
  281. id: 'a1-chunk-1',
  282. text: 'First PDF line',
  283. startLine: 1,
  284. }),
  285. expect.objectContaining({
  286. id: 'a2-chunk-1',
  287. text: 'Second PDF line',
  288. startLine: 2,
  289. }),
  290. ]);
  291. await expect(actions.clearDocumentState()).resolves.toMatchObject({
  292. total: 0,
  293. currentIndex: -1,
  294. });
  295. expect(emitted[emitted.length - 1]).toBe('location:2');
  296. expect(calls).toEqual(['observe', 'refresh', 'observe', 'refresh', 'search:PDF', 'next', 'clear']);
  297. });
  298. it('creates document feature controller action handlers with DOM search state targets', async () => {
  299. const { document } = parseHTML(`
  300. <main id="root" style="overflow-y:auto">
  301. <p data-viewer-anchor-id="line-1" data-viewer-line="1">Alpha PDF content.</p>
  302. <p data-viewer-anchor-id="line-2" data-viewer-line="2">Beta PDF content.</p>
  303. </main>
  304. `);
  305. const root = document.getElementById('root') as HTMLElement;
  306. const lines = Array.from(root.querySelectorAll<HTMLElement>('[data-viewer-anchor-id]'));
  307. const secondLine = root.querySelector('[data-viewer-line="2"]') as HTMLElement;
  308. const scrollIntoView = vi.fn();
  309. const emitted: string[] = [];
  310. const target = {
  311. anchors: { value: [] as FileViewerDocumentAnchor[] },
  312. state: createEmptyFileViewerSearchState(),
  313. };
  314. Object.defineProperty(root, 'scrollTop', { configurable: true, writable: true, value: 20 });
  315. Object.defineProperty(root, 'scrollLeft', { configurable: true, writable: true, value: 0 });
  316. setScrollMetrics(root, { clientHeight: 80, scrollHeight: 240 });
  317. setRect(root, { top: 0, left: 0, width: 360, height: 80 });
  318. lines.forEach((line, index) => {
  319. setRect(line, { top: 12 + (index * 32), left: 16, width: 280, height: 24 });
  320. });
  321. Object.defineProperty(secondLine, 'scrollIntoView', {
  322. configurable: true,
  323. value: scrollIntoView,
  324. });
  325. const actions = createFileViewerDocumentFeatureControllerActionHandlers({
  326. root: () => root,
  327. searchTarget: target,
  328. searchOptions: () => true,
  329. waitForDomUpdate: () => Promise.resolve(),
  330. getAiOptions: () => ({ chunkSize: 120 }),
  331. onSearchChange: state => {
  332. emitted.push(`search:${state.query}:${state.total}:${state.currentIndex}`);
  333. },
  334. onLocationChange: nextAnchor => {
  335. emitted.push(`location:${nextAnchor?.line ?? 'none'}`);
  336. },
  337. });
  338. await expect(actions.refreshDocumentIndex()).resolves.toHaveLength(2);
  339. expect(target.anchors.value.map(item => item.line)).toEqual([1, 2]);
  340. await expect(actions.searchDocument('PDF')).resolves.toMatchObject({
  341. query: 'PDF',
  342. total: 2,
  343. currentIndex: 0,
  344. });
  345. expect(target.state).toMatchObject({
  346. query: 'PDF',
  347. total: 2,
  348. currentIndex: 0,
  349. });
  350. await expect(actions.nextSearchResult()).resolves.toMatchObject({
  351. query: 'PDF',
  352. total: 2,
  353. currentIndex: 1,
  354. });
  355. await expect(actions.scrollToLine(2)).resolves.toBe(true);
  356. expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest' });
  357. expect(actions.getDocumentTextChunks()).toEqual([
  358. expect.objectContaining({
  359. id: 'line-1-chunk-1',
  360. text: 'Alpha PDF content.',
  361. startLine: 1,
  362. }),
  363. expect.objectContaining({
  364. id: 'line-2-chunk-1',
  365. text: 'Beta PDF content.',
  366. startLine: 2,
  367. }),
  368. ]);
  369. expect(actions.destroyDocumentFeatures()).toBe(target.state);
  370. expect(target.state).toMatchObject({
  371. total: 0,
  372. currentIndex: -1,
  373. current: null,
  374. matches: [],
  375. });
  376. expect(emitted).toEqual([
  377. 'location:1',
  378. 'search:PDF:2:0',
  379. 'location:1',
  380. 'search:PDF:2:1',
  381. 'location:1',
  382. ]);
  383. });
  384. it('builds AI-friendly document text chunks with overlap and limits', () => {
  385. const longText = Array.from({ length: 450 }, (_, index) => String.fromCharCode(97 + (index % 26))).join('');
  386. const chunks = buildFileViewerDocumentTextChunks(
  387. [anchor('intro', longText, 3)],
  388. { chunkSize: 120, chunkOverlap: 50, maxTextLength: 420 }
  389. );
  390. expect(chunks.map(chunk => ({
  391. id: chunk.id,
  392. length: chunk.text.length,
  393. startLine: chunk.startLine,
  394. endLine: chunk.endLine,
  395. }))).toEqual([
  396. { id: 'intro-chunk-1', length: 200, startLine: 3, endLine: 3 },
  397. { id: 'intro-chunk-2', length: 200, startLine: 3, endLine: 3 },
  398. { id: 'intro-chunk-3', length: 120, startLine: 3, endLine: 3 },
  399. ]);
  400. expect(buildFileViewerDocumentTextChunks([anchor('intro', 'text')], false)).toEqual([]);
  401. });
  402. it('exposes renderer provider contracts from core', async () => {
  403. const zoomProvider: FileViewerZoomProvider = {
  404. zoomIn: async () => createFileViewerZoomState({ scale: 1.1 }),
  405. zoomOut: async () => createFileViewerZoomState({ scale: 0.9 }),
  406. resetZoom: async () => createFileViewerZoomState(),
  407. getState: () => createFileViewerZoomState(),
  408. };
  409. const searchProvider: FileViewerSearchProvider = {
  410. search: async query => ({
  411. query,
  412. total: 0,
  413. currentIndex: -1,
  414. current: null,
  415. matches: [],
  416. }),
  417. };
  418. await expect(zoomProvider.zoomIn()).resolves.toMatchObject({ scale: 1.1 });
  419. await expect(searchProvider.search('cad')).resolves.toMatchObject({ query: 'cad' });
  420. });
  421. it('collects DOM anchors and resolves the current document location', () => {
  422. const { document } = parseHTML(`
  423. <main id="root">
  424. <article>
  425. <h1>PDF 沉浸式翻译</h1>
  426. <p>第一段正文,包含可用于搜索和定位的文本。</p>
  427. <div class="state-panel"><p>loading should be ignored</p></div>
  428. </article>
  429. </main>
  430. `);
  431. const root = document.getElementById('root') as HTMLElement;
  432. const heading = root.querySelector('h1') as HTMLElement;
  433. const paragraph = root.querySelector('article > p') as HTMLElement;
  434. const ignored = root.querySelector('.state-panel p') as HTMLElement;
  435. Object.defineProperty(root, 'clientHeight', { configurable: true, value: 120 });
  436. Object.defineProperty(root, 'scrollTop', { configurable: true, writable: true, value: 35 });
  437. Object.defineProperty(root, 'scrollLeft', { configurable: true, writable: true, value: 0 });
  438. setRect(root, { top: 0, left: 0, width: 800, height: 120 });
  439. setRect(heading, { top: 10, left: 12, width: 400, height: 32 });
  440. setRect(paragraph, { top: 70, left: 12, width: 600, height: 24 });
  441. setRect(ignored, { top: 100, left: 12, width: 600, height: 24 });
  442. const anchors = collectFileViewerDocumentAnchors(root);
  443. expect(anchors.map(item => ({
  444. line: item.line,
  445. type: item.type,
  446. label: item.label,
  447. top: item.top,
  448. }))).toEqual([
  449. { line: 1, type: 'block', label: 'PDF 沉浸式翻译', top: 45 },
  450. { line: 2, type: 'line', label: '第一段正文,包含可用于搜索和定位的文本。', top: 105 },
  451. ]);
  452. expect(ignored.dataset.viewerAnchorId).toBeUndefined();
  453. expect(getCurrentFileViewerDocumentAnchor(root, anchors)?.line).toBe(1);
  454. expect(resolveFileViewerLocationChangeAnchor({ root, anchors })?.line).toBe(1);
  455. expect(createFileViewerDocumentChangeSnapshot({
  456. root,
  457. anchors,
  458. searchState: createEmptyFileViewerSearchState('PDF'),
  459. })).toMatchObject({
  460. searchState: { query: 'PDF', total: 0, currentIndex: -1 },
  461. locationAnchor: { line: 1 },
  462. });
  463. });
  464. it('resolves the best document scroll container with framework-neutral DOM rules', () => {
  465. const { document } = parseHTML(`
  466. <main id="root" style="overflow-y:visible">
  467. <section class="pdf-wrapper" style="overflow-y:hidden"></section>
  468. <article id="small" style="overflow-y:auto"></article>
  469. <article id="large" style="overflow-y:auto"></article>
  470. </main>
  471. `);
  472. const root = document.getElementById('root') as HTMLElement;
  473. const preferred = root.querySelector('.pdf-wrapper') as HTMLElement;
  474. const small = document.getElementById('small') as HTMLElement;
  475. const large = document.getElementById('large') as HTMLElement;
  476. vi.stubGlobal('window', {
  477. getComputedStyle: (element: HTMLElement) => ({
  478. overflow: element.style.overflow,
  479. overflowY: element.style.overflowY,
  480. }),
  481. });
  482. setScrollMetrics(root, { clientHeight: 100, scrollHeight: 800 });
  483. setScrollMetrics(preferred, { clientHeight: 100, scrollHeight: 160 });
  484. setScrollMetrics(small, { clientHeight: 100, scrollHeight: 180 });
  485. setScrollMetrics(large, { clientHeight: 100, scrollHeight: 420 });
  486. expect(getFileViewerScrollableRange(large)).toBe(320);
  487. expect(isFileViewerScrollableElement(root)).toBe(false);
  488. expect(resolveFileViewerScrollContainer(root)).toBe(large);
  489. preferred.style.overflowY = 'auto';
  490. expect(resolveFileViewerScrollContainer(root)).toBe(preferred);
  491. });
  492. it('registers framework-neutral search and zoom providers on DOM hosts', async () => {
  493. const { document } = parseHTML('<div id="root"><section id="search" data-viewer-search-provider="pdf"></section><section id="zoom"></section></div>');
  494. const root = document.getElementById('root') as HTMLElement;
  495. const searchHost = document.getElementById('search') as HTMLElement;
  496. const zoomHost = document.getElementById('zoom') as HTMLElement;
  497. const searchProvider: FileViewerSearchProvider = {
  498. search: query => createEmptyFileViewerSearchState(query),
  499. };
  500. const zoomProvider: FileViewerZoomProvider = {
  501. zoomIn: () => createFileViewerZoomState({ scale: 1.1 }),
  502. zoomOut: () => createFileViewerZoomState({ scale: 0.9 }),
  503. resetZoom: () => createFileViewerZoomState(),
  504. getState: () => createFileViewerZoomState(),
  505. };
  506. registerFileViewerSearchProvider(searchHost, searchProvider);
  507. registerFileViewerZoomProvider(zoomHost, zoomProvider);
  508. expect(findFileViewerSearchProvider(root)).toBe(searchProvider);
  509. expect(findFileViewerZoomProvider(zoomHost)).toBe(zoomProvider);
  510. expect(zoomHost.dataset.viewerZoomProvider).toBe('custom');
  511. await expect(Promise.resolve(findFileViewerSearchProvider(root)?.search('pdf'))).resolves.toMatchObject({ query: 'pdf' });
  512. unregisterFileViewerSearchProvider(searchHost);
  513. unregisterFileViewerZoomProvider(zoomHost);
  514. expect(findFileViewerSearchProvider(root)).toBeNull();
  515. expect(findFileViewerZoomProvider(zoomHost)).toBeNull();
  516. expect(zoomHost.dataset.viewerZoomProvider).toBeUndefined();
  517. });
  518. it('runs the DOM search controller without framework state', async () => {
  519. const { document } = parseHTML(`
  520. <main id="root">
  521. <article>
  522. <p>Alpha PDF beta pdf.</p>
  523. <button>pdf ignored</button>
  524. <canvas>pdf ignored</canvas>
  525. </article>
  526. </main>
  527. `);
  528. const root = document.getElementById('root') as HTMLElement;
  529. const paragraph = root.querySelector('p') as HTMLElement;
  530. Object.defineProperty(root, 'clientHeight', { configurable: true, value: 80 });
  531. Object.defineProperty(root, 'scrollHeight', { configurable: true, value: 400 });
  532. Object.defineProperty(root, 'clientWidth', { configurable: true, value: 200 });
  533. Object.defineProperty(root, 'scrollWidth', { configurable: true, value: 200 });
  534. Object.defineProperty(root, 'scrollTop', { configurable: true, writable: true, value: 0 });
  535. Object.defineProperty(root, 'scrollLeft', { configurable: true, writable: true, value: 18 });
  536. root.scrollTo = (options?: ScrollToOptions | number, y?: number) => {
  537. if (typeof options === 'number') {
  538. root.scrollLeft = options;
  539. root.scrollTop = y || 0;
  540. return;
  541. }
  542. root.scrollTop = Number(options?.top || 0);
  543. root.scrollLeft = Number(options?.left || 0);
  544. };
  545. setRect(root, { top: 0, left: 0, width: 200, height: 80 });
  546. setRect(paragraph, { top: 40, left: 10, width: 180, height: 20 });
  547. const controller = createFileViewerDomSearchController({
  548. root: () => root,
  549. waitForDomUpdate: () => Promise.resolve(),
  550. preferredScrollContainer: () => root,
  551. });
  552. const target = {
  553. anchors: { value: [] as FileViewerDocumentAnchor[] },
  554. state: createEmptyFileViewerSearchState(),
  555. };
  556. await runFileViewerDomSearchControllerAction(target, controller, () => controller.search('pdf'));
  557. expect(controller.state).toMatchObject({
  558. query: 'pdf',
  559. total: 2,
  560. currentIndex: 0,
  561. });
  562. expect(target.state).toMatchObject({ query: 'pdf', total: 2, currentIndex: 0 });
  563. expect(target.anchors.value).toBe(controller.anchors);
  564. expect(Array.from(root.querySelectorAll('mark')).map(mark => mark.textContent)).toEqual(['PDF', 'pdf']);
  565. expect(root.querySelectorAll('.flyfish-search-match--active')).toHaveLength(1);
  566. expect(root.scrollLeft).toBe(18);
  567. await runFileViewerDomSearchControllerAction(target, controller, () => controller.next());
  568. expect(controller.state.currentIndex).toBe(1);
  569. expect(target.state.currentIndex).toBe(1);
  570. expect(observeFileViewerDomSearchController(target, controller)).toBe(target.state);
  571. expect(syncFileViewerDomSearchControllerState(target, controller)).toBe(target.state);
  572. await runFileViewerDomSearchControllerAction(target, controller, () => controller.clear());
  573. expect(controller.state).toMatchObject({
  574. query: '',
  575. total: 0,
  576. currentIndex: -1,
  577. current: null,
  578. });
  579. expect(target.state).toMatchObject({ query: '', total: 0, currentIndex: -1 });
  580. expect(root.querySelectorAll('mark')).toHaveLength(0);
  581. expect(paragraph.textContent).toBe('Alpha PDF beta pdf.');
  582. destroyFileViewerDomSearchController(target, controller);
  583. });
  584. it('creates DOM search action facades for wrapper state targets', async () => {
  585. const { document } = parseHTML('<main id="root"><p>Alpha PDF beta pdf.</p></main>');
  586. const root = document.getElementById('root') as HTMLElement;
  587. const controller = createFileViewerDomSearchController({
  588. root: () => root,
  589. waitForDomUpdate: () => Promise.resolve(),
  590. preferredScrollContainer: () => root,
  591. });
  592. const target = {
  593. anchors: { value: [] as FileViewerDocumentAnchor[] },
  594. state: createEmptyFileViewerSearchState(),
  595. };
  596. const actions = createFileViewerDomSearchControllerActionHandlers(target, controller);
  597. expect(actions.observe()).toBe(target.state);
  598. const searchState = await actions.search('pdf');
  599. expect(searchState).toBe(target.state);
  600. expect(target.state).toMatchObject({ query: 'pdf', total: 2, currentIndex: 0 });
  601. expect(target.anchors.value).toBe(controller.anchors);
  602. const anchors = await actions.refreshAnchors();
  603. expect(anchors).toBe(target.anchors.value);
  604. await actions.next();
  605. expect(target.state.currentIndex).toBe(1);
  606. await actions.previous();
  607. expect(target.state.currentIndex).toBe(0);
  608. await actions.clear();
  609. expect(target.state).toMatchObject({ query: '', total: 0, currentIndex: -1 });
  610. expect(actions.destroy()).toBe(target.state);
  611. });
  612. it('runs the zoom controller without framework state', async () => {
  613. const { document } = parseHTML('<main id="root"><section id="zoom" data-viewer-zoom-provider="docx"></section></main>');
  614. const root = document.getElementById('root') as HTMLElement;
  615. const zoomHost = document.getElementById('zoom') as HTMLElement;
  616. const listeners = new Set<() => void>();
  617. const beforeOperations: string[] = [];
  618. const stateTarget = createFileViewerZoomState();
  619. expect(applyFileViewerZoomState(stateTarget, { scale: 2, canZoomOut: true })).toBe(stateTarget);
  620. expect(stateTarget).toMatchObject({
  621. scale: 2,
  622. label: '200%',
  623. canZoomOut: true,
  624. });
  625. applyFileViewerZoomState(stateTarget, null);
  626. expect(stateTarget).toEqual(createFileViewerZoomState());
  627. let scale = 1;
  628. let allowZoomOut = false;
  629. const getState = () => createFileViewerZoomState({
  630. scale,
  631. canZoomIn: scale < 2,
  632. canZoomOut: scale > 0.5,
  633. canReset: scale !== 1,
  634. minScale: 0.5,
  635. maxScale: 2,
  636. });
  637. const emit = () => {
  638. listeners.forEach(listener => listener());
  639. };
  640. const zoomProvider: FileViewerZoomProvider = {
  641. zoomIn: () => {
  642. scale = 1.25;
  643. emit();
  644. return getState();
  645. },
  646. zoomOut: () => {
  647. scale = 0.75;
  648. emit();
  649. return getState();
  650. },
  651. resetZoom: () => {
  652. scale = 1;
  653. emit();
  654. return getState();
  655. },
  656. getState,
  657. subscribe(listener) {
  658. listeners.add(listener);
  659. return () => {
  660. listeners.delete(listener);
  661. };
  662. },
  663. };
  664. registerFileViewerZoomProvider(zoomHost, zoomProvider);
  665. const controller = createFileViewerZoomController({
  666. root: () => root,
  667. beforeZoom: operation => {
  668. beforeOperations.push(operation);
  669. return operation !== 'zoom-out' || allowZoomOut;
  670. },
  671. });
  672. expect(controller.hasProvider()).toBe(true);
  673. expect(syncFileViewerZoomControllerState(stateTarget, controller)).toBe(stateTarget);
  674. expect(controller.state).toMatchObject({
  675. scale: 1,
  676. label: '100%',
  677. canZoomIn: true,
  678. });
  679. expect(createFileViewerZoomChangeState(stateTarget)).toEqual(controller.getState());
  680. await expect(runFileViewerZoomControllerAction(stateTarget, () => controller.zoomIn()))
  681. .resolves.toMatchObject({ scale: 1.25, label: '125%' });
  682. expect(beforeOperations).toEqual(['zoom-in']);
  683. expect(controller.state.canReset).toBe(true);
  684. expect(stateTarget).toMatchObject({ scale: 1.25, label: '125%' });
  685. scale = 1.5;
  686. emit();
  687. expect(controller.state).toMatchObject({ scale: 1.5, label: '150%' });
  688. expect(refreshFileViewerZoomControllerProvider(stateTarget, controller)).toBe(zoomProvider);
  689. expect(stateTarget).toMatchObject({ scale: 1.5, label: '150%' });
  690. expect(observeFileViewerZoomController(stateTarget, controller)).toBe(stateTarget);
  691. await expect(controller.zoomOut()).resolves.toMatchObject({ scale: 1.5 });
  692. expect(beforeOperations).toEqual(['zoom-in', 'zoom-out']);
  693. allowZoomOut = true;
  694. await expect(controller.zoomOut()).resolves.toMatchObject({ scale: 0.75, label: '75%' });
  695. clearFileViewerZoomControllerProvider(stateTarget, controller);
  696. expect(controller.provider).toBeNull();
  697. expect(controller.getState()).toEqual(createFileViewerZoomState());
  698. expect(stateTarget).toEqual(createFileViewerZoomState());
  699. controller.destroy();
  700. unregisterFileViewerZoomProvider(zoomHost);
  701. });
  702. it('creates zoom action facades for wrapper state targets', async () => {
  703. const { document } = parseHTML('<main id="root"><section id="zoom" data-viewer-zoom-provider="docx"></section></main>');
  704. const root = document.getElementById('root') as HTMLElement;
  705. const zoomHost = document.getElementById('zoom') as HTMLElement;
  706. let scale = 1;
  707. const getState = () => createFileViewerZoomState({
  708. scale,
  709. label: `${Math.round(scale * 100)}%`,
  710. canZoomIn: scale < 2,
  711. canZoomOut: scale > 0.5,
  712. canReset: scale !== 1,
  713. });
  714. const zoomProvider: FileViewerZoomProvider = {
  715. zoomIn: () => {
  716. scale = 1.25;
  717. return getState();
  718. },
  719. zoomOut: () => {
  720. scale = 0.75;
  721. return getState();
  722. },
  723. resetZoom: () => {
  724. scale = 1;
  725. return getState();
  726. },
  727. getState,
  728. };
  729. const stateTarget = createFileViewerZoomState();
  730. const controller = createFileViewerZoomController({ root: () => root });
  731. const actions = createFileViewerZoomControllerActionHandlers(stateTarget, controller);
  732. try {
  733. registerFileViewerZoomProvider(zoomHost, zoomProvider);
  734. expect(actions.hasZoomProvider()).toBe(true);
  735. expect(stateTarget).toMatchObject({ scale: 1, label: '100%' });
  736. expect(actions.getZoomState()).toEqual(stateTarget);
  737. await expect(actions.zoomIn()).resolves.toMatchObject({ scale: 1.25, label: '125%' });
  738. expect(stateTarget).toMatchObject({ scale: 1.25, label: '125%' });
  739. await expect(actions.zoomOut()).resolves.toMatchObject({ scale: 0.75, label: '75%' });
  740. expect(stateTarget).toMatchObject({ scale: 0.75, label: '75%' });
  741. await expect(actions.resetZoom()).resolves.toMatchObject({ scale: 1, label: '100%' });
  742. expect(actions.refreshZoomProvider()).toBe(zoomProvider);
  743. expect(actions.startZoomObserver()).toBe(stateTarget);
  744. expect(actions.clearZoomProvider()).toBe(stateTarget);
  745. expect(controller.provider).toBeNull();
  746. expect(actions.stopZoomObserver()).toBe(stateTarget);
  747. } finally {
  748. controller.destroy();
  749. unregisterFileViewerZoomProvider(zoomHost);
  750. }
  751. });
  752. });