- {{ 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 @@
>