lumina-slides 8.9.5 → 9.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LUMINA_LLM_EXAMPLES.json +234 -0
- package/README.md +18 -18
- package/dist/lumina-slides.js +13153 -12619
- package/dist/lumina-slides.umd.cjs +217 -217
- package/dist/style.css +1 -1
- package/package.json +5 -4
- package/src/App.vue +16 -0
- package/src/animation/index.ts +11 -0
- package/src/animation/registry.ts +126 -0
- package/src/animation/stagger.ts +95 -0
- package/src/animation/types.ts +53 -0
- package/src/components/LandingPage.vue +229 -0
- package/src/components/LuminaDeck.vue +224 -0
- package/src/components/LuminaSpeakerNotes.vue +701 -0
- package/src/components/base/BaseSlide.vue +122 -0
- package/src/components/base/LuminaElement.vue +67 -0
- package/src/components/base/VideoPlayer.vue +204 -0
- package/src/components/layouts/LayoutAuto.vue +71 -0
- package/src/components/layouts/LayoutChart.vue +287 -0
- package/src/components/layouts/LayoutCustom.vue +92 -0
- package/src/components/layouts/LayoutDiagram.vue +253 -0
- package/src/components/layouts/LayoutFeatures.vue +121 -0
- package/src/components/layouts/LayoutFlex.vue +172 -0
- package/src/components/layouts/LayoutFree.vue +62 -0
- package/src/components/layouts/LayoutHalf.vue +127 -0
- package/src/components/layouts/LayoutStatement.vue +74 -0
- package/src/components/layouts/LayoutSteps.vue +106 -0
- package/src/components/layouts/LayoutTimeline.vue +104 -0
- package/src/components/layouts/LayoutVideo.vue +41 -0
- package/src/components/parts/FlexBullets.vue +45 -0
- package/src/components/parts/FlexButton.vue +132 -0
- package/src/components/parts/FlexImage.vue +54 -0
- package/src/components/parts/FlexOrdered.vue +44 -0
- package/src/components/parts/FlexSpacer.vue +13 -0
- package/src/components/parts/FlexStepper.vue +59 -0
- package/src/components/parts/FlexText.vue +29 -0
- package/src/components/parts/FlexTimeline.vue +67 -0
- package/src/components/parts/FlexTitle.vue +39 -0
- package/src/components/parts/LuminaBackground.vue +100 -0
- package/src/components/site/LivePreview.vue +101 -0
- package/src/components/site/SiteApi.vue +301 -0
- package/src/components/site/SiteDashboard.vue +604 -0
- package/src/components/site/SiteDocs.vue +3273 -0
- package/src/components/site/SiteExamples.vue +65 -0
- package/src/components/site/SiteFooter.vue +6 -0
- package/src/components/site/SiteHome.vue +362 -0
- package/src/components/site/SiteNavBar.vue +122 -0
- package/src/components/site/SitePlayground.vue +389 -0
- package/src/components/site/SitePromptBuilder.vue +266 -0
- package/src/components/site/SiteUserMenu.vue +90 -0
- package/src/components/studio/ActionEditor.vue +108 -0
- package/src/components/studio/ArrayEditor.vue +124 -0
- package/src/components/studio/CollapsibleSection.vue +33 -0
- package/src/components/studio/ColorField.vue +22 -0
- package/src/components/studio/EditorCanvas.vue +326 -0
- package/src/components/studio/EditorLayoutFeatures.vue +18 -0
- package/src/components/studio/EditorLayoutFixed.vue +46 -0
- package/src/components/studio/EditorLayoutFlex.vue +133 -0
- package/src/components/studio/EditorLayoutHalf.vue +18 -0
- package/src/components/studio/EditorLayoutStatement.vue +18 -0
- package/src/components/studio/EditorLayoutSteps.vue +18 -0
- package/src/components/studio/EditorLayoutTimeline.vue +18 -0
- package/src/components/studio/EditorNode.vue +89 -0
- package/src/components/studio/FieldEditor.vue +133 -0
- package/src/components/studio/IconPicker.vue +109 -0
- package/src/components/studio/LayerItem.vue +117 -0
- package/src/components/studio/LuminaStudio.vue +30 -0
- package/src/components/studio/SaveSuccessModal.vue +138 -0
- package/src/components/studio/SlideNavigator.vue +373 -0
- package/src/components/studio/SliderField.vue +44 -0
- package/src/components/studio/StudioInspector.vue +595 -0
- package/src/components/studio/StudioJsonEditor.vue +191 -0
- package/src/components/studio/StudioLayers.vue +145 -0
- package/src/components/studio/StudioSettings.vue +514 -0
- package/src/components/studio/StudioSidebar.vue +29 -0
- package/src/components/studio/StudioToolbar.vue +222 -0
- package/src/components/studio/fieldLabels.ts +224 -0
- package/src/components/studio/inspectors/DiagramEdgeEditor.vue +77 -0
- package/src/components/studio/inspectors/DiagramNodeEditor.vue +117 -0
- package/src/components/studio/nodes/StudioDiagramNode.vue +138 -0
- package/src/composables/useAuth.ts +87 -0
- package/src/composables/useEditor.ts +224 -0
- package/src/composables/useElementState.ts +81 -0
- package/src/composables/useFlexLayout.ts +122 -0
- package/src/composables/useKeyboard.ts +45 -0
- package/src/composables/useLumina.ts +32 -0
- package/src/composables/useStudio.ts +87 -0
- package/src/composables/useSwipeNav.ts +53 -0
- package/src/composables/useTransition.ts +373 -0
- package/src/core/Lumina.ts +819 -0
- package/src/core/animationConfig.ts +251 -0
- package/src/core/compression.ts +34 -0
- package/src/core/elementController.ts +170 -0
- package/src/core/elementId.ts +27 -0
- package/src/core/elementResolver.ts +207 -0
- package/src/core/events.ts +53 -0
- package/src/core/fonts.ts +100 -0
- package/src/core/presets.ts +231 -0
- package/src/core/prompts.ts +272 -0
- package/src/core/schema.ts +478 -0
- package/src/core/speaker-channel.ts +250 -0
- package/src/core/store.ts +465 -0
- package/src/core/theme.ts +666 -0
- package/src/core/types.ts +1615 -0
- package/src/directives/vStudio.ts +45 -0
- package/src/index.ts +175 -0
- package/src/main.ts +17 -0
- package/src/router/index.ts +92 -0
- package/src/style/main.css +462 -0
- package/src/utils/deep.ts +127 -0
- package/src/utils/firebase.ts +184 -0
- package/src/utils/streaming.ts +134 -0
- package/src/views/DashboardView.vue +32 -0
- package/src/views/DeckView.vue +289 -0
- package/src/views/HomeView.vue +17 -0
- package/src/views/SiteLayout.vue +21 -0
- package/src/views/StudioView.vue +61 -0
- package/src/vite-env.d.ts +6 -0
- package/IMPLEMENTATION.md +0 -418
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Single root element required for Vue attribute inheritance -->
|
|
3
|
+
<div class="studio-diagram-node" :data-lumina-id="elementId || undefined">
|
|
4
|
+
<NodeResizer v-if="editable && selected" :min-width="80" :min-height="40" />
|
|
5
|
+
|
|
6
|
+
<!-- Image Node -->
|
|
7
|
+
<div v-if="isImageNode" class="node-content node-image" :style="imageContainerStyle">
|
|
8
|
+
<img v-if="data?.imageUrl" :src="data.imageUrl" alt="" class="node-img" />
|
|
9
|
+
<div v-else class="placeholder">
|
|
10
|
+
<i class="ph-thin ph-image text-2xl text-gray-400"></i>
|
|
11
|
+
<span class="text-xs text-gray-400">No Image</span>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<!-- Regular Node -->
|
|
16
|
+
<div v-else class="node-content" :style="contentStyle">
|
|
17
|
+
{{ displayLabel || data?.label || 'Node' }}
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<!-- Handles only visible in edit mode -->
|
|
21
|
+
<template v-if="editable">
|
|
22
|
+
<Handle type="target" :position="Position.Top" class="node-handle" />
|
|
23
|
+
<Handle type="source" :position="Position.Bottom" class="node-handle" />
|
|
24
|
+
<Handle type="target" :position="Position.Left" class="node-handle" />
|
|
25
|
+
<Handle type="source" :position="Position.Right" class="node-handle" />
|
|
26
|
+
</template>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup lang="ts">
|
|
31
|
+
import { computed, type VNode, type Component } from 'vue';
|
|
32
|
+
import { Handle, Position } from '@vue-flow/core';
|
|
33
|
+
import { NodeResizer } from '@vue-flow/node-resizer';
|
|
34
|
+
import '@vue-flow/node-resizer/dist/style.css';
|
|
35
|
+
|
|
36
|
+
const props = defineProps<{
|
|
37
|
+
id: string;
|
|
38
|
+
data: any;
|
|
39
|
+
selected?: boolean;
|
|
40
|
+
// VueFlow can pass label as string, object, VNode, or Component
|
|
41
|
+
label?: string | object | VNode | Component;
|
|
42
|
+
type?: string;
|
|
43
|
+
style?: Record<string, any>;
|
|
44
|
+
editable?: boolean;
|
|
45
|
+
/** Element id for engine.element(id) control; applied as data-lumina-id on the root. */
|
|
46
|
+
elementId?: string;
|
|
47
|
+
}>();
|
|
48
|
+
|
|
49
|
+
// Extract a displayable string from the label prop
|
|
50
|
+
const displayLabel = computed(() => {
|
|
51
|
+
if (!props.label) return null;
|
|
52
|
+
if (typeof props.label === 'string') return props.label;
|
|
53
|
+
// For objects/VNodes/Components, try to extract text or return null
|
|
54
|
+
if (typeof props.label === 'object' && 'toString' in props.label) {
|
|
55
|
+
const str = props.label.toString();
|
|
56
|
+
// Avoid returning "[object Object]"
|
|
57
|
+
if (str !== '[object Object]') return str;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Check if this is an image node
|
|
63
|
+
const isImageNode = computed(() => props.data?.type === 'image' || props.data?.shape === 'image');
|
|
64
|
+
|
|
65
|
+
// Shape is stored in data.shape for styling
|
|
66
|
+
const shape = computed(() => props.data?.shape || 'rectangle');
|
|
67
|
+
|
|
68
|
+
const contentStyle = computed(() => ({
|
|
69
|
+
backgroundColor: props.style?.backgroundColor || props.data?.style?.backgroundColor || '#ffffff',
|
|
70
|
+
color: props.style?.color || props.data?.style?.color || '#000000',
|
|
71
|
+
borderRadius: shape.value === 'rounded' ? '12px' : '4px',
|
|
72
|
+
border: props.editable && props.selected ? '2px solid #3b82f6' : '1px solid #ccc',
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const imageContainerStyle = computed(() => ({
|
|
76
|
+
borderRadius: '4px',
|
|
77
|
+
border: props.editable && props.selected ? '2px solid #3b82f6' : 'none',
|
|
78
|
+
overflow: 'hidden',
|
|
79
|
+
}));
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<style scoped>
|
|
83
|
+
.studio-diagram-node {
|
|
84
|
+
width: 100%;
|
|
85
|
+
height: 100%;
|
|
86
|
+
position: relative;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.node-content {
|
|
90
|
+
width: 100%;
|
|
91
|
+
height: 100%;
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
justify-content: center;
|
|
95
|
+
padding: 8px 16px;
|
|
96
|
+
box-sizing: border-box;
|
|
97
|
+
text-align: center;
|
|
98
|
+
font-size: 12px;
|
|
99
|
+
word-break: break-word;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.node-image {
|
|
103
|
+
padding: 0;
|
|
104
|
+
background: transparent;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.node-img {
|
|
108
|
+
width: 100%;
|
|
109
|
+
height: 100%;
|
|
110
|
+
object-fit: cover;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.placeholder {
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
align-items: center;
|
|
117
|
+
justify-content: center;
|
|
118
|
+
width: 100%;
|
|
119
|
+
height: 100%;
|
|
120
|
+
min-height: 60px;
|
|
121
|
+
background: rgba(255, 255, 255, 0.1);
|
|
122
|
+
border: 1px dashed rgba(255, 255, 255, 0.3);
|
|
123
|
+
border-radius: 4px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.node-handle {
|
|
127
|
+
width: 10px;
|
|
128
|
+
height: 10px;
|
|
129
|
+
background-color: rgba(59, 130, 246, 0.6);
|
|
130
|
+
border: 2px solid white;
|
|
131
|
+
border-radius: 50%;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.node-handle:hover {
|
|
135
|
+
background-color: #3b82f6;
|
|
136
|
+
transform: scale(1.2);
|
|
137
|
+
}
|
|
138
|
+
</style>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ref, computed } from 'vue';
|
|
2
|
+
import {
|
|
3
|
+
signInWithPopup,
|
|
4
|
+
signOut as firebaseSignOut,
|
|
5
|
+
onAuthStateChanged,
|
|
6
|
+
User,
|
|
7
|
+
AuthError
|
|
8
|
+
} from 'firebase/auth';
|
|
9
|
+
import { getFirebaseAuth, getAuthProvider } from '../utils/firebase';
|
|
10
|
+
|
|
11
|
+
// Global state (Singleton pattern for composable)
|
|
12
|
+
const currentUser = ref<User | null>(null);
|
|
13
|
+
const isLoading = ref(true);
|
|
14
|
+
let isListenerAttached = false;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Composable for Firebase Authentication.
|
|
18
|
+
* Provides reactive user state and login/logout methods.
|
|
19
|
+
*/
|
|
20
|
+
export function useAuth() {
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Helper to safely get auth instance or throw.
|
|
24
|
+
*/
|
|
25
|
+
const ensureAuth = () => {
|
|
26
|
+
try {
|
|
27
|
+
return { auth: getFirebaseAuth(), provider: getAuthProvider() };
|
|
28
|
+
} catch (e) {
|
|
29
|
+
throw new Error("Authentication service is not ready. Ensure initFirebase() is called in main.ts");
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const loginWithGoogle = async (): Promise<User> => {
|
|
34
|
+
const { auth, provider } = ensureAuth();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const result = await signInWithPopup(auth, provider);
|
|
38
|
+
currentUser.value = result.user;
|
|
39
|
+
return result.user;
|
|
40
|
+
} catch (error: unknown) {
|
|
41
|
+
const authError = error as AuthError;
|
|
42
|
+
console.error(`[Auth] Login failed: ${authError.code} - ${authError.message}`);
|
|
43
|
+
throw authError; // Propagate for UI handling
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const logout = async (): Promise<void> => {
|
|
48
|
+
try {
|
|
49
|
+
const auth = getFirebaseAuth(); // Can just get auth, provider not needed
|
|
50
|
+
await firebaseSignOut(auth);
|
|
51
|
+
currentUser.value = null;
|
|
52
|
+
} catch (error: unknown) {
|
|
53
|
+
console.error("[Auth] Logout failed", error);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Initialize the Auth listener.
|
|
60
|
+
* Idempotent: calling it multiple times is safe.
|
|
61
|
+
*/
|
|
62
|
+
const initAuthListener = (): void => {
|
|
63
|
+
if (isListenerAttached) return;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const auth = getFirebaseAuth();
|
|
67
|
+
onAuthStateChanged(auth, (user) => {
|
|
68
|
+
currentUser.value = user;
|
|
69
|
+
isLoading.value = false;
|
|
70
|
+
});
|
|
71
|
+
isListenerAttached = true;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// If firebase isn't ready yet, this might fail.
|
|
74
|
+
// In a strict app, this shouldn't happen if main.ts inits first.
|
|
75
|
+
console.warn("[Auth] Failed to attach listener:", e);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
user: computed(() => currentUser.value),
|
|
81
|
+
isAuthenticated: computed(() => !!currentUser.value),
|
|
82
|
+
isLoading: computed(() => isLoading.value),
|
|
83
|
+
loginWithGoogle,
|
|
84
|
+
logout,
|
|
85
|
+
initAuthListener
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { ref, computed, inject, InjectionKey, watch, onMounted, onUnmounted } from 'vue';
|
|
2
|
+
import { StoreKey, type LuminaStore } from '../core/store';
|
|
3
|
+
import { getByPath } from '../utils/deep';
|
|
4
|
+
|
|
5
|
+
interface HistorySnapshot {
|
|
6
|
+
deck: any;
|
|
7
|
+
currentIndex: number;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const EditorKey: InjectionKey<ReturnType<typeof createEditor>> = Symbol('EditorKey');
|
|
12
|
+
|
|
13
|
+
export function createEditor(manualStore?: LuminaStore) {
|
|
14
|
+
const store = manualStore || inject(StoreKey);
|
|
15
|
+
if (!store) {
|
|
16
|
+
throw new Error("Lumina Editor requires Lumina Store.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// --- State ---
|
|
20
|
+
const selection = ref<string | null>(null);
|
|
21
|
+
const clipboard = ref<any>(null);
|
|
22
|
+
const history = ref<HistorySnapshot[]>([]);
|
|
23
|
+
const historyIndex = ref(-1);
|
|
24
|
+
|
|
25
|
+
// --- Selection ---
|
|
26
|
+
const select = (path: string | null) => {
|
|
27
|
+
selection.value = path;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const getSelectedData = () => {
|
|
31
|
+
if (!selection.value || !store.state.deck) return null;
|
|
32
|
+
return getByPath(store.state.deck, selection.value);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// --- History Management ---
|
|
36
|
+
let debounceTimer: any = null;
|
|
37
|
+
const isUndoRedoOp = ref(false); // Internal flag to prevent history loops
|
|
38
|
+
|
|
39
|
+
const commit = (immediate = false) => {
|
|
40
|
+
if (isUndoRedoOp.value) return;
|
|
41
|
+
|
|
42
|
+
// Clear any pending debounce
|
|
43
|
+
if (debounceTimer) {
|
|
44
|
+
clearTimeout(debounceTimer);
|
|
45
|
+
debounceTimer = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pushToHistory = () => {
|
|
49
|
+
// Remove future history if we were in the middle of the stack
|
|
50
|
+
if (historyIndex.value < history.value.length - 1) {
|
|
51
|
+
history.value = history.value.slice(0, historyIndex.value + 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (store.state.deck) {
|
|
55
|
+
// Deduplicate: Don't push if identical to current head
|
|
56
|
+
const currentHead = history.value[historyIndex.value];
|
|
57
|
+
const newSnapshot = JSON.parse(JSON.stringify(store.state.deck));
|
|
58
|
+
const currentIndex = store.state.currentIndex;
|
|
59
|
+
|
|
60
|
+
if (currentHead &&
|
|
61
|
+
JSON.stringify(currentHead.deck) === JSON.stringify(newSnapshot) &&
|
|
62
|
+
currentHead.currentIndex === currentIndex
|
|
63
|
+
) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
history.value.push({
|
|
68
|
+
deck: newSnapshot,
|
|
69
|
+
currentIndex: currentIndex,
|
|
70
|
+
timestamp: Date.now()
|
|
71
|
+
});
|
|
72
|
+
historyIndex.value++;
|
|
73
|
+
|
|
74
|
+
// Limit history size
|
|
75
|
+
if (history.value.length > 50) {
|
|
76
|
+
history.value.shift();
|
|
77
|
+
historyIndex.value--;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (immediate) {
|
|
83
|
+
pushToHistory();
|
|
84
|
+
} else {
|
|
85
|
+
// Debounce for text typing etc.
|
|
86
|
+
debounceTimer = setTimeout(pushToHistory, 500);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Watch for ANY change in the deck
|
|
91
|
+
watch(
|
|
92
|
+
() => store.state.deck,
|
|
93
|
+
(newVal) => {
|
|
94
|
+
if (newVal && !isUndoRedoOp.value) {
|
|
95
|
+
commit(false); // Debounced by default
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{ deep: true }
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Also watch index changes so navigation is undoable
|
|
102
|
+
watch(
|
|
103
|
+
() => store.state.currentIndex,
|
|
104
|
+
() => {
|
|
105
|
+
if (!isUndoRedoOp.value) {
|
|
106
|
+
commit(false);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const undo = () => {
|
|
112
|
+
// Force commit any pending changes (e.g. from debounce)
|
|
113
|
+
// so we can undo from the current state
|
|
114
|
+
commit(true);
|
|
115
|
+
|
|
116
|
+
if (historyIndex.value > 0) {
|
|
117
|
+
isUndoRedoOp.value = true;
|
|
118
|
+
historyIndex.value--;
|
|
119
|
+
const snapshot = history.value[historyIndex.value];
|
|
120
|
+
if (snapshot) {
|
|
121
|
+
store.loadDeck(JSON.parse(JSON.stringify(snapshot.deck)));
|
|
122
|
+
store.goto(snapshot.currentIndex); // Restore slide position
|
|
123
|
+
}
|
|
124
|
+
// Wait for Vue reactivity to settle before re-enabling watcher
|
|
125
|
+
setTimeout(() => { isUndoRedoOp.value = false; }, 100);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const redo = () => {
|
|
130
|
+
// Force commit shouldn't be necessary for redo usually (as we are going forward),
|
|
131
|
+
// but for consistency if we made a change then decided to redo??
|
|
132
|
+
// Actually if we make a change, history is sliced, so we can't redo.
|
|
133
|
+
// So redo checks are safe.
|
|
134
|
+
// But let's keep logic simple.
|
|
135
|
+
|
|
136
|
+
if (historyIndex.value < history.value.length - 1) {
|
|
137
|
+
isUndoRedoOp.value = true;
|
|
138
|
+
historyIndex.value++;
|
|
139
|
+
const snapshot = history.value[historyIndex.value];
|
|
140
|
+
if (snapshot) {
|
|
141
|
+
store.loadDeck(JSON.parse(JSON.stringify(snapshot.deck)));
|
|
142
|
+
store.goto(snapshot.currentIndex); // Restore slide position
|
|
143
|
+
}
|
|
144
|
+
setTimeout(() => { isUndoRedoOp.value = false; }, 100);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// --- Keyboard Shortcuts ---
|
|
149
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
150
|
+
// Only trigger if no input/textarea is focused (unless it's just a general command)
|
|
151
|
+
// Actually, standard undo/redo SHOULD work in inputs too, but browser handles that natively for text.
|
|
152
|
+
// We want app-level undo.
|
|
153
|
+
|
|
154
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
|
155
|
+
if (e.shiftKey) {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
redo();
|
|
158
|
+
} else {
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
undo();
|
|
161
|
+
}
|
|
162
|
+
} else if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
redo();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
onMounted(() => {
|
|
169
|
+
window.addEventListener('keydown', handleKeydown);
|
|
170
|
+
// Initial commit
|
|
171
|
+
if (store.state.deck && history.value.length === 0) {
|
|
172
|
+
commit(true);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
onUnmounted(() => {
|
|
177
|
+
window.removeEventListener('keydown', handleKeydown);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// --- Clipboard ---
|
|
181
|
+
const copy = () => {
|
|
182
|
+
if (!selection.value || !store.state.deck) return;
|
|
183
|
+
const data = getByPath(store.state.deck, selection.value);
|
|
184
|
+
if (data) {
|
|
185
|
+
clipboard.value = JSON.parse(JSON.stringify(data));
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const paste = (targetArrayPath: string) => {
|
|
190
|
+
if (!clipboard.value) return;
|
|
191
|
+
store.addNode(targetArrayPath, JSON.parse(JSON.stringify(clipboard.value)));
|
|
192
|
+
// Watcher will catch this
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// --- Update Helpers ---
|
|
196
|
+
const updateSelected = (key: string, value: any) => {
|
|
197
|
+
if (!selection.value) return;
|
|
198
|
+
store.updateNode(`${selection.value}.${key}`, value);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
selection,
|
|
203
|
+
clipboard,
|
|
204
|
+
store,
|
|
205
|
+
canUndo: computed(() => historyIndex.value > 0),
|
|
206
|
+
canRedo: computed(() => historyIndex.value < history.value.length - 1),
|
|
207
|
+
select,
|
|
208
|
+
getSelectedData,
|
|
209
|
+
updateSelected,
|
|
210
|
+
commit, // Exposed but mostly internal now
|
|
211
|
+
undo,
|
|
212
|
+
redo,
|
|
213
|
+
copy,
|
|
214
|
+
paste
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function useEditor() {
|
|
219
|
+
const editor = inject(EditorKey);
|
|
220
|
+
if (!editor) {
|
|
221
|
+
throw new Error("useEditor must be used within LuminaStudio");
|
|
222
|
+
}
|
|
223
|
+
return editor;
|
|
224
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { computed, inject, type MaybeRefOrGetter, toValue } from 'vue';
|
|
2
|
+
import { StoreKey } from '../core/store';
|
|
3
|
+
import type { ElementState } from '../core/types';
|
|
4
|
+
|
|
5
|
+
/** @internal */
|
|
6
|
+
type CRef<T> = import('vue').ComputedRef<T>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Return type of {@link useElementState}. Composable bindings for visibility, style, and class
|
|
10
|
+
* derived from the engine’s element state (written by engine.element(id).show/hide/opacity/etc.).
|
|
11
|
+
*/
|
|
12
|
+
export interface UseElementStateReturn {
|
|
13
|
+
/** false when engine.element(id).hide() was called; true otherwise. Default true. */
|
|
14
|
+
visible: CRef<boolean>;
|
|
15
|
+
/** Combined style: opacity, transform, and any style from the store. Apply as :style="state.style". */
|
|
16
|
+
style: CRef<Record<string, string | number>>;
|
|
17
|
+
/** Extra CSS classes from the store. Merge with component classes. */
|
|
18
|
+
class: CRef<string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Composable that subscribes to the engine’s element state for a given id. Use in custom
|
|
23
|
+
* layouts when you need to apply visibility/style/class yourself instead of using
|
|
24
|
+
* LuminaElement. Requires Lumina app context (StoreKey). If store is missing, returns
|
|
25
|
+
* safe defaults (visible true, empty style and class).
|
|
26
|
+
*
|
|
27
|
+
* @param id - Element id: string, Ref, or getter. From resolveId, elemId, or explicit id.
|
|
28
|
+
* @returns {UseElementStateReturn} visible, style, class. Bind :style and :class. When visible is
|
|
29
|
+
* false, style includes opacity: 0 so the element keeps its layout space (no v-show/display:none).
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* const id = computed(() => resolveId(props.data, props.slideIndex, ['tag']));
|
|
33
|
+
* const state = useElementState(id);
|
|
34
|
+
* // In template: <span :style="state.style" :class="state.class">...</span>
|
|
35
|
+
*
|
|
36
|
+
* @see LuminaElement
|
|
37
|
+
* @see resolveId
|
|
38
|
+
* @see ElementState
|
|
39
|
+
*/
|
|
40
|
+
export function useElementState(id: MaybeRefOrGetter<string>): UseElementStateReturn {
|
|
41
|
+
const store = inject(StoreKey);
|
|
42
|
+
if (!store) {
|
|
43
|
+
return {
|
|
44
|
+
visible: computed(() => true),
|
|
45
|
+
style: computed(() => ({})),
|
|
46
|
+
class: computed(() => '')
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const visible = computed(() => {
|
|
51
|
+
const resolved = toValue(id);
|
|
52
|
+
if (!resolved) return true;
|
|
53
|
+
const s = store.state.elementState[resolved];
|
|
54
|
+
return s?.visible !== false;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const style = computed(() => {
|
|
58
|
+
const resolved = toValue(id);
|
|
59
|
+
if (!resolved) return {};
|
|
60
|
+
const s = store.state.elementState[resolved] as ElementState | undefined;
|
|
61
|
+
const out: Record<string, string | number> = {};
|
|
62
|
+
// When not visible: opacity 0 so the element keeps its layout space (like slides in any app)
|
|
63
|
+
if (s?.visible === false) {
|
|
64
|
+
out.opacity = 0;
|
|
65
|
+
} else if (s?.opacity !== undefined) {
|
|
66
|
+
out.opacity = s.opacity;
|
|
67
|
+
}
|
|
68
|
+
if (s?.transform) out.transform = s.transform;
|
|
69
|
+
if (s?.style && typeof s.style === 'object') Object.assign(out, s.style);
|
|
70
|
+
return out;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const classVal = computed(() => {
|
|
74
|
+
const resolved = toValue(id);
|
|
75
|
+
if (!resolved) return '';
|
|
76
|
+
const s = store.state.elementState[resolved];
|
|
77
|
+
return (s?.class as string) || '';
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return { visible, style, class: classVal };
|
|
81
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { FlexElementContent } from '../core/types';
|
|
2
|
+
import type { CSSProperties } from 'vue';
|
|
3
|
+
|
|
4
|
+
// Constants
|
|
5
|
+
export const spacingVarMap: Record<string, string> = {
|
|
6
|
+
'none': 'var(--lumina-space-none, 0)',
|
|
7
|
+
'xs': 'var(--lumina-space-xs)',
|
|
8
|
+
'sm': 'var(--lumina-space-sm)',
|
|
9
|
+
'md': 'var(--lumina-space-md)',
|
|
10
|
+
'lg': 'var(--lumina-space-lg)',
|
|
11
|
+
'xl': 'var(--lumina-space-xl)',
|
|
12
|
+
'2xl': 'var(--lumina-space-2xl)',
|
|
13
|
+
'3xl': 'var(--lumina-space-3xl)',
|
|
14
|
+
'4xl': 'var(--lumina-space-4xl)',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const sizeMap: Record<string, string> = {
|
|
18
|
+
'auto': 'auto',
|
|
19
|
+
'quarter': '25%',
|
|
20
|
+
'third': '33.333%',
|
|
21
|
+
'half': '50%',
|
|
22
|
+
'two-thirds': '66.666%',
|
|
23
|
+
'three-quarters': '75%',
|
|
24
|
+
'full': '100%',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const valignMap: Record<string, string> = {
|
|
28
|
+
'top': 'flex-start',
|
|
29
|
+
'Top': 'flex-start',
|
|
30
|
+
'center': 'center',
|
|
31
|
+
'Center': 'center',
|
|
32
|
+
'bottom': 'flex-end',
|
|
33
|
+
'Bottom': 'flex-end',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const halignMap: Record<string, string> = {
|
|
37
|
+
'left': 'flex-start',
|
|
38
|
+
'Left': 'flex-start',
|
|
39
|
+
'center': 'center',
|
|
40
|
+
'Center': 'center',
|
|
41
|
+
'right': 'flex-end',
|
|
42
|
+
'Right': 'flex-end',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Helpers
|
|
46
|
+
export const getSizeClass = (size?: string): string => {
|
|
47
|
+
if (!size || size === 'auto') return 'flex-1';
|
|
48
|
+
return '';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const getFlexItemStyle = (element: { size?: string }, directionVertical: boolean): CSSProperties => {
|
|
52
|
+
const size = element.size || 'auto';
|
|
53
|
+
if (size === 'auto') return { flex: '1 1 auto' };
|
|
54
|
+
return {
|
|
55
|
+
flex: `0 0 ${sizeMap[size]}`,
|
|
56
|
+
maxWidth: !directionVertical ? sizeMap[size] : undefined,
|
|
57
|
+
maxHeight: directionVertical ? sizeMap[size] : undefined,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const getFlexContainerStyle = (element: FlexElementContent): CSSProperties => {
|
|
62
|
+
const valign = element.valign || 'center';
|
|
63
|
+
const halign = element.halign || 'left';
|
|
64
|
+
const gap = element.gap || 'md';
|
|
65
|
+
const padding = element.padding || 'lg';
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
display: 'flex',
|
|
69
|
+
flexDirection: 'column', // Content containers are always column based for children
|
|
70
|
+
justifyContent: valignMap[valign] as CSSProperties['justifyContent'],
|
|
71
|
+
alignItems: halignMap[halign] as CSSProperties['alignItems'],
|
|
72
|
+
gap: spacingVarMap[gap],
|
|
73
|
+
padding: spacingVarMap[padding],
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Deprecated: kept for backward compat if needed, but we will update consumers
|
|
78
|
+
export const getElementStyle = getFlexItemStyle;
|
|
79
|
+
export const getContentStyle = (element: FlexElementContent & { size?: string }): CSSProperties => {
|
|
80
|
+
// Legacy combined style
|
|
81
|
+
return {
|
|
82
|
+
...getFlexItemStyle(element, false), // Default assumption
|
|
83
|
+
...getFlexContainerStyle(element)
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const getTopLevelStyle = (): CSSProperties => ({
|
|
88
|
+
display: 'flex',
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
padding: spacingVarMap['lg'],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Alignment for the main flex slide container (all elements as a group).
|
|
95
|
+
* When direction is horizontal: justify-content = halign, align-items = valign.
|
|
96
|
+
* When direction is vertical: justify-content = valign, align-items = halign.
|
|
97
|
+
*/
|
|
98
|
+
export const getFlexSlideMainStyle = (
|
|
99
|
+
direction: 'horizontal' | 'vertical' | undefined,
|
|
100
|
+
halign: string | undefined,
|
|
101
|
+
valign: string | undefined
|
|
102
|
+
): CSSProperties => {
|
|
103
|
+
const jc = direction === 'vertical' ? valignMap[valign || 'top'] : halignMap[halign || 'left'];
|
|
104
|
+
const ai = direction === 'vertical' ? halignMap[halign || 'left'] : valignMap[valign || 'top'];
|
|
105
|
+
return {
|
|
106
|
+
justifyContent: jc as CSSProperties['justifyContent'],
|
|
107
|
+
alignItems: ai as CSSProperties['alignItems'],
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const getRoundedClass = (rounded?: string, fill?: boolean) => {
|
|
112
|
+
if (fill !== false && !rounded) return 'w-full h-full';
|
|
113
|
+
const map: Record<string, string> = {
|
|
114
|
+
'none': 'w-full h-full rounded-none',
|
|
115
|
+
'sm': 'w-full h-full rounded-sm',
|
|
116
|
+
'md': 'w-full h-full rounded-md',
|
|
117
|
+
'lg': 'w-full h-full rounded-lg',
|
|
118
|
+
'xl': 'w-full h-full rounded-xl',
|
|
119
|
+
'full': 'w-full h-full rounded-full',
|
|
120
|
+
};
|
|
121
|
+
return map[rounded || 'lg'] || 'w-full h-full rounded-lg';
|
|
122
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { onMounted, onUnmounted, inject } from 'vue';
|
|
2
|
+
import { StoreKey } from '../core/store';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GLOBAL KEYBOARD HANDLER
|
|
6
|
+
*
|
|
7
|
+
* Binds global keyboard events (Arrow Keys, Enter, etc.) to store actions.
|
|
8
|
+
* Throttles input to prevent accidental double-skips.
|
|
9
|
+
* Respects 'navigation' and 'keyboard' global flags.
|
|
10
|
+
*/
|
|
11
|
+
export function useKeyboard() {
|
|
12
|
+
const store = inject(StoreKey);
|
|
13
|
+
|
|
14
|
+
if (!store) return;
|
|
15
|
+
|
|
16
|
+
let lastTime = 0;
|
|
17
|
+
const THROTTLE_MS = 300;
|
|
18
|
+
|
|
19
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
20
|
+
const { options } = store.state;
|
|
21
|
+
if (!options.keyboard || !options.navigation) return;
|
|
22
|
+
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
if (now - lastTime < THROTTLE_MS) return;
|
|
25
|
+
|
|
26
|
+
const nextKeys = options.keys?.next || [];
|
|
27
|
+
const prevKeys = options.keys?.prev || [];
|
|
28
|
+
|
|
29
|
+
if (nextKeys.includes(e.key)) {
|
|
30
|
+
store.next();
|
|
31
|
+
lastTime = now;
|
|
32
|
+
} else if (prevKeys.includes(e.key)) {
|
|
33
|
+
store.prev();
|
|
34
|
+
lastTime = now;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
onMounted(() => {
|
|
39
|
+
window.addEventListener('keydown', handleKeydown);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
onUnmounted(() => {
|
|
43
|
+
window.removeEventListener('keydown', handleKeydown);
|
|
44
|
+
});
|
|
45
|
+
}
|