psd.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. import {
  2. createFileViewerTranslator,
  3. createFileViewerZoomChangeEmitter,
  4. registerFileViewerZoomProvider,
  5. unregisterFileViewerZoomProvider,
  6. type FileRenderContext,
  7. type FileViewerRenderedInstance,
  8. type FileViewerZoomState
  9. } from '@file-viewer/core'
  10. interface PsdLayerItem {
  11. id: string
  12. name: string
  13. depth: number
  14. left: number
  15. top: number
  16. width: number
  17. height: number
  18. hidden: boolean
  19. canvas?: HTMLCanvasElement
  20. }
  21. const psdStyle = `
  22. .psd-viewer{display:grid;height:100%;min-height:460px;grid-template-rows:auto minmax(0,1fr);--psd-bg:#eef1f4;--psd-surface:#fff;--psd-border:rgba(15,23,42,.1);--psd-text:#132235;--psd-muted:#64748b;--psd-accent:#0f766e;background:var(--psd-bg);color:var(--psd-text);box-sizing:border-box}
  23. .psd-toolbar{position:sticky;top:0;z-index:2;display:flex;min-height:52px;align-items:center;justify-content:space-between;gap:14px;padding:10px 16px;border-bottom:1px solid var(--psd-border);background:rgba(255,255,255,.92);backdrop-filter:blur(12px);box-sizing:border-box}
  24. .psd-title{display:flex;min-width:0;flex-direction:column;gap:2px}
  25. .psd-title strong{overflow:hidden;font-size:14px;text-overflow:ellipsis;white-space:nowrap}
  26. .psd-title span{color:var(--psd-muted);font-size:12px;font-weight:700}
  27. .psd-actions{display:flex;flex-shrink:0;align-items:center;gap:6px}
  28. .psd-actions button{height:30px;min-width:34px;border:1px solid rgba(100,116,139,.28);border-radius:6px;background:#fff;color:#132235;cursor:pointer;font-size:12px;font-weight:800}
  29. .psd-actions button:hover{border-color:rgba(15,118,110,.45);color:var(--psd-accent)}
  30. .psd-actions span{min-width:48px;color:var(--psd-muted);font-size:12px;font-weight:800;text-align:center}
  31. .psd-layout{display:grid;min-height:0;grid-template-columns:280px minmax(0,1fr)}
  32. .psd-sidebar{min-height:0;overflow:auto;border-right:1px solid var(--psd-border);background:rgba(248,250,252,.86)}
  33. .psd-sidebar-header{position:sticky;top:0;z-index:1;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px;border-bottom:1px solid var(--psd-border);background:rgba(248,250,252,.94);font-size:12px;font-weight:800}
  34. .psd-layer-list{margin:0;padding:8px;list-style:none}
  35. .psd-layer{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:start;margin:0 0 6px;padding:8px;border:1px solid transparent;border-radius:8px;background:#fff}
  36. .psd-layer:hover{border-color:rgba(15,118,110,.24)}
  37. .psd-layer input{margin-top:2px;accent-color:var(--psd-accent)}
  38. .psd-layer strong{display:block;overflow:hidden;font-size:12px;text-overflow:ellipsis;white-space:nowrap}
  39. .psd-layer span{display:block;margin-top:3px;color:var(--psd-muted);font-size:11px}
  40. .psd-stage{min-width:0;min-height:0;overflow:auto;padding:28px}
  41. .psd-canvas-wrap{display:inline-block;transform-origin:top left;transition:transform .18s ease}
  42. .psd-canvas-shell{display:inline-block;padding:18px;border:1px solid var(--psd-border);border-radius:8px;background:
  43. linear-gradient(45deg,#e5e7eb 25%,transparent 25%),
  44. linear-gradient(-45deg,#e5e7eb 25%,transparent 25%),
  45. linear-gradient(45deg,transparent 75%,#e5e7eb 75%),
  46. linear-gradient(-45deg,transparent 75%,#e5e7eb 75%);
  47. background-color:#fff;background-position:0 0,0 10px,10px -10px,-10px 0;background-size:20px 20px;box-shadow:0 18px 48px rgba(15,23,42,.14)}
  48. .psd-canvas-shell canvas{display:block;max-width:none;background:transparent}
  49. .psd-empty{padding:20px;color:var(--psd-muted);font-weight:700}
  50. .file-viewer[data-viewer-theme='dark'] .psd-viewer{--psd-bg:#0d1117;--psd-surface:#161b22;--psd-border:rgba(139,148,158,.24);--psd-text:#e6edf3;--psd-muted:#8b949e}
  51. .file-viewer[data-viewer-theme='dark'] .psd-toolbar{background:rgba(13,17,23,.92)}
  52. .file-viewer[data-viewer-theme='dark'] .psd-sidebar,.file-viewer[data-viewer-theme='dark'] .psd-sidebar-header{background:rgba(22,27,34,.92)}
  53. .file-viewer[data-viewer-theme='dark'] .psd-layer,.file-viewer[data-viewer-theme='dark'] .psd-actions button{background:#161b22;color:#e6edf3}
  54. @media (prefers-color-scheme:dark){.file-viewer[data-viewer-theme='system'] .psd-viewer{--psd-bg:#0d1117;--psd-surface:#161b22;--psd-border:rgba(139,148,158,.24);--psd-text:#e6edf3;--psd-muted:#8b949e}.file-viewer[data-viewer-theme='system'] .psd-toolbar{background:rgba(13,17,23,.92)}.file-viewer[data-viewer-theme='system'] .psd-sidebar,.file-viewer[data-viewer-theme='system'] .psd-sidebar-header{background:rgba(22,27,34,.92)}.file-viewer[data-viewer-theme='system'] .psd-layer,.file-viewer[data-viewer-theme='system'] .psd-actions button{background:#161b22;color:#e6edf3}}
  55. @media (max-width:760px){.psd-layout{grid-template-columns:1fr}.psd-sidebar{max-height:240px;border-right:0;border-bottom:1px solid var(--psd-border)}.psd-stage{padding:14px}}
  56. `
  57. const createElement = <TagName extends keyof HTMLElementTagNameMap>(
  58. documentRef: Document,
  59. tagName: TagName,
  60. className?: string,
  61. text?: string
  62. ) => {
  63. const element = documentRef.createElement(tagName)
  64. if (className) {
  65. element.className = className
  66. }
  67. if (typeof text === 'string') {
  68. element.textContent = text
  69. }
  70. return element
  71. }
  72. const createStyle = (documentRef: Document) => {
  73. const style = documentRef.createElement('style')
  74. style.textContent = psdStyle
  75. return style
  76. }
  77. const toCanvas = (documentRef: Document, source: unknown): HTMLCanvasElement | undefined => {
  78. if (!source) {
  79. return undefined
  80. }
  81. const ownerWindow = documentRef.defaultView || (typeof window !== 'undefined' ? window : undefined)
  82. if (ownerWindow && source instanceof ownerWindow.HTMLCanvasElement) {
  83. return source
  84. }
  85. if (ownerWindow && source instanceof ownerWindow.ImageData) {
  86. const canvas = documentRef.createElement('canvas')
  87. canvas.width = source.width
  88. canvas.height = source.height
  89. canvas.getContext('2d')?.putImageData(source, 0, 0)
  90. return canvas
  91. }
  92. if (
  93. typeof source === 'object' &&
  94. 'data' in source &&
  95. 'width' in source &&
  96. 'height' in source
  97. ) {
  98. const pixelData = source as { data: Uint8ClampedArray | Uint8Array; width: number; height: number }
  99. const canvas = documentRef.createElement('canvas')
  100. canvas.width = pixelData.width
  101. canvas.height = pixelData.height
  102. const clamped = new Uint8ClampedArray(pixelData.data.length)
  103. clamped.set(pixelData.data)
  104. const ImageDataConstructor = ownerWindow?.ImageData || ImageData
  105. canvas.getContext('2d')?.putImageData(new ImageDataConstructor(clamped, pixelData.width, pixelData.height), 0, 0)
  106. return canvas
  107. }
  108. return undefined
  109. }
  110. const flattenLayers = (
  111. documentRef: Document,
  112. layers: any[] | undefined,
  113. depth = 0,
  114. path = ''
  115. ): PsdLayerItem[] => {
  116. if (!Array.isArray(layers)) {
  117. return []
  118. }
  119. return layers.flatMap((layer, index) => {
  120. const name = String(layer.name || `Layer ${index + 1}`)
  121. const id = `${path}${index}`
  122. const canvas = toCanvas(documentRef, layer.canvas || layer.imageData)
  123. const item: PsdLayerItem = {
  124. id,
  125. name,
  126. depth,
  127. left: Number(layer.left || 0),
  128. top: Number(layer.top || 0),
  129. width: Math.max(0, Number(layer.right || 0) - Number(layer.left || 0)),
  130. height: Math.max(0, Number(layer.bottom || 0) - Number(layer.top || 0)),
  131. hidden: Boolean(layer.hidden),
  132. canvas
  133. }
  134. return [item, ...flattenLayers(documentRef, layer.children, depth + 1, `${id}.`)]
  135. })
  136. }
  137. const clampZoom = (value: number) => {
  138. return Math.min(4, Math.max(0.1, Number(value.toFixed(2))))
  139. }
  140. export default async function renderPsdAsset(
  141. buffer: ArrayBuffer,
  142. target: HTMLDivElement,
  143. context?: FileRenderContext
  144. ): Promise<FileViewerRenderedInstance> {
  145. const documentRef = target.ownerDocument || document
  146. const t = createFileViewerTranslator(context?.options)
  147. const { readPsd } = await import('ag-psd')
  148. const psd = readPsd(buffer, { useImageData: true })
  149. const compositeCanvas = toCanvas(documentRef, (psd as any).canvas || (psd as any).imageData)
  150. const layers = flattenLayers(documentRef, (psd as any).children)
  151. const drawableLayers = layers.filter(layer => layer.canvas)
  152. const selected = new Set(drawableLayers.filter(layer => !layer.hidden).map(layer => layer.id))
  153. let zoom = 1
  154. const zoomEmitter = createFileViewerZoomChangeEmitter()
  155. const root = createElement(documentRef, 'div', 'psd-viewer')
  156. root.dataset.viewerZoomProvider = 'psd'
  157. const toolbar = createElement(documentRef, 'div', 'psd-toolbar')
  158. const title = createElement(documentRef, 'div', 'psd-title')
  159. title.append(
  160. createElement(documentRef, 'strong', undefined, t('psd.title')),
  161. createElement(documentRef, 'span', undefined, `${psd.width || 0} x ${psd.height || 0} · ${layers.length} layers · ag-psd`)
  162. )
  163. const actions = createElement(documentRef, 'div', 'psd-actions')
  164. const zoomOut = createElement(documentRef, 'button', undefined, '-') as HTMLButtonElement
  165. const zoomLabel = createElement(documentRef, 'span', undefined, '100%')
  166. const zoomIn = createElement(documentRef, 'button', undefined, '+') as HTMLButtonElement
  167. const reset = createElement(documentRef, 'button', undefined, t('psd.action.fit')) as HTMLButtonElement
  168. const showAll = createElement(documentRef, 'button', undefined, t('psd.action.showAll')) as HTMLButtonElement
  169. const hideAll = createElement(documentRef, 'button', undefined, t('psd.action.hideAll')) as HTMLButtonElement
  170. ;[zoomOut, zoomIn, reset, showAll, hideAll].forEach(button => {
  171. button.type = 'button'
  172. })
  173. actions.append(zoomOut, zoomLabel, zoomIn, reset, showAll, hideAll)
  174. toolbar.append(title, actions)
  175. const layout = createElement(documentRef, 'div', 'psd-layout')
  176. const sidebar = createElement(documentRef, 'aside', 'psd-sidebar')
  177. const sidebarHeader = createElement(documentRef, 'div', 'psd-sidebar-header')
  178. sidebarHeader.append(
  179. createElement(documentRef, 'span', undefined, t('psd.layers.title')),
  180. createElement(documentRef, 'span', undefined, t('psd.layers.redrawable', { count: drawableLayers.length }))
  181. )
  182. const list = createElement(documentRef, 'ul', 'psd-layer-list')
  183. sidebar.append(sidebarHeader, list)
  184. const stage = createElement(documentRef, 'main', 'psd-stage')
  185. const wrap = createElement(documentRef, 'div', 'psd-canvas-wrap')
  186. const shell = createElement(documentRef, 'div', 'psd-canvas-shell')
  187. const canvas = createElement(documentRef, 'canvas') as HTMLCanvasElement
  188. canvas.width = Math.max(1, Number(psd.width || compositeCanvas?.width || 1))
  189. canvas.height = Math.max(1, Number(psd.height || compositeCanvas?.height || 1))
  190. shell.appendChild(canvas)
  191. wrap.appendChild(shell)
  192. stage.appendChild(wrap)
  193. layout.append(sidebar, stage)
  194. root.append(toolbar, layout)
  195. target.replaceChildren(createStyle(documentRef), root)
  196. const redraw = () => {
  197. const context = canvas.getContext('2d')
  198. if (!context) {
  199. return
  200. }
  201. context.clearRect(0, 0, canvas.width, canvas.height)
  202. const visibleLayerCanvases = drawableLayers.filter(layer => selected.has(layer.id))
  203. if (!visibleLayerCanvases.length && compositeCanvas) {
  204. context.drawImage(compositeCanvas, 0, 0)
  205. return
  206. }
  207. visibleLayerCanvases.slice().reverse().forEach(layer => {
  208. if (layer.canvas) {
  209. context.drawImage(layer.canvas, layer.left, layer.top)
  210. }
  211. })
  212. }
  213. if (!layers.length) {
  214. list.appendChild(createElement(documentRef, 'li', 'psd-empty', t('psd.layers.empty')))
  215. }
  216. layers.forEach(layer => {
  217. const item = createElement(documentRef, 'li', 'psd-layer')
  218. item.style.paddingLeft = `${8 + layer.depth * 14}px`
  219. const checkbox = createElement(documentRef, 'input') as HTMLInputElement
  220. checkbox.type = 'checkbox'
  221. checkbox.disabled = !layer.canvas
  222. checkbox.checked = selected.has(layer.id)
  223. checkbox.addEventListener('change', () => {
  224. if (checkbox.checked) {
  225. selected.add(layer.id)
  226. } else {
  227. selected.delete(layer.id)
  228. }
  229. redraw()
  230. })
  231. const copy = createElement(documentRef, 'div')
  232. copy.append(
  233. createElement(documentRef, 'strong', undefined, layer.name),
  234. createElement(documentRef, 'span', undefined, `${layer.left},${layer.top} · ${layer.width} x ${layer.height}${layer.hidden ? ` · ${t('psd.layers.hidden')}` : ''}`)
  235. )
  236. item.append(checkbox, copy)
  237. list.appendChild(item)
  238. })
  239. const getZoomState = (): FileViewerZoomState => ({
  240. scale: zoom,
  241. label: `${Math.round(zoom * 100)}%`,
  242. canZoomIn: zoom < 4,
  243. canZoomOut: zoom > 0.1,
  244. canReset: zoom !== 1,
  245. minScale: 0.1,
  246. maxScale: 4
  247. })
  248. const setZoom = (scale: number) => {
  249. zoom = clampZoom(scale)
  250. wrap.style.transform = `scale(${zoom})`
  251. zoomLabel.textContent = `${Math.round(zoom * 100)}%`
  252. zoomEmitter.emit()
  253. return getZoomState()
  254. }
  255. zoomOut.addEventListener('click', () => setZoom(zoom - 0.1))
  256. zoomIn.addEventListener('click', () => setZoom(zoom + 0.1))
  257. reset.addEventListener('click', () => setZoom(1))
  258. showAll.addEventListener('click', () => {
  259. drawableLayers.forEach(layer => selected.add(layer.id))
  260. list.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach(input => {
  261. if (!input.disabled) input.checked = true
  262. })
  263. redraw()
  264. })
  265. hideAll.addEventListener('click', () => {
  266. selected.clear()
  267. list.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach(input => {
  268. if (!input.disabled) input.checked = false
  269. })
  270. redraw()
  271. })
  272. registerFileViewerZoomProvider(root, {
  273. zoomIn: () => setZoom(zoom + 0.1),
  274. zoomOut: () => setZoom(zoom - 0.1),
  275. resetZoom: () => setZoom(1),
  276. setZoom,
  277. getState: getZoomState,
  278. subscribe: zoomEmitter.subscribe
  279. })
  280. redraw()
  281. setZoom(1)
  282. return {
  283. $el: root,
  284. unmount() {
  285. unregisterFileViewerZoomProvider(root)
  286. target.replaceChildren()
  287. }
  288. }
  289. }