From aa7d6e9aaaebdf1e38b562cccd68c545e2bbcc82 Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Tue, 29 Sep 2026 15:07:36 +0300 Subject: [PATCH] add pinch zoom and fix touch editing --- app/javascript/template_builder/builder.vue | 92 ++++++++++++++++--- .../template_builder/dynamic_block_menu.vue | 6 +- .../template_builder/dynamic_document.vue | 17 +++- .../template_builder/dynamic_editor.js | 44 ++++++++- .../template_builder/dynamic_insert_menu.vue | 56 +++++++++-- .../template_builder/dynamic_section.vue | 6 +- app/javascript/template_builder/i18n.js | 7 -- 7 files changed, 185 insertions(+), 43 deletions(-) diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 9ed5d3205..03bb05153 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -587,9 +587,14 @@ class="w-full mt-0.5 pt-0.5" :class="[ isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto', - zoomLevel > 1 ? 'overflow-x-auto' : 'overflow-x-hidden' + zoomLevel > 1 ? 'overflow-x-auto' : 'overflow-x-hidden', + { 'touch-pan-x touch-pan-y': hasDynamicDocuments && !isVisualViewportZoomed } ]" @wheel="onPagesWheel" + @touchstart="onPagesTouchStart" + @touchmove="onPagesTouchMove" + @touchend="onPagesTouchEnd" + @touchcancel="onPagesTouchEnd" >
- {{ Math.round(zoomLevel * 100) }}% + {{ Math.round((pinchZoomLevel || zoomLevel) * 100) }}% @@ -1394,10 +1399,15 @@ export default { editModalDocumentUuid: null, revisions: [], beforeRevisionSnapshot: null, - zoomLevel: 1 + zoomLevel: 1, + pinchZoomLevel: null, + isVisualViewportZoomed: false } }, computed: { + hasDynamicDocuments () { + return this.template.schema.some((item) => item.dynamic) + }, submitterDefaultNames: FieldSubmitter.computed.names, isSelectModeRef: () => ref(false), isCmdKeyRef: () => ref(false), @@ -1653,6 +1663,7 @@ export default { window.addEventListener('keydown', this.onKeyDown) window.addEventListener('dragleave', this.onWindowDragLeave) + window.visualViewport?.addEventListener('resize', this.onVisualViewportResize) this.$nextTick(() => { if (document.location.search?.includes('stripe_connect_success')) { @@ -1677,6 +1688,7 @@ export default { this.resizeObserver.disconnect() window.removeEventListener('dragleave', this.onWindowDragLeave) + window.visualViewport?.removeEventListener('resize', this.onVisualViewportResize) }, beforeUpdate () { this.documentRefs = [] @@ -2464,23 +2476,73 @@ export default { event.preventDefault() - const oldZoom = this.zoomLevel - const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006)) + this.setZoomLevel(this.zoomLevel - event.deltaY * 0.006, event.clientX, event.clientY) + }, + onVisualViewportResize () { + if (!this.hasDynamicDocuments) return - if (nextZoom === oldZoom) return + this.isVisualViewportZoomed = window.visualViewport.scale > 1.01 + }, + onPagesTouchStart (event) { + if (event.touches.length === 2 && this.hasDynamicDocuments && !this.isVisualViewportZoomed) { + const [a, b] = event.touches + const rect = this.$refs.documents.getBoundingClientRect() + const x = (a.clientX + b.clientX) / 2 + const y = (a.clientY + b.clientY) / 2 + this.pinch = { distance: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), zoomLevel: this.zoomLevel, x, y } + this.pinchZoomLevel = this.zoomLevel + + this.$refs.documents.style.transformOrigin = `${x - rect.left}px ${y - rect.top}px` + } + }, + onPagesTouchMove (event) { + if (!this.pinch || event.touches.length !== 2) return + + event.preventDefault() + + const [a, b] = event.touches + + this.pinchZoomLevel = Math.max(1, Math.min(3, this.pinch.zoomLevel * Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) / this.pinch.distance)) + + this.$refs.documents.style.transform = `scale(${this.pinchZoomLevel / this.pinch.zoomLevel})` + }, + onPagesTouchEnd (event) { + if (!this.pinch || event.touches.length === 2) return + + this.$refs.documents.style.transform = '' + this.$refs.documents.style.transformOrigin = '' + + this.setZoomLevel(this.pinchZoomLevel, this.pinch.x, this.pinch.y) + + this.pinch = null + this.pinchZoomLevel = null + }, + resetZoomLevel () { const rect = this.$refs.pagesContainer.getBoundingClientRect() - const cursorX = event.clientX - rect.left - const cursorY = event.clientY - rect.top - const ratio = nextZoom / oldZoom - const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX - const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY + + this.setZoomLevel(1, rect.left + rect.width / 2, (Math.max(rect.top, 0) + Math.min(rect.bottom, window.innerHeight)) / 2) + }, + setZoomLevel (zoomLevel, clientX, clientY) { + const nextZoom = Math.max(1, Math.min(3, zoomLevel)) + + if (nextZoom === this.zoomLevel) return + + const container = this.$refs.pagesContainer + const scrollContainer = container.scrollHeight > container.clientHeight ? container : document.scrollingElement + const rect = this.$refs.documents.getBoundingClientRect() this.zoomLevel = nextZoom this.$nextTick(() => { - this.$refs.pagesContainer.scrollLeft = nextScrollLeft - this.$refs.pagesContainer.scrollTop = nextScrollTop + this.documentRefs.filter((ref) => ref.isDynamic).forEach((ref) => ref.updateContainerWidth()) + + this.$nextTick(() => { + const nextRect = this.$refs.documents.getBoundingClientRect() + + container.scrollLeft += nextRect.left - clientX + (clientX - rect.left) * nextRect.width / rect.width + scrollContainer.scrollTop += nextRect.top - clientY + (clientY - rect.top) * nextRect.height / rect.height + }) }) }, setDocumentRefs (el) { diff --git a/app/javascript/template_builder/dynamic_block_menu.vue b/app/javascript/template_builder/dynamic_block_menu.vue index 73d7aa67a..c0da1151c 100644 --- a/app/javascript/template_builder/dynamic_block_menu.vue +++ b/app/javascript/template_builder/dynamic_block_menu.vue @@ -49,7 +49,7 @@ />
this.documentFonts) @@ -197,21 +197,29 @@ export default { this.ensureListsStyle(this.bodyDom) - this.containerWidth = this.$refs.container.clientWidth + this.updateContainerWidth() this.resizeObserver = new ResizeObserver(() => { if (this.$refs.container) { - this.containerWidth = this.$refs.container.clientWidth + this.updateContainerWidth() } }) this.resizeObserver.observe(this.$refs.container) window.addEventListener('beforeunload', this.onBeforeUnload) + + if (this.nativePlatform === 'ios') { + this.viewportMeta = document.querySelector('meta[name="viewport"]') + this.viewportContent = this.viewportMeta.getAttribute('content') + this.viewportMeta.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0') + } }, beforeUnmount () { window.removeEventListener('beforeunload', this.onBeforeUnload) + this.viewportMeta?.setAttribute('content', this.viewportContent) + this.resizeObserver.unobserve(this.$refs.container) }, beforeUpdate () { @@ -219,6 +227,9 @@ export default { }, methods: { mergeSchemaProperties, + updateContainerWidth () { + this.containerWidth = this.$refs.container.clientWidth + }, removeArea (area) { this.sectionRefs.forEach((sectionRef) => { const pos = sectionRef.findAreaNodePos(area.uuid) diff --git a/app/javascript/template_builder/dynamic_editor.js b/app/javascript/template_builder/dynamic_editor.js index ef25279c6..886d13fc2 100644 --- a/app/javascript/template_builder/dynamic_editor.js +++ b/app/javascript/template_builder/dynamic_editor.js @@ -104,12 +104,12 @@ img.ProseMirror-separator { } [data-resize-handle] { - --handle-size: calc(10px / var(--zoom, 1)); + --handle-size: calc(10px / var(--zoom)); display: none; width: var(--handle-size); height: var(--handle-size); background: #ffffff; - border: calc(2px / var(--zoom, 1)) solid #3b82f6; + border: calc(2px / var(--zoom)) solid #3b82f6; border-radius: 2px; z-index: 1; } @@ -118,7 +118,7 @@ img.ProseMirror-separator { [data-resize-handle]::after { content: ""; position: absolute; - inset: calc(-8px / var(--zoom, 1)); + inset: calc(-8px / var(--zoom)); } } @@ -135,6 +135,12 @@ img.ProseMirror-separator { [data-resize-handle="bottom-left"] { margin: 0 0 calc(var(--handle-size) / -2) calc(var(--handle-size) / -2); cursor: nesw-resize; } [data-resize-handle="bottom-right"] { margin: 0 calc(var(--handle-size) / -2) calc(var(--handle-size) / -2) 0; cursor: nwse-resize; } +/* iOS 27 WebKit drops thin collapsed borders when zoomed out */ +[data-zoomed-out] .ProseMirror table { + border-collapse: separate !important; + border-spacing: 0 !important; +} + .ProseMirror .selectedCell { position: relative; } @@ -1586,7 +1592,9 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor widths[columnIndex] += change widths[columnIndex + 1] -= change } else { - widths[columnIndex] = Math.max(widths[columnIndex] + delta, MIN_SIZE_PX) + const maxDelta = (view.dom.parentElement.getBoundingClientRect().right - tableDom.getBoundingClientRect().right) / zoom + + widths[columnIndex] = Math.max(widths[columnIndex] + Math.min(delta, Math.max(maxDelta, 0)), MIN_SIZE_PX) } setTableColumnWidths(view, tableDom, widths) @@ -1680,6 +1688,25 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor }) class ZoomedResizableNodeView extends ResizableNodeView { + constructor (options) { + super(options) + + this.wrapper.addEventListener('touchstart', (event) => { + if (event.target.closest('[data-resize-handle]')) { + document.addEventListener('touchend', this.handleTouchEnd) + document.addEventListener('touchcancel', this.handleTouchEnd) + } + }, true) + } + + handleTouchEnd = () => { + document.removeEventListener('touchend', this.handleTouchEnd) + document.removeEventListener('touchcancel', this.handleTouchEnd) + document.removeEventListener('touchmove', this.handleTouchMove) + + this.handleMouseUp() + } + handleResize (deltaX, deltaY) { const zoom = dynamicAreaProps.getZoom() @@ -1688,6 +1715,15 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor } const DynamicImageNode = ImageNode.extend({ + addProseMirrorPlugins () { + return [ + new Plugin({ + props: { + attributes: (state) => state.selection.node?.type.name === this.name ? { inputmode: 'none', style: 'caret-color: transparent' } : {} + } + }) + ] + }, renderHTML ({ node }) { const { loading, ...attrs } = node.attrs.htmlAttrs diff --git a/app/javascript/template_builder/dynamic_insert_menu.vue b/app/javascript/template_builder/dynamic_insert_menu.vue index 914a0f565..820eaad0e 100644 --- a/app/javascript/template_builder/dynamic_insert_menu.vue +++ b/app/javascript/template_builder/dynamic_insert_menu.vue @@ -27,7 +27,7 @@ >