feat: add EditorGridSizePicker and EditorSelectionFrame components; implement FreeCreateView for article management; introduce ArticleSelectionOptimizeService for optimizing article selections
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type Component, type CSSProperties } from "vue";
|
||||
|
||||
type EditorActionMenuItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: Component;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
type EditorActionMenuGroup = {
|
||||
key: string;
|
||||
columns?: number;
|
||||
items: EditorActionMenuItem[];
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
x: number;
|
||||
y: number;
|
||||
groups: EditorActionMenuGroup[];
|
||||
variant?: "tile" | "inline";
|
||||
minWidth?: string;
|
||||
maxWidth?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [key: string];
|
||||
}>();
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
const frame = ref({
|
||||
x: props.x,
|
||||
y: props.y,
|
||||
});
|
||||
|
||||
const resolvedVariant = computed(() => props.variant ?? "inline");
|
||||
|
||||
const menuStyle = computed<CSSProperties>(() => {
|
||||
const style: CSSProperties = {
|
||||
left: `${frame.value.x}px`,
|
||||
top: `${frame.value.y}px`,
|
||||
};
|
||||
|
||||
if (props.minWidth) {
|
||||
style.minWidth = props.minWidth;
|
||||
}
|
||||
|
||||
if (props.maxWidth) {
|
||||
style.maxWidth = props.maxWidth;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function syncMenuFrame(): void {
|
||||
void nextTick(() => {
|
||||
const menu = menuRef.value;
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = 16;
|
||||
frame.value = {
|
||||
x: clamp(props.x, offset, Math.max(offset, window.innerWidth - menu.offsetWidth - offset)),
|
||||
y: clamp(props.y, offset, Math.max(offset, window.innerHeight - menu.offsetHeight - offset)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleWindowResize(): void {
|
||||
syncMenuFrame();
|
||||
}
|
||||
|
||||
function handleSelect(item: EditorActionMenuItem): void {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("select", item.key);
|
||||
}
|
||||
|
||||
function resolveGroupStyle(group: EditorActionMenuGroup): CSSProperties {
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${Math.max(group.columns ?? group.items.length, 1)}, minmax(0, 1fr))`,
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
x: props.x,
|
||||
y: props.y,
|
||||
groups: props.groups,
|
||||
minWidth: props.minWidth,
|
||||
maxWidth: props.maxWidth,
|
||||
variant: resolvedVariant.value,
|
||||
}),
|
||||
() => {
|
||||
syncMenuFrame();
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
flush: "post",
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
syncMenuFrame();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleWindowResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="editor-action-menu"
|
||||
:class="`editor-action-menu--${resolvedVariant}`"
|
||||
:style="menuStyle"
|
||||
@pointerdown.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-for="(group, groupIndex) in groups" :key="group.key">
|
||||
<div class="editor-action-menu__group" :style="resolveGroupStyle(group)">
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="editor-action-menu__action"
|
||||
:class="{
|
||||
'editor-action-menu__action--active': item.active,
|
||||
'editor-action-menu__action--danger': item.danger,
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="groupIndex < groups.length - 1" class="editor-action-menu__divider"></div>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-action-menu {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-action-menu--tile {
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid #edf2fa;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
0 20px 48px rgba(15, 23, 42, 0.08),
|
||||
0 6px 16px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.editor-action-menu--inline {
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e8eef8;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 251, 255, 0.98) 100%);
|
||||
box-shadow:
|
||||
0 20px 48px rgba(15, 23, 42, 0.1),
|
||||
0 6px 18px rgba(15, 23, 42, 0.06);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.editor-action-menu__group {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-action-menu--inline .editor-action-menu__group {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-action-menu__divider {
|
||||
height: 1px;
|
||||
background: #f0f3f8;
|
||||
}
|
||||
|
||||
.editor-action-menu__action {
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.editor-action-menu__action span {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-action-menu__action :deep(svg) {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.editor-action-menu--tile .editor-action-menu__action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 86px;
|
||||
padding: 12px 10px;
|
||||
border-radius: 12px;
|
||||
background: #f7f9fa;
|
||||
color: #3f4a5c;
|
||||
}
|
||||
|
||||
.editor-action-menu--tile .editor-action-menu__action span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor-action-menu--tile .editor-action-menu__action :deep(svg) {
|
||||
font-size: 22px;
|
||||
color: #5c6b81;
|
||||
}
|
||||
|
||||
.editor-action-menu--tile .editor-action-menu__action:hover:not(:disabled) {
|
||||
background: #eff4ff;
|
||||
border-color: #c2d6ff;
|
||||
color: #245bdb;
|
||||
}
|
||||
|
||||
.editor-action-menu--tile .editor-action-menu__action:hover:not(:disabled) :deep(svg) {
|
||||
color: #245bdb;
|
||||
}
|
||||
|
||||
.editor-action-menu--inline .editor-action-menu__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
background: #f7f9fc;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-action-menu--inline .editor-action-menu__action :deep(svg) {
|
||||
font-size: 18px;
|
||||
color: #52607a;
|
||||
}
|
||||
|
||||
.editor-action-menu--inline .editor-action-menu__action:hover:not(:disabled),
|
||||
.editor-action-menu--inline .editor-action-menu__action--active {
|
||||
border-color: #c8d8ff;
|
||||
background: #edf4ff;
|
||||
color: #245bdb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.editor-action-menu--inline .editor-action-menu__action:hover:not(:disabled) :deep(svg),
|
||||
.editor-action-menu--inline .editor-action-menu__action--active :deep(svg) {
|
||||
color: #245bdb;
|
||||
}
|
||||
|
||||
.editor-action-menu__action--danger:hover:not(:disabled) {
|
||||
border-color: #ffd2cf;
|
||||
background: #fff1f0;
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.editor-action-menu__action--danger:hover:not(:disabled) :deep(svg) {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.editor-action-menu--tile .editor-action-menu__action:disabled {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.editor-action-menu__action:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user