Написание движка для интерактивного обучения

This commit is contained in:
brusnitsyn
2026-07-15 00:17:17 +09:00
parent 082f2b8b01
commit e48d38f3a2
18 changed files with 1160 additions and 352 deletions

View File

@@ -0,0 +1,125 @@
import { ref, watch, onBeforeUnmount, unref, nextTick } from "vue";
import {
computePosition,
autoUpdate,
offset,
flip,
shift
} from "@floating-ui/dom";
export function useFloating(target, floating, options = {}) {
const x = ref(0);
const y = ref(0);
const strategy = ref("fixed");
const placement = ref(
options.placement ?? "bottom"
);
let cleanup = null;
function resolveTarget() {
const value = unref(target);
if (!value)
return null;
if (typeof value === "function")
return value();
if (typeof value === "string")
return document.querySelector(value);
return value;
}
function resolveFloating() {
return unref(floating);
}
async function update() {
const reference = resolveTarget();
const popup = resolveFloating();
if (!reference || !popup)
return;
const result = await computePosition(
reference,
popup,
{
strategy: strategy.value,
placement: placement.value,
middleware: [
offset(
options.offset ?? 12
),
flip(),
shift({
padding: 8
})
]
}
)
x.value = result.x;
y.value = result.y;
}
function start() {
cleanup?.();
const reference = resolveTarget();
const popup = resolveFloating();
if (!reference || !popup)
return;
cleanup = autoUpdate(
reference,
popup,
update
);
update();
}
watch(
[
() => resolveTarget(),
() => resolveFloating()
],
() => {
start()
},
{
immediate:true
}
)
watch(
floating,
async value => {
if (!value)
return
await nextTick()
update()
}
)
onBeforeUnmount(() => {
cleanup?.();
});
return {
x,
y,
strategy,
update
};
}

View File

@@ -0,0 +1,138 @@
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
unref
} from "vue";
export function useHighlight(target, options = {}) {
const rect = ref(null);
const padding = options.padding ?? 8;
let resizeObserver = null;
function resolveTarget() {
const value = unref(target);
if (!value)
return null;
if (typeof value === "function")
return value();
if (typeof value === "string")
return document.querySelector(value);
return value;
}
function update() {
const element = resolveTarget();
if (!element) {
rect.value = null;
return;
}
const box = element.getBoundingClientRect();
rect.value = {
top: box.top - padding,
left: box.left - padding,
width: box.width + padding * 2,
height: box.height + padding * 2,
bottom:
box.bottom + padding,
right:
box.right + padding
};
}
function observe() {
const element = resolveTarget();
if (!element)
return;
resizeObserver?.disconnect();
resizeObserver = new ResizeObserver(() => {
update();
});
resizeObserver.observe(element);
}
const style = computed(() => {
if (!rect.value)
return {};
return {
top:
`${rect.value.top}px`,
left:
`${rect.value.left}px`,
width:
`${rect.value.width}px`,
height:
`${rect.value.height}px`
};
});
watch(
() => unref(target),
() => {
update();
observe();
},
{
immediate: true
}
);
onMounted(() => {
window.addEventListener(
"resize",
update
);
window.addEventListener(
"scroll",
update,
true
);
update();
observe();
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
window.removeEventListener(
"resize",
update
);
window.removeEventListener(
"scroll",
update,
true
);
});
return {
rect,
style,
update
};
}

View File

@@ -0,0 +1,61 @@
import { nextTick } from "vue"
export function useWaitTarget() {
async function waitTarget(target, options = {}) {
const {
timeout = 5000,
interval = 50,
visible = false
} = options
const start = Date.now()
while (Date.now() - start < timeout) {
await nextTick()
let element = null
if (typeof target === "function") {
element = target()
}
else {
element = document.querySelector(target)
}
if (element) {
if (visible) {
const rect = element.getBoundingClientRect()
if (
rect.width === 0 ||
rect.height === 0
) {
await sleep(interval)
continue
}
}
return element
}
await sleep(interval)
}
console.warn(
`[Tour] Target not found:`,
target
)
return null
}
return {
waitTarget
}
}
function sleep(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}