|
|
@@ -104,6 +104,7 @@
|
|
|
height: `${menuPosition.endY - menuPosition.startY}px`,
|
|
|
border: '2px solid #165DFF',
|
|
|
zIndex: '9',
|
|
|
+ pointerEvents: 'none',
|
|
|
}"
|
|
|
></div>
|
|
|
<!-- 选中文本的工具栏 -->
|
|
|
@@ -272,6 +273,7 @@ export default {
|
|
|
curSelectId: '', // 当前选中组件id
|
|
|
|
|
|
isSelecting: false, // 是否开始框选内容
|
|
|
+ selectionRect: null, // 框选开始时缓存的课件位置(视口坐标),供整段框选复用
|
|
|
file_preview_url: getConfig() ? getConfig().doc_preview_service_address : '',
|
|
|
visible: false,
|
|
|
newpath: '',
|
|
|
@@ -1092,22 +1094,42 @@ export default {
|
|
|
});
|
|
|
}
|
|
|
},
|
|
|
+ /**
|
|
|
+ * 把鼠标事件的视口坐标换算成相对课件左上角的坐标,并裁剪到课件范围内
|
|
|
+ * @param {MouseEvent} event 鼠标事件
|
|
|
+ * @return {{ x: number, y: number }} 相对课件左上角、且已裁剪到课件盒子内的坐标
|
|
|
+ */
|
|
|
+ getPointInCourseware(event) {
|
|
|
+ // 整段框选复用 mousedown 时缓存的 rect:既避免 mousemove 反复触发同步重排,
|
|
|
+ // 也保证起止坐标基于同一份坐标系(反之若中途滚动/重排,落点会整体偏移)
|
|
|
+ const rect = this.selectionRect || this.$el.getBoundingClientRect();
|
|
|
+ const x = event.clientX - rect.left;
|
|
|
+ const y = event.clientY - rect.top;
|
|
|
+ // 边界校验:拖出课件的部分按课件边界处理,避免产生负数或超出课件的坐标
|
|
|
+ return {
|
|
|
+ x: Math.min(Math.max(x, 0), rect.width),
|
|
|
+ y: Math.min(Math.max(y, 0), rect.height),
|
|
|
+ };
|
|
|
+ },
|
|
|
startSelection(event) {
|
|
|
- if (this.canRemark) {
|
|
|
- this.isSelecting = true;
|
|
|
- let clientRect = document.getElementById(`selectable-area-preview`).getBoundingClientRect();
|
|
|
- this.menuPosition.startX = event.clientX - clientRect.left;
|
|
|
- this.menuPosition.startY = event.clientY - clientRect.top;
|
|
|
- this.menuPosition.endX = null;
|
|
|
- this.menuPosition.endY = null;
|
|
|
- }
|
|
|
+ if (!this.canRemark) return;
|
|
|
+
|
|
|
+ this.isSelecting = true;
|
|
|
+ const { left, top, width, height } = this.$el.getBoundingClientRect();
|
|
|
+ this.selectionRect = { left, top, width, height };
|
|
|
+
|
|
|
+ const { x, y } = this.getPointInCourseware(event);
|
|
|
+ this.menuPosition.startX = x;
|
|
|
+ this.menuPosition.startY = y;
|
|
|
+ this.menuPosition.endX = null;
|
|
|
+ this.menuPosition.endY = null;
|
|
|
},
|
|
|
updateSelection(event) {
|
|
|
if (!this.isSelecting || !this.canRemark) return;
|
|
|
- let clientRect = document.getElementById(`selectable-area-preview`).getBoundingClientRect();
|
|
|
|
|
|
- this.menuPosition.endX = event.clientX - clientRect.left;
|
|
|
- this.menuPosition.endY = event.clientY - clientRect.top;
|
|
|
+ const { x, y } = this.getPointInCourseware(event);
|
|
|
+ this.menuPosition.endX = x;
|
|
|
+ this.menuPosition.endY = y;
|
|
|
},
|
|
|
endSelection() {
|
|
|
this.isSelecting = false;
|
|
|
@@ -1153,20 +1175,63 @@ export default {
|
|
|
this.visible = true;
|
|
|
},
|
|
|
/**
|
|
|
+ * 判断元素是否为本课件的一级网格组件(.courseware > .row > .col > .grid[data-id])
|
|
|
+ * 用于把命中范围严格限制在当前课件内部,避免命中内嵌课件或页面上的其它课件
|
|
|
+ * @param {Element} element 待判断的元素
|
|
|
+ * @return {boolean} 是否为本课件的一级网格组件
|
|
|
+ */
|
|
|
+ isOwnGridElement(element) {
|
|
|
+ const courseware = this.$el;
|
|
|
+ if (!courseware || !element) return false;
|
|
|
+ // 边界校验 1:元素必须位于当前课件内部
|
|
|
+ if (!courseware.contains(element)) return false;
|
|
|
+ // 边界校验 2:必须是带 data-id 的网格组件
|
|
|
+ if (!element.classList.contains('grid') || !element.dataset.id) return false;
|
|
|
+ // 边界校验 3:网格所在的 .row 必须是课件根节点的直接子节点,
|
|
|
+ // 借此排除内嵌课件(如 MobileCoursewarePreview)里结构相同的网格
|
|
|
+ const row = element.closest('.row');
|
|
|
+ return Boolean(row && row.parentElement === courseware);
|
|
|
+ },
|
|
|
+ /**
|
|
|
* 根据相对 .courseware 元素左上角的坐标,获取命中的组件(.grid)元素
|
|
|
* @param {number} x 相对 .courseware 元素左上角的 x 坐标
|
|
|
* @param {number} y 相对 .courseware 元素左上角的 y 坐标
|
|
|
* @return {HTMLElement|null} 命中的 .grid 元素,如果没有找到则返回 null
|
|
|
*/
|
|
|
getGridElementByPoint(x, y) {
|
|
|
- const coursewareRect = this.$el.getBoundingClientRect();
|
|
|
- let el = document.elementFromPoint(coursewareRect.left + x, coursewareRect.top + y);
|
|
|
- // 向上查找,直到找到具有 data-id 属性和 grid 类的元素
|
|
|
- while (el && (!el.dataset.id || !el.classList.contains('grid'))) {
|
|
|
- el = el.parentElement;
|
|
|
+ const courseware = this.$el;
|
|
|
+ if (!courseware) return null;
|
|
|
+
|
|
|
+ // 边界校验:坐标必须是有效数字,且落在课件盒子内
|
|
|
+ // (溢出到课件左侧的行复选框、或框选拖出课件都会产生负值/越界值)
|
|
|
+ const coursewareRect = courseware.getBoundingClientRect();
|
|
|
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
|
+ if (x < 0 || y < 0 || x > coursewareRect.width || y > coursewareRect.height) return null;
|
|
|
+
|
|
|
+ const pointX = coursewareRect.left + x;
|
|
|
+ const pointY = coursewareRect.top + y;
|
|
|
+
|
|
|
+ // 用 elementsFromPoint(复数)拿到该点自顶向下的所有元素:
|
|
|
+ // 框选框、批注高亮层、工具栏等覆盖层会挡住 elementFromPoint(单数),
|
|
|
+ // 但它们在 elementsFromPoint 的结果里只是最上层的几项,跳过它们即可命中下方真正的组件
|
|
|
+ const stack =
|
|
|
+ typeof document.elementsFromPoint === 'function'
|
|
|
+ ? document.elementsFromPoint(pointX, pointY)
|
|
|
+ : [document.elementFromPoint(pointX, pointY)];
|
|
|
+
|
|
|
+ for (const element of stack) {
|
|
|
+ // 边界校验:只在该点属于当前课件内部时才开始上溯
|
|
|
+ if (!element || !courseware.contains(element)) continue;
|
|
|
+
|
|
|
+ // 自下而上查找,最多上溯到课件根节点为止,绝不越界到课件之外
|
|
|
+ let node = element;
|
|
|
+ while (node && node !== courseware) {
|
|
|
+ if (this.isOwnGridElement(node)) return node;
|
|
|
+ node = node.parentElement;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- return el || null;
|
|
|
+ return null;
|
|
|
},
|
|
|
/**
|
|
|
* 根据相对 .courseware 元素左上角的坐标获取组件id
|