Ariver
2026-06-25 c8be92c4ca12b53cec27c39977d2139bea3f0fe7
src/Apptag/AppDragCoordinator.swift
@@ -21,6 +21,9 @@
    private var dragLayer: CALayer?
    private var normalDragImage: CGImage?
    private var copyDragImage: CGImage?
    private var shredPreviewActive = false
    private var shredSliceLayers: [CALayer] = []
    private var shredGuideLayers: [CALayer] = []
    private var currentCopyMode = false
    private var dragWindow: NSWindow?
    private var dragImageSize: NSSize = .zero
@@ -131,6 +134,7 @@
            dragLayer.position = contentPoint
            CATransaction.commit()
            updateHoverTarget(at: screenPoint)
            updateRemoveTagPreview(at: screenPoint)
            hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode)
            return
        }
@@ -145,6 +149,7 @@
        )
        dragWindow.setFrameOrigin(origin)
        updateHoverTarget(at: screenPoint)
        updateRemoveTagPreview(at: screenPoint)
        hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode)
    }
@@ -190,11 +195,17 @@
        currentCopyMode = copy
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        dragLayer?.contents = copy ? copyDragImage : normalDragImage
        if shredPreviewActive {
            let image = currentDragImage
            shredSliceLayers.forEach { $0.contents = image }
        } else {
            dragLayer?.contents = currentDragImage
        }
        CATransaction.commit()
    }
    private func endDragVisuals() {
        stopShredPreview()
        hoveredTarget?.appDragHoverChanged(active: false)
        hoveredTarget = nil
        dragLayer?.removeFromSuperlayer()
@@ -210,6 +221,10 @@
        dragImageSize = .zero
    }
    private var currentDragImage: CGImage? {
        currentCopyMode ? copyDragImage : normalDragImage
    }
    private func pruneDeadTargets() {
        targets = targets.filter { $0.value.view != nil }
        emptyTargets = emptyTargets.filter { $0.value.view != nil }
@@ -223,6 +238,157 @@
        hoveredTarget?.appDragHoverChanged(active: false)
        nextTarget?.appDragHoverChanged(active: true)
        hoveredTarget = nextTarget
    }
    private func updateRemoveTagPreview(at screenPoint: NSPoint) {
        guard hasActiveDrag else {
            setShredPreviewActive(false)
            return
        }
        setShredPreviewActive(removeTagPreviewTarget(at: screenPoint) != nil)
    }
    private func removeTagPreviewTarget(at screenPoint: NSPoint) -> AppEmptyDropReceivingView? {
        guard dropTarget(at: screenPoint) == nil else { return nil }
        let parts = activePayload.components(separatedBy: "\n")
        guard let path = parts.first, !path.isEmpty else { return nil }
        let source = parts.dropFirst().first ?? ""
        return emptyTargets.values
            .compactMap { target -> (AppEmptyDropReceivingView, CGFloat)? in
                guard let view = target.view,
                      let frame = view.screenFrame(),
                      frame.contains(screenPoint),
                      view.canPreviewEmptyDrop(
                        path: path,
                        source: source,
                        screenPoint: screenPoint,
                        copy: currentCopyMode
                      )
                else { return nil }
                return (view, frame.width * frame.height)
            }
            .sorted { $0.1 < $1.1 }
            .first?.0
    }
    private func setShredPreviewActive(_ active: Bool) {
        guard active != shredPreviewActive else { return }
        if active {
            startShredPreview()
        } else {
            stopShredPreview()
        }
    }
    private func startShredPreview() {
        guard let dragLayer,
              let image = currentDragImage,
              !shredPreviewActive,
              dragImageSize.width > 0,
              dragImageSize.height > 0
        else { return }
        shredPreviewActive = true
        dragLayer.contents = nil
        shredSliceLayers.removeAll()
        shredGuideLayers.removeAll()
        let sliceCount = 7
        let sliceWidth = dragImageSize.width / CGFloat(sliceCount)
        for index in 0..<sliceCount {
            let x = CGFloat(index) * sliceWidth
            let width = index == sliceCount - 1
                ? dragImageSize.width - x
                : ceil(sliceWidth)
            let slice = CALayer()
            slice.frame = CGRect(x: x, y: 0, width: width, height: dragImageSize.height)
            slice.contents = image
            slice.contentsGravity = .resize
            slice.contentsScale = NSScreen.main?.backingScaleFactor ?? 2
            slice.contentsRect = CGRect(
                x: CGFloat(index) / CGFloat(sliceCount),
                y: 0,
                width: 1 / CGFloat(sliceCount),
                height: 1
            )
            slice.actions = [
                "position": NSNull(),
                "opacity": NSNull(),
                "transform": NSNull()
            ]
            dragLayer.addSublayer(slice)
            addShredAnimation(to: slice, index: index)
            shredSliceLayers.append(slice)
            if index > 0 {
                let guide = CALayer()
                guide.frame = CGRect(
                    x: x - 0.5,
                    y: dragImageSize.height * 0.12,
                    width: 1,
                    height: dragImageSize.height * 0.76
                )
                guide.backgroundColor = NSColor.white.withAlphaComponent(0.42).cgColor
                guide.opacity = 0
                guide.actions = [
                    "opacity": NSNull(),
                    "position": NSNull()
                ]
                dragLayer.addSublayer(guide)
                addShredGuideAnimation(to: guide, index: index)
                shredGuideLayers.append(guide)
            }
        }
    }
    private func stopShredPreview() {
        guard shredPreviewActive || !shredSliceLayers.isEmpty || !shredGuideLayers.isEmpty else { return }
        shredPreviewActive = false
        shredSliceLayers.forEach {
            $0.removeAllAnimations()
            $0.removeFromSuperlayer()
        }
        shredGuideLayers.forEach {
            $0.removeAllAnimations()
            $0.removeFromSuperlayer()
        }
        shredSliceLayers.removeAll()
        shredGuideLayers.removeAll()
        dragLayer?.contents = currentDragImage
    }
    private func addShredAnimation(to layer: CALayer, index: Int) {
        let direction: CGFloat = index.isMultiple(of: 2) ? 1 : -1
        let travel = min(4.5, max(2.0, dragImageSize.height * 0.06))
        let drift = min(2.4, max(1.0, dragImageSize.width * 0.025)) * direction
        let y = CAKeyframeAnimation(keyPath: "transform.translation.y")
        y.values = [0, -travel, travel * 0.28, -travel * 0.52, 0]
        let x = CAKeyframeAnimation(keyPath: "transform.translation.x")
        x.values = [0, drift, -drift * 0.45, drift * 0.25, 0]
        let opacity = CAKeyframeAnimation(keyPath: "opacity")
        opacity.values = [1, 0.82, 0.96, 0.88, 1]
        let group = CAAnimationGroup()
        group.animations = [x, y, opacity]
        group.duration = 0.46
        group.repeatCount = .greatestFiniteMagnitude
        group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        group.beginTime = CACurrentMediaTime() + Double(index) * 0.018
        layer.add(group, forKey: "removeTagShredPreview")
    }
    private func addShredGuideAnimation(to layer: CALayer, index: Int) {
        let opacity = CAKeyframeAnimation(keyPath: "opacity")
        opacity.values = [0, 0.42, 0.18, 0.35, 0]
        opacity.duration = 0.46
        opacity.repeatCount = .greatestFiniteMagnitude
        opacity.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        opacity.beginTime = CACurrentMediaTime() + Double(index) * 0.018
        layer.add(opacity, forKey: "removeTagShredGuide")
    }
    private func dropTarget(at screenPoint: NSPoint) -> AppDropTargetReceivingView? {
@@ -298,6 +464,7 @@
protocol AppEmptyDropReceivingView: AnyObject {
    func screenFrame() -> NSRect?
    func canPreviewEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) -> Bool
    func performEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool)
}
@@ -325,6 +492,12 @@
    }
}
extension AppEmptyDropReceivingView {
    func canPreviewEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) -> Bool {
        true
    }
}
struct AppDropTargetView: NSViewRepresentable {
    let targetTag: String
    let onDropApp: (String, String, Bool) -> Void