[js] 平板和移动端触摸多选 + 拖拽

碎碎念

这个脚本写了好久了,但之前一直有点 bug(选了嵌入块没法退出多选模式)没有修,今天有空就给修了。其实主要是给平板端用的。

使用方式

  • 长按单选块
    • 长按拖动拖拽块
    • 长按后,松手再向左滑进入多选块模式
  • 向左滑:进入多选块模式
  • 向右滑:退出多选块模式

需要注意的地方

  • 不要直接在嵌入块上左滑进入多选模式
  • 思源左右页面的问题,后面看看社区有没有解决方案,或者另起一个 js

效果

代码

(function() {
    // 常量定义
    const LONG_PRESS_DURATION = 800; // 长按时间阈值(毫秒)
    const MIN_MOVE_FOR_SWIPE = 50;  // 滑动识别的最小距离
    const ZWSP = "\u200b"; // 零宽空格,用于分隔拖拽数据
  
    // 状态变量
    let touchTimer = null;
    let isMultiSelectMode = false;
    let touchStartX = 0;
    let touchStartY = 0;
    let selectedBlock = null;
    let ghostElement = null;
    let isDragging = false;
    let isLongPressing = false; // 标记是否正在长按
    let initialState = []; // 记录初始状态下选中的块ID列表
    let isMultiSelecting = false; // 标记是否正在滑动多选
    let lastTouchedBlock = null; // 记录上一个触摸到的块
    let selectedBlocksMap = new Map(); // 用Map记录已选中的块,避免被思源清除
  
    // 辅助函数:获取最近的块元素(排除整个编辑区域和容器元素)
    const getClosestBlock = (element) => {
        // 首先尝试使用closest方法直接获取最近的块
        const closestBlock = element.closest('[data-node-id]');
  
        // 检查是否是容器元素或编辑区域
        if (closestBlock && !isContainerElement(closestBlock)) {
            return closestBlock;
        }
  
        // 如果closest方法失败或返回了容器元素,则使用传统的向上遍历方法
        let currentElement = element;
        while (currentElement && 
               (!currentElement.getAttribute('data-node-id') || 
                isContainerElement(currentElement))) {
            currentElement = currentElement.parentElement;
            if (!currentElement) return null;
        }
  
        // 额外检查,确保不返回容器元素
        if (currentElement && isContainerElement(currentElement)) {
            return null;
        }
  
        return currentElement;
    };
  
    // 辅助函数:检查元素是否是容器元素(不应该被选中的元素)
    const isContainerElement = (element) => {
        if (!element || !element.classList) return false;
  
        // 检查是否是编辑区域或其他容器元素
        return element.classList.contains('protyle-wysiwyg') || 
               element.classList.contains('layout-tab-container') || 
               element.classList.contains('fn__flex-1') || 
               element.classList.contains('protyle') || 
               element.classList.contains('protyle-content') || 
               element.classList.contains('protyle-wysiwyg__embed') || 
               // 检查是否是没有data-node-id的元素
               (!element.getAttribute('data-node-id') && element.classList.length > 0);
    };
  
    // 辅助函数:检查元素是否在嵌入块中,并返回嵌入块元素
    const isInEmbedBlock = (element) => {
        let parent = element?.parentElement;
        while (parent) {
            if (parent.classList && parent.classList.contains("protyle-wysiwyg__embed")) {
                return parent; // 返回嵌入块元素
            }
            parent = parent.parentElement;
        }
        return false;
    };
  
    // 辅助函数:获取嵌入块的父块
    const getEmbedBlockParent = (embedElement) => {
        // 嵌入块的父块通常是包含data-node-id的最近祖先元素
        let parent = embedElement?.parentElement;
        while (parent) {
            if (parent.getAttribute('data-node-id')) {
                return parent;
            }
            parent = parent.parentElement;
        }
        return null;
    };
  
    // 辅助函数:获取当前编辑器元素
    const getProtyleWysiwyg = () => {
        // 获取当前激活的编辑器
        const wysiwyg = document.querySelector('.layout__wnd--active .protyle-wysiwyg') || 
                        document.querySelector('.protyle-wysiwyg');
        return wysiwyg;
    };
  
    // 清理拖拽状态
    const cleanupDrag = () => {
        isDragging = false;
        if (ghostElement) {
            ghostElement.remove();
            ghostElement = null;
        }
  
        // 移除任何拖拽提示样式
        document.querySelectorAll('.dragover__top, .dragover__bottom, .dragover__left, .dragover__right, .dragover').forEach(el => {
            el.classList.remove('dragover__top', 'dragover__bottom', 'dragover__left', 'dragover__right', 'dragover');
        });
  
        window.siyuan.dragElement = undefined;
    };
  
    // 创建拖拽数据
    const createGutterDragData = (blockElement) => {
        // 获取选中的块
        const selectedBlocks = document.querySelectorAll('.protyle-wysiwyg--select');
        const selectIds = Array.from(selectedBlocks).map(el => el.getAttribute('data-node-id')).join(',');
  
        // 使用和思源一样的数据格式
        let blockType = blockElement.getAttribute('data-type') || 'NodeParagraph';
        let subType = blockElement.getAttribute('data-subtype') || '';
  
        // 检查DOM类名,确保我们正确识别列表项和引述块
        if (blockElement.classList.contains('li') && blockType !== 'NodeListItem') {
            blockType = 'NodeListItem';
            // 尝试从类名获取subtype (o: 有序列表, u: 无序列表, t: 任务列表)
            if (blockElement.parentElement && blockElement.parentElement.classList.contains('list')) {
                subType = blockElement.parentElement.getAttribute('data-subtype') || '';
            }
        } else if (blockElement.classList.contains('list') && blockType !== 'NodeList') {
            blockType = 'NodeList';
            subType = blockElement.getAttribute('data-subtype') || '';
        } else if (blockElement.classList.contains('bq') && blockType !== 'NodeBlockquote') {
            blockType = 'NodeBlockquote';
        }
  
        const workspaceDir = window.siyuan.config.system.workspaceDir;
        const wysiwygElem = getProtyleWysiwyg();
  
        console.log(`创建拖拽数据: 类型=${blockType}, 子类型=${subType}, IDs=${selectIds}`);
  
        return {
            type: `application/siyuan-gutter${blockType}${ZWSP}${subType}${ZWSP}${selectIds}${ZWSP}${workspaceDir}`,
            html: wysiwygElem ? wysiwygElem.innerHTML : ''
        };
    };
  
    // 创建拖拽预览元素
    const createGhostElement = (x, y) => {
        if (ghostElement) {
            ghostElement.remove();
        }
  
        const selectedBlocks = document.querySelectorAll('.protyle-wysiwyg--select');
        if (selectedBlocks.length === 0) return;
  
        ghostElement = document.createElement('div');
        ghostElement.className = 'protyle-wysiwyg__drag';
        ghostElement.style.position = 'fixed';
        ghostElement.style.opacity = '0.3';
        ghostElement.style.zIndex = '999';
        ghostElement.style.left = `${x}px`;
        ghostElement.style.top = `${y}px`;
        ghostElement.style.pointerEvents = 'none';
  
        // 复制第一个选中元素作为预览
        const cloneNode = selectedBlocks[0].cloneNode(true);
        cloneNode.style.width = `${Math.min(selectedBlocks[0].offsetWidth, 200)}px`;
        cloneNode.style.height = 'auto';
        cloneNode.style.maxHeight = '100px';
        cloneNode.style.overflow = 'hidden';
        ghostElement.appendChild(cloneNode);
  
        document.body.appendChild(ghostElement);
    };
  
    // 更新预览元素位置
    const updateGhostPosition = (x, y) => {
        if (!ghostElement) return;
        ghostElement.style.left = `${x}px`;
        ghostElement.style.top = `${y}px`;
    };
  
    // 开始拖拽
    const startDrag = (element, x, y) => {
        if (!element || isInEmbedBlock(element)) return;
  
        // 如果没有选中任何块,则选中当前块
        if (document.querySelectorAll('.protyle-wysiwyg--select').length === 0) {
            element.classList.add('protyle-wysiwyg--select');
        }
  
        // 设置拖拽元素 - 重要:这是思源拖拽的关键部分
        const wysiwygElement = getProtyleWysiwyg();
        if (!wysiwygElement) return;
  
        window.siyuan.dragElement = wysiwygElement;
  
        // 创建拖拽预览
        createGhostElement(x, y);
  
        // 设置拖拽状态
        isDragging = true;
  
        // 震动反馈
        if (navigator.vibrate) {
            navigator.vibrate(50);
        }
    };
  
    // 处理拖拽经过元素时的视觉反馈
    const handleDragOver = (targetElement, clientX, clientY) => {
        if (!isDragging || !window.siyuan.dragElement) return;
  
        // 获取目标块
        const blockElement = getClosestBlock(targetElement);
        if (!blockElement) return;
  
        // 如果是嵌入块,则不允许拖入
        if (isInEmbedBlock(blockElement)) return;
  
        // 移除之前的拖拽样式
        document.querySelectorAll('.dragover__top, .dragover__bottom, .dragover__left, .dragover__right, .dragover').forEach(el => {
            el.classList.remove('dragover__top', 'dragover__bottom', 'dragover__left', 'dragover__right', 'dragover');
        });
  
        // 计算拖拽位置
        const rect = blockElement.getBoundingClientRect();
        const isTop = clientY < rect.top + rect.height * 0.3;
        const isBottom = clientY > rect.bottom - rect.height * 0.3;
        const isLeft = clientX < rect.left + rect.width * 0.3;
        const isRight = clientX > rect.right - rect.width * 0.3;
  
        // 获取源块类型,用于判断拖拽限制
        const selectedBlock = document.querySelector('.protyle-wysiwyg--select');
        if (!selectedBlock) return;
  
        const sourceBlockType = selectedBlock.getAttribute('data-type');
        const sourceIsListItem = sourceBlockType === 'NodeListItem' || selectedBlock.classList.contains('li');
        const targetIsListItem = blockElement.getAttribute('data-type') === 'NodeListItem' || blockElement.classList.contains('li');
        const targetIsList = blockElement.getAttribute('data-type') === 'NodeList' || blockElement.classList.contains('list');
  
        // 特殊限制处理
        let disabledPosition = null;
  
        // 非列表项不能拖入列表项周围
        if (!sourceIsListItem && targetIsListItem) {
            return;
        }
  
        // 列表项不能拖入列表项中第一个元素之上
        if (sourceIsListItem && targetIsListItem && 
            blockElement.parentElement.classList.contains('li') &&
            blockElement.previousElementSibling?.classList.contains('protyle-action')) {
            disabledPosition = "top";
        }
  
        // 列表项不能拖入列表上方块的下面
        if (sourceIsListItem && blockElement.nextElementSibling?.classList.contains('list')) {
            disabledPosition = "bottom";
        }
  
        // 引述块相关限制
        const sourceIsBlockquote = sourceBlockType === 'NodeBlockquote' || selectedBlock.classList.contains('bq');
        const targetIsBlockquote = blockElement.getAttribute('data-type') === 'NodeBlockquote' || blockElement.classList.contains('bq');
  
        // 超级块相关限制
        const targetIsSuperBlock = blockElement.getAttribute('data-type') === 'NodeSuperBlock' || blockElement.classList.contains('sb');
  
        // 根据禁用的位置调整样式应用
        if (disabledPosition === "top" && isTop) {
            return; // 不应用任何样式
        }
        if (disabledPosition === "bottom" && isBottom) {
            return; // 不应用任何样式
        }
  
        // 设置拖拽样式 - 与思源原版逻辑保持一致
        if (isTop && disabledPosition !== "top") {
            blockElement.classList.add('dragover__top');
        } else if (isBottom && disabledPosition !== "bottom") {
            blockElement.classList.add('dragover__bottom');
        } else if (isLeft && disabledPosition !== "left") {
            blockElement.classList.add('dragover__left');
        } else if (isRight && disabledPosition !== "right") {
            blockElement.classList.add('dragover__right');
        } else {
            // 只有容器块才能接受内部拖放
            if (blockElement.classList.contains('sb') || 
                blockElement.classList.contains('li') ||
                blockElement.classList.contains('list') ||
                blockElement.classList.contains('bq')) {
                blockElement.classList.add('dragover');
            }
        }
    };
  
    // 保护选中状态,防止被思源清除
    const protectSelection = () => {
        // 如果不在多选模式,不需要保护
        if (!isMultiSelectMode) return;
  
        // 恢复所有应该选中的块,但确保不选中容器元素
        selectedBlocksMap.forEach((value, id) => {
            const el = document.querySelector(`[data-node-id="${id}"]`);
            if (el && !el.classList.contains('protyle-wysiwyg--select') && !isContainerElement(el)) {
                el.classList.add('protyle-wysiwyg--select');
            } else if (el && isContainerElement(el)) {
                // 如果是容器元素,从选中映射中移除
                selectedBlocksMap.delete(id);
            }
        });
    };
  
    // 记录当前选中状态
    const saveCurrentState = () => {
        // 获取所有选中的块,但过滤掉容器元素
        initialState = Array.from(document.querySelectorAll('.protyle-wysiwyg--select'))
            .filter(el => !isContainerElement(el) && el.getAttribute('data-node-id'))
            .map(el => el.getAttribute('data-node-id'));
  
        // 初始化选中块映射
        selectedBlocksMap.clear();
        initialState.forEach(id => {
            selectedBlocksMap.set(id, true);
        });
    };
  
    // 恢复到初始状态
    const restoreInitialState = () => {
        // 先清除所有当前选择
        document.querySelectorAll('.protyle-wysiwyg--select').forEach(el => {
            el.classList.remove('protyle-wysiwyg--select');
        });
  
        // 只有在有初始状态且不是用户主动清除所有选择的情况下才恢复初始选择
        if (initialState.length > 0) {
            // 过滤掉容器元素
            const validInitialState = [];
      
            initialState.forEach(id => {
                const el = document.querySelector(`[data-node-id="${id}"]`);
                if (el && !isContainerElement(el)) {
                    el.classList.add('protyle-wysiwyg--select');
                    validInitialState.push(id);
                }
            });
      
            // 重置选中块映射,只包含有效的非容器元素
            selectedBlocksMap.clear();
            validInitialState.forEach(id => {
                selectedBlocksMap.set(id, true);
            });
      
            // 更新初始状态,移除容器元素
            initialState = validInitialState;
        } else {
            // 如果没有初始状态或用户主动清除了所有选择,就不选中任何块
            selectedBlocksMap.clear();
        }
  
        // 退出多选模式
        isMultiSelectMode = false;
    };
  
    // 检查是否有选中的块,如果没有则恢复初始状态
    const checkSelectedBlocks = () => {
        if (!isMultiSelectMode) return;
  
        // 获取所有选中的块
        const selectedBlocks = document.querySelectorAll('.protyle-wysiwyg--select');
  
        // 如果没有选中的块,恢复到初始状态
        if (selectedBlocks.length === 0) {
            restoreInitialState();
            console.log('已恢复初始状态');
        }
    };
  
    // 添加动画效果
    const animateBlock = (blockElement) => {
        if (!blockElement) return;
  
        // 保存原始样式
        const originalTransition = blockElement.style.transition;
        const originalTransform = blockElement.style.transform;
  
        // 设置动画过渡
        blockElement.style.transition = 'transform 0.3s ease-in-out';
  
        // 向左移动
        blockElement.style.transform = 'translateX(-20px)';
  
        // 弹回来
        setTimeout(() => {
            blockElement.style.transform = originalTransform || 'translateX(0)';
      
            // 动画结束后恢复原始过渡样式
            setTimeout(() => {
                blockElement.style.transition = originalTransition;
            }, 300);
        }, 150);
    };
  
    // 阻止长按时弹出右键菜单
    const preventContextMenu = (event) => {
        if (isLongPressing || isDragging || isMultiSelectMode) {
            event.preventDefault();
            event.stopPropagation();
            return false;
        }
    };
  
    // 处理触摸移动
    const handleTouchMove = (event) => {
        if (event.touches.length !== 1) return;
  
        const touch = event.touches[0];
        const moveX = touch.clientX - touchStartX;
        const moveY = touch.clientY - touchStartY;
  
        // 如果移动距离过大,取消长按
        if (!isDragging && (Math.abs(moveX) > 10 || Math.abs(moveY) > 10)) {
            clearTimeout(touchTimer);
        }
  
        // 检测左滑进入多选模式
        if (!isDragging && !isMultiSelectMode && moveX < -MIN_MOVE_FOR_SWIPE) {
            clearTimeout(touchTimer);
            isMultiSelectMode = true;
            isMultiSelecting = true; // 标记正在滑动多选
      
            // 保存当前状态
            saveCurrentState();
      
            // 震动反馈
            if (navigator.vibrate) {
                navigator.vibrate([20, 20, 20]);
            }
      
            // 为当前块添加动画效果
            if (selectedBlock) {
                // 确保只选中当前块,而不是容器元素
                document.querySelectorAll('.protyle-wysiwyg--select').forEach(el => {
                    if (el !== selectedBlock || isContainerElement(el)) {
                        el.classList.remove('protyle-wysiwyg--select');
                    }
                });
                selectedBlocksMap.clear();
                const blockId = selectedBlock.getAttribute('data-node-id');
                if (blockId) {
                    selectedBlocksMap.set(blockId, true);
                    initialState = [blockId];
                }
          
                animateBlock(selectedBlock);
            }
      
            // 清除任何可能的文本选择
            window.getSelection().removeAllRanges();
      
            // 阻止事件传播和默认行为
            event.preventDefault();
            event.stopPropagation();
      
            return;
        }
  
        // 检测右滑退出多选模式
        if (!isDragging && isMultiSelectMode && moveX > MIN_MOVE_FOR_SWIPE) {
            clearTimeout(touchTimer);
      
            // 震动反馈
            if (navigator.vibrate) {
                navigator.vibrate([20, 20, 20]);
            }
      
            // 退出多选模式
            isMultiSelectMode = false;
      
            // 清除所有选择
            document.querySelectorAll('.protyle-wysiwyg--select').forEach(el => {
                el.classList.remove('protyle-wysiwyg--select');
            });
            selectedBlocksMap.clear();
            initialState = [];
      
            // 清除任何可能的文本选择
            window.getSelection().removeAllRanges();
      
            // 阻止事件传播和默认行为
            event.preventDefault();
            event.stopPropagation();
      
            return;
        }
  
        // 多选模式下阻止默认的左右滑动行为
        if (isMultiSelectMode && (Math.abs(moveX) > 10)) {
            event.preventDefault();
            event.stopPropagation();
        }
  
        // 如果在拖拽中,更新预览并处理dragover
        if (isDragging) {
            event.preventDefault(); // 阻止滚动
      
            // 更新拖拽预览位置
            updateGhostPosition(touch.clientX, touch.clientY);
      
            // 处理拖拽经过效果
            const targetElement = document.elementFromPoint(touch.clientX, touch.clientY);
            handleDragOver(targetElement, touch.clientX, touch.clientY);
        }
    };
  
    // 处理触摸结束
    const handleTouchEnd = (event) => {
        clearTimeout(touchTimer);
  
        // 如果在拖拽中,处理放置
        if (isDragging) {
            event.preventDefault();
      
            const x = event.changedTouches[0].clientX;
            const y = event.changedTouches[0].clientY;
      
            // 获取放置目标
            const targetElement = document.elementFromPoint(x, y);
      
            // 处理放置
            handleDrop(targetElement, x, y);
        }
  
        // 重置滑动多选状态
        isMultiSelecting = false;
    };
  
    // 处理点击事件(多选模式下)
    const handleClick = (event) => {
        // 只在多选模式下处理
        if (!isMultiSelectMode) return;
  
        // 立即阻止事件冒泡和默认行为,确保思源的其他点击处理不会干扰
        event.preventDefault();
        event.stopPropagation();
        event.stopImmediatePropagation();
  
        // 清除任何选择
        if (window.getSelection) {
            window.getSelection().removeAllRanges();
        }
  
        // 首先尝试直接从点击目标获取最具体的块
        let clickedBlock = event.target.closest('[data-node-id]');
  
        // 处理嵌入块的情况
        const embedBlock = isInEmbedBlock(event.target);
        if (embedBlock) {
            // 如果点击的是嵌入块内部,获取嵌入块的父块
            const embedParent = getEmbedBlockParent(embedBlock);
            if (embedParent) {
                clickedBlock = embedParent;
            }
        }
  
        // 如果上述方法未找到块,或者找到的是容器元素,尝试使用getClosestBlock
        if (!clickedBlock || isContainerElement(clickedBlock)) {
            clickedBlock = getClosestBlock(event.target);
        }
  
        // 最终安全检查:确保不选中容器元素
        if (clickedBlock && isContainerElement(clickedBlock)) {
            console.log('警告:点击事件试图选中容器元素,已阻止');
            return;
        }
  
        if (!clickedBlock) return;
  
        const blockId = clickedBlock.getAttribute('data-node-id');
        if (!blockId) return;
  
        // 在多选模式下模拟shift+点击行为(累加选择)
        // 如果块已选中,点击会取消选择;如果块未选中,点击会添加到选择
        if (clickedBlock.classList.contains('protyle-wysiwyg--select')) {
            clickedBlock.classList.remove('protyle-wysiwyg--select');
            selectedBlocksMap.delete(blockId);
      
            // 立即检查是否为最后一个块,如果是则退出多选模式
            if (selectedBlocksMap.size === 0) {
                // 修改这里:直接清除所有选择并退出多选模式,不恢复初始状态
                document.querySelectorAll('.protyle-wysiwyg--select').forEach(el => {
                    el.classList.remove('protyle-wysiwyg--select');
                });
                selectedBlocksMap.clear();
                initialState = [];
                isMultiSelectMode = false;
                return false;
            }
        } else {
            // 添加选中样式前先移除任何文本选择
            window.getSelection().removeAllRanges();
      
            // 获取当前所有选中块,检查是否有不应该被选中的元素(如容器元素)
            document.querySelectorAll('.protyle-wysiwyg--select').forEach(el => {
                // 只保留有效的块元素选中状态,移除容器元素的选中状态
                if (!el.getAttribute('data-node-id') || isContainerElement(el)) {
                    el.classList.remove('protyle-wysiwyg--select');
                }
            });
      
            // 处理方向选择逻辑
            if (lastTouchedBlock && lastTouchedBlock !== clickedBlock) {
                // 获取上一个块和当前块的位置
                const lastRect = lastTouchedBlock.getBoundingClientRect();
                const currentRect = clickedBlock.getBoundingClientRect();
          
                // 判断选择方向
                const isUpward = currentRect.top < lastRect.top;
                const isDownward = currentRect.top > lastRect.top;
          
                // 获取所有块元素
                const allBlocks = Array.from(document.querySelectorAll('[data-node-id]'));
          
                // 如果是向上或向下选择,尝试选择中间的块
                if (isUpward || isDownward) {
                    // 找到上一个块和当前块在DOM中的索引
                    const lastIndex = allBlocks.indexOf(lastTouchedBlock);
                    const currentIndex = allBlocks.indexOf(clickedBlock);
              
                    if (lastIndex !== -1 && currentIndex !== -1) {
                        // 确定选择范围
                        const startIndex = Math.min(lastIndex, currentIndex);
                        const endIndex = Math.max(lastIndex, currentIndex);
                  
                        // 选择范围内的所有块
                        for (let i = startIndex; i <= endIndex; i++) {
                            const block = allBlocks[i];
                            if (block && block.getAttribute('data-node-id')) {
                                block.classList.add('protyle-wysiwyg--select');
                                selectedBlocksMap.set(block.getAttribute('data-node-id'), true);
                            }
                        }
                    }
                } else {
                    // 如果不是明确的上下方向,只选择当前块
                    clickedBlock.classList.add('protyle-wysiwyg--select');
                    selectedBlocksMap.set(blockId, true);
                }
            } else {
                // 如果没有上一个块或点击的是同一个块,只选择当前块
                clickedBlock.classList.add('protyle-wysiwyg--select');
                selectedBlocksMap.set(blockId, true);
            }
      
            // 记录最后触摸的块,用于方向选择判断
            lastTouchedBlock = clickedBlock;
        }
  
        // 调试信息
        console.log('多选模式点击事件触发', {
            isMultiSelectMode,
            clickedBlock,
            selectedClass: clickedBlock.classList.contains('protyle-wysiwyg--select'),
            allSelected: document.querySelectorAll('.protyle-wysiwyg--select').length,
            mapSize: selectedBlocksMap.size,
            mapContents: Array.from(selectedBlocksMap.keys())
        });
  
        // 确保事件不会继续传播
        return false;
    };
  
    // 移除双击处理函数
    const handleDblClick = (event) => {
        // 空函数,不再处理双击
    };
  
    // 为开发者工具的移动设备模拟提供鼠标事件处理器
    const handleMouseDown = (event) => {
        if (event.button !== 0) return;
        handleTouchStart({
            touches: [{clientX: event.clientX, clientY: event.clientY}],
            target: event.target
        });
    };
  
    const handleMouseMove = (event) => {
        if (!touchTimer && !isDragging) return;
        handleTouchMove({
            touches: [{clientX: event.clientX, clientY: event.clientY}],
            preventDefault: () => { event.preventDefault(); }
        });
    };
  
    const handleMouseUp = (event) => {
        handleTouchEnd({
            changedTouches: [{clientX: event.clientX, clientY: event.clientY}],
            preventDefault: () => { event.preventDefault(); }
        });
    };
  
    // 初始化触摸支持
    const initTouchSupport = () => {
        // 检查是否是触摸设备或开发者工具中的模拟设备
        if (!("ontouchstart" in window) && 
            !window.matchMedia("(pointer: coarse)").matches && 
            !navigator.userAgent.includes("Mobile")) {
            return;
        }
  
        // 等待编辑器加载完成
        const addEvents = () => {
            const protyles = document.querySelectorAll('.protyle-wysiwyg');
            if (protyles.length === 0) {
                setTimeout(addEvents, 1000);
                return;
            }
      
            // 添加事件监听
            protyles.forEach(protyle => {
                // 移除可能已存在的事件监听器,避免重复绑定
                protyle.removeEventListener('touchstart', handleTouchStart);
                protyle.removeEventListener('touchmove', handleTouchMove);
                protyle.removeEventListener('touchend', handleTouchEnd);
                protyle.removeEventListener('click', handleClick, true);
                protyle.removeEventListener('dblclick', handleDblClick);
                protyle.removeEventListener('contextmenu', preventContextMenu);
          
                // 重新添加事件监听器
                protyle.addEventListener('touchstart', handleTouchStart, {passive: true});
                protyle.addEventListener('touchmove', handleTouchMove, {passive: false});
                protyle.addEventListener('touchend', handleTouchEnd, {passive: false});
                protyle.addEventListener('click', handleClick, {capture: true, useCapture: true}); // 使用捕获模式确保先处理
                protyle.addEventListener('dblclick', handleDblClick, {capture: true}); // 使用捕获模式确保先处理
                protyle.addEventListener('contextmenu', preventContextMenu, {capture: true}); // 使用捕获模式确保先处理
          
                // 为开发者工具中的移动设备模拟添加鼠标事件
                if (navigator.userAgent.includes("Chrome") && window.matchMedia("(pointer: coarse)").matches) {
                    protyle.removeEventListener('mousedown', handleMouseDown);
                    protyle.removeEventListener('mousemove', handleMouseMove);
                    protyle.removeEventListener('mouseup', handleMouseUp);
              
                    protyle.addEventListener('mousedown', handleMouseDown);
                    protyle.addEventListener('mousemove', handleMouseMove);
                    protyle.addEventListener('mouseup', handleMouseUp);
                    protyle.addEventListener('contextmenu', preventContextMenu);
                }
            });
      
            // 全局添加右键菜单阻止,确保在拖拽时不会出现右键菜单
            document.removeEventListener('contextmenu', preventContextMenu);
            document.addEventListener('contextmenu', preventContextMenu, {capture: true});
      
            // 添加全局双击监听,确保能够退出多选模式
            document.removeEventListener('dblclick', handleDblClick);
            document.addEventListener('dblclick', handleDblClick, {capture: true});
      
            // 添加MutationObserver监视DOM变化,保护选择状态
            const selectionObserver = new MutationObserver(mutations => {
                mutations.forEach(mutation => {
                    if (mutation.type === 'attributes' && 
                        mutation.attributeName === 'class' && 
                        isMultiSelectMode) {
                        // 当class属性变化时,检查是否需要恢复选择状态
                        protectSelection();
                    }
                });
            });
      
            // 观察整个文档的class变化
            selectionObserver.observe(document.body, { 
                attributes: true, 
                attributeFilter: ['class'], 
                subtree: true 
            });
        };
  
        addEvents();
  
        // 监听新添加的编辑器元素
        const observer = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                if (mutation.addedNodes) {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === 1 && node.querySelector) {
                            const protyle = node.querySelector('.protyle-wysiwyg');
                            if (protyle) {
                                // 移除可能已存在的事件监听器
                                protyle.removeEventListener('touchstart', handleTouchStart);
                                protyle.removeEventListener('touchmove', handleTouchMove);
                                protyle.removeEventListener('touchend', handleTouchEnd);
                                protyle.removeEventListener('click', handleClick, true);
                                protyle.removeEventListener('dblclick', handleDblClick);
                                protyle.removeEventListener('contextmenu', preventContextMenu);
                          
                                // 重新添加事件监听器
                                protyle.addEventListener('touchstart', handleTouchStart, {passive: true});
                                protyle.addEventListener('touchmove', handleTouchMove, {passive: false});
                                protyle.addEventListener('touchend', handleTouchEnd, {passive: false});
                                protyle.addEventListener('click', handleClick, {capture: true, useCapture: true});
                                protyle.addEventListener('dblclick', handleDblClick, {capture: true});
                                protyle.addEventListener('contextmenu', preventContextMenu, {capture: true});
                          
                                if (navigator.userAgent.includes("Chrome") && window.matchMedia("(pointer: coarse)").matches) {
                                    protyle.removeEventListener('mousedown', handleMouseDown);
                                    protyle.removeEventListener('mousemove', handleMouseMove);
                                    protyle.removeEventListener('mouseup', handleMouseUp);
                              
                                    protyle.addEventListener('mousedown', handleMouseDown);
                                    protyle.addEventListener('mousemove', handleMouseMove);
                                    protyle.addEventListener('mouseup', handleMouseUp);
                                    protyle.addEventListener('contextmenu', preventContextMenu);
                                }
                            }
                        }
                    });
                }
            });
        });
  
        observer.observe(document.body, {childList: true, subtree: true});
    };
  
    // 页面加载完成后初始化
    setTimeout(initTouchSupport, 1000);

    // 处理拖拽结束/放置
    const handleDrop = (targetElement, clientX, clientY) => {
        if (!isDragging || !window.siyuan.dragElement) return;
  
        // 获取放置目标
        const dropTarget = getClosestBlock(targetElement);
        if (!dropTarget) {
            cleanupDrag();
            return;
        }
  
        // 获取拖拽数据
        const selectedBlock = document.querySelector('.protyle-wysiwyg--select');
        if (!selectedBlock) {
            cleanupDrag();
            return;
        }
  
        // 检查是否为容器块(列表项、引述块等)
        const isContainerBlock = selectedBlock.getAttribute('data-type') === 'NodeListItem' || 
                               selectedBlock.getAttribute('data-type') === 'NodeList' ||
                               selectedBlock.getAttribute('data-type') === 'NodeBlockquote' ||
                               selectedBlock.classList.contains('li') ||
                               selectedBlock.classList.contains('list') ||
                               selectedBlock.classList.contains('bq');
  
        // 检查放置的位置
        const targetRect = dropTarget.getBoundingClientRect();
        const isTop = clientY < targetRect.top + targetRect.height * 0.3;
        const isBottom = clientY > targetRect.bottom - targetRect.height * 0.3;
        const isLeft = clientX < targetRect.left + targetRect.width * 0.3;
        const isRight = clientX > targetRect.right - targetRect.width * 0.3;
  
        // 检查特殊情况:不允许列表项拖放到非列表的内部
        if (isContainerBlock && !(isTop || isBottom) && 
            dropTarget.getAttribute('data-type') !== 'NodeList' && 
            dropTarget.getAttribute('data-type') !== 'NodeListItem' &&
            !dropTarget.classList.contains('list') &&
            !dropTarget.classList.contains('li')) {
            console.log('容器块只能拖放到顶部或底部,不能拖放到非容器块内部');
            // 强制设置为顶部或底部拖放
            if (clientY < targetRect.top + targetRect.height * 0.5) {
                dropTarget.classList.remove('dragover', 'dragover__left', 'dragover__right');
                dropTarget.classList.add('dragover__top');
            } else {
                dropTarget.classList.remove('dragover', 'dragover__left', 'dragover__right');
                dropTarget.classList.add('dragover__bottom');
            }
        }
  
        // 检查是否尝试拖放到自身的左右侧边缘
        if ((isLeft || isRight) && dropTarget.getAttribute('data-node-id') === selectedBlock.getAttribute('data-node-id')) {
            console.log('不能拖放到自身的左右侧');
            cleanupDrag();
            return;
        }
  
        // 创建拖拽数据
        const dragData = createGutterDragData(selectedBlock);
  
        // 创建dataTransfer对象
        const dataTransfer = {
            items: [{type: dragData.type}],
            types: [dragData.type],
            getData: (type) => type === dragData.type ? dragData.html : ''
        };

        // 获取protyle元素 - 安全地获取,避免undefined错误
        const wysiwygElement = getProtyleWysiwyg();
        if (!wysiwygElement) {
            cleanupDrag();
            return;
        }

        // 创建自定义事件以触发原生处理函数
        const dropEvent = new CustomEvent('custom-drop', {
            bubbles: true,
            cancelable: true,
            detail: {
                clientX,
                clientY,
                dataTransfer,
                target: targetElement
            }
        });
  
        // 尝试调用思源的原生处理函数
        try {
            // 首先尝试使用直接的drop事件
            const realDropEvent = {
                preventDefault: () => {},
                stopPropagation: () => {},
                clientX,
                clientY,
                target: targetElement,
                dataTransfer
            };
      
            // 尝试手动模拟事件触发思源的原生拖放处理函数
            const dragoverEvent = document.createEvent('MouseEvents');
            dragoverEvent.initMouseEvent('dragover', true, true, window, 0, 
                0, 0, clientX, clientY, false, false, false, false, 0, null);
            dragoverEvent.dataTransfer = dataTransfer;
            targetElement.dispatchEvent(dragoverEvent);
      
            const dropNativeEvent = document.createEvent('MouseEvents');
            dropNativeEvent.initMouseEvent('drop', true, true, window, 0, 
                0, 0, clientX, clientY, false, false, false, false, 0, null);
            dropNativeEvent.dataTransfer = dataTransfer;
      
            try {
                targetElement.dispatchEvent(dropNativeEvent);
                wysiwygElement.dispatchEvent(dropEvent);
          
                // 放置后,设置一个短暂延时检查容器块是否保持其结构
                if (isContainerBlock) {
                    setTimeout(() => {
                        // 通过ID找到拖拽后的块
                        const droppedBlock = document.querySelector(`[data-node-id="${selectedBlock.getAttribute('data-node-id')}"]`);
                        if (droppedBlock) {
                            // 确保列表项仍然在列表中
                            if (selectedBlock.getAttribute('data-type') === 'NodeListItem' && 
                                droppedBlock.getAttribute('data-type') !== 'NodeListItem') {
                                console.log('列表项结构可能已损坏,尝试修复...');
                                // 这里只能通过界面提示用户,因为我们无法直接修改思源的内部数据结构
                                // 可以考虑引导用户使用撤销操作
                            }
                        }
                    }, 300);
                }
            } catch (e) {
                console.error("拖拽放置失败", e);
            }
        } catch (e) {
            console.error("拖拽处理失败", e);
        }
  
        // 清理拖拽状态
        cleanupDrag();
    };

    // 处理触摸开始
    const handleTouchStart = (event) => {
        if (event.touches.length !== 1) return;
  
        // 获取触摸位置
        touchStartX = event.touches[0].clientX;
        touchStartY = event.touches[0].clientY;
  
        // 获取触摸的元素
        const target = event.target;
  
        // 首先尝试直接从触摸点获取最具体的块
        let specificBlock = target.closest('[data-node-id]');
        if (specificBlock && specificBlock.getAttribute('data-node-id') && 
            !isContainerElement(specificBlock)) {
            selectedBlock = specificBlock;
        } else {
            // 处理嵌入块的情况
            const embedBlock = isInEmbedBlock(target);
            if (embedBlock) {
                // 如果触摸的是嵌入块内部,获取嵌入块的父块
                const embedParent = getEmbedBlockParent(embedBlock);
                if (embedParent && embedParent.getAttribute('data-node-id') && 
                    !isContainerElement(embedParent)) {
                    selectedBlock = embedParent;
                } else {
                    // 尝试从触摸点向上查找有效块
                    let validBlock = null;
                    let currentElement = target;
              
                    // 向上遍历DOM树,寻找第一个有效的块
                    while (currentElement && !validBlock) {
                        if (currentElement.getAttribute && 
                            currentElement.getAttribute('data-node-id') && 
                            !isContainerElement(currentElement)) {
                            validBlock = currentElement;
                            break;
                        }
                        currentElement = currentElement.parentElement;
                    }
              
                    selectedBlock = validBlock;
                }
            } else {
                // 使用改进后的getClosestBlock函数
                selectedBlock = getClosestBlock(target);
            }
        }
  
        // 最终安全检查,确保不选中容器元素
        if (selectedBlock && isContainerElement(selectedBlock)) {
            console.log('防止选中容器元素:', selectedBlock.className);
            selectedBlock = null;
        }
  
        lastTouchedBlock = selectedBlock; // 记录初始触摸的块
  
        // 重置长按状态
        isLongPressing = false;
        isMultiSelecting = false;
  
        // 确保在多选模式下不会选中其他内容
        if (isMultiSelectMode) {
            // 清除可能的文本选择
            if (window.getSelection) {
                window.getSelection().removeAllRanges();
            }
      
            // 检查是否有不应该被选中的元素(如容器元素)
            document.querySelectorAll('.protyle-wysiwyg--select').forEach(el => {
                // 只保留有效的块元素选中状态,移除容器元素的选中状态
                if (!el.getAttribute('data-node-id') || isContainerElement(el)) {
                    el.classList.remove('protyle-wysiwyg--select');
                }
            });
        }
  
        // 设置长按计时器
        clearTimeout(touchTimer);
        touchTimer = setTimeout(() => {
            isLongPressing = true; // 标记正在长按
      
            // 震动反馈
            if (navigator.vibrate) {
                navigator.vibrate(50);
            }
      
            // 无论是否在多选模式,长按都可以开始拖拽
            const hasSelectedBlocks = document.querySelectorAll('.protyle-wysiwyg--select').length > 0;
      
            if (hasSelectedBlocks) {
                // 如果有已选择的块,直接开始拖拽
                startDrag(selectedBlock, touchStartX, touchStartY);
            } else if (selectedBlock) {
                // 如果没有选中的块,选中当前块并开始拖拽
                selectedBlock.classList.add('protyle-wysiwyg--select');
                startDrag(selectedBlock, touchStartX, touchStartY);
            }
        }, LONG_PRESS_DURATION);
    };
})();
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    28446 引用 • 119768 回帖
  • 代码片段

    代码片段分为 CSS 与 JS 两种代码,添加在 [设置 - 外观 - 代码片段] 中,这些代码会在思源笔记加载时自动执行,用于改善笔记的样式或功能。

    用户在该标签下分享代码片段时需在帖子标题前添加 [css] [js] 用于区分代码片段类型。

    285 引用 • 1985 回帖
1 操作
ACai 在 2025-07-13 23:44:28 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...