From 5a80657b2b8c2bd49b2bccd53a38bef0170b5c64 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 22 Jul 2026 20:54:12 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20b=E1=BB=95=20sung=20ph=E1=BA=A7n=20menu?= =?UTF-8?q?=20insert=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/js/app.jsx | 211 ++++++++++++++++++++++++++++++- app/static/js/app.precompiled.js | 211 ++++++++++++++++++++++++++++++- app/storage/sonicforge.db | Bin 45056 -> 45056 bytes 3 files changed, 420 insertions(+), 2 deletions(-) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index a6f0937..358fd41 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -184,6 +184,8 @@ const WaveformLane = ({ onContextMenu, onClipDragStart, onClipStretchStart, + onSectionItemDragStart, + onSectionItemResizeStart, onSelectionEdgeDragStart, setSelectedClipId, selectedClipId, @@ -555,6 +557,49 @@ const WaveformLane = ({ canvasRef.current.style.cursor = 'ew-resize'; return; } + + // Check section/MIDI item hover for resize or drag + const allSections = track.sections || []; + const allMidiItems = track.midiItems || []; + const sectionTolerance = 8 / zoom; + let foundSectionItem = null; + let sectionItemEdge = null; + const checkEdge = (item, startTime, dur) => { + const leftEdge = Math.abs(time - startTime) <= sectionTolerance; + const rightEdge = Math.abs(time - (startTime + dur)) <= sectionTolerance; + if (leftEdge || rightEdge) return leftEdge ? 'left' : 'right'; + return null; + }; + for (const sec of allSections) { + const edge = checkEdge(sec, sec.start, sec.duration); + if (edge) { foundSectionItem = { type: 'section', item: sec }; sectionItemEdge = edge; break; } + } + if (!foundSectionItem) { + for (const midi of allMidiItems) { + const edge = checkEdge(midi, midi.startTime, midi.duration); + if (edge) { foundSectionItem = { type: 'midiItem', item: midi }; sectionItemEdge = edge; break; } + } + } + if (foundSectionItem && sectionItemEdge) { + canvasRef.current.style.cursor = 'ew-resize'; + return; + } + // Check body hover for drag + if (!foundSectionItem) { + for (const sec of allSections) { + if (time >= sec.start && time < sec.start + sec.duration) { foundSectionItem = { type: 'section', item: sec }; break; } + } + } + if (!foundSectionItem) { + for (const midi of allMidiItems) { + if (time >= midi.startTime && time < midi.startTime + midi.duration) { foundSectionItem = { type: 'midiItem', item: midi }; break; } + } + } + if (foundSectionItem) { + canvasRef.current.style.cursor = 'grab'; + return; + } + const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); const isOverClip = !!hoveredClip; if (activeTool === 'pen') { @@ -647,6 +692,46 @@ const WaveformLane = ({ } return; } + + // Check section/MIDI item edge for resize, then body for drag + const secItems = track.sections || []; + const midiItems = track.midiItems || []; + const secTol = 8 / zoom; + let hitItem = null; + let hitEdge = null; + for (const sec of secItems) { + if (Math.abs(time - sec.start) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'left'; break; } + if (Math.abs(time - (sec.start + sec.duration)) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'right'; break; } + } + if (!hitItem) { + for (const midi of midiItems) { + if (Math.abs(time - midi.startTime) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'left'; break; } + if (Math.abs(time - (midi.startTime + midi.duration)) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'right'; break; } + } + } + if (hitItem && hitEdge) { + e.preventDefault(); + e.stopPropagation(); + if (onSectionItemResizeStart) onSectionItemResizeStart(track.id, hitItem.type, hitItem.id, hitEdge, time); + return; + } + if (!hitItem) { + for (const sec of secItems) { + if (time >= sec.start && time < sec.start + sec.duration) { hitItem = { type: 'section', id: sec.id, start: sec.start }; break; } + } + } + if (!hitItem) { + for (const midi of midiItems) { + if (time >= midi.startTime && time < midi.startTime + midi.duration) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime }; break; } + } + } + if (hitItem && !e.altKey && !e.ctrlKey && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start); + return; + } + const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); // Set selected clip ID @@ -3560,6 +3645,8 @@ const App = () => { const [menuOpen, setMenuOpen] = useState(null); const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId } const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } + const [draggedSectionItem, setDraggedSectionItem] = useState(null); + const [resizedSectionItem, setResizedSectionItem] = useState(null); const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId } const [editNameInput, setEditNameInput] = useState(''); @@ -6146,6 +6233,10 @@ const App = () => { hoveredTrackIdRef.current = hoveredTrackId; const captureTrackSnapshotRef = useRef(null); captureTrackSnapshotRef.current = captureTrackSnapshot; + const draggedSectionItemRef = useRef(null); + draggedSectionItemRef.current = draggedSectionItem; + const resizedSectionItemRef = useRef(null); + resizedSectionItemRef.current = resizedSectionItem; let clipSeqCounter = 0; const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`; @@ -6408,6 +6499,122 @@ const App = () => { document.removeEventListener('mouseup', handleMouseUp); }; }, [zoom]); + + // ── Section / MIDI Item Drag Start ── + const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset) => { + setDraggedSectionItem({ trackId, itemType, itemId, clickOffset }); + }; + + // ── Section / MIDI Item Resize Start ── + const handleSectionItemResizeStart = (trackId, itemType, itemId, side, clickTime) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const items = itemType === 'section' ? track.sections : track.midiItems; + const item = (items || []).find(it => it.id === itemId); + if (!item) return; + const start = itemType === 'section' ? item.start : item.startTime; + setResizedSectionItem({ trackId, itemType, itemId, side, originalStart: start, originalDuration: item.duration }); + }; + + // ── Document-level mousemove/mouseup for Section/MIDI item drag ── + useEffect(() => { + const handleMouseMove = e => { + const drag = draggedSectionItemRef.current; + if (!drag) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = mouseX / zoom; + const newStart = Math.max(0, time - drag.clickOffset); + const targetTrackId = hoveredTrackIdRef.current || drag.trackId; + setTracks(prev => prev.map(t => { + const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []); + const updatedItems = items.filter(it => it.id !== drag.itemId); + if (t.id === targetTrackId) { + const movedItem = items.find(it => it.id === drag.itemId); + if (movedItem) { + updatedItems.push(drag.itemType === 'section' + ? { ...movedItem, start: newStart } + : { ...movedItem, startTime: newStart }); + } else { + if (drag.itemType === 'section') { + updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4, color: '#06b6d4' }); + } else { + updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4, notes: [], color: '#a78bfa' }); + } + } + } + return drag.itemType === 'section' + ? { ...t, sections: updatedItems } + : { ...t, midiItems: updatedItems }; + })); + if (drag.trackId !== targetTrackId) { + setDraggedSectionItem(prev => ({ ...prev, trackId: targetTrackId })); + } + }; + const handleMouseUp = () => { + const drag = draggedSectionItemRef.current; + if (!drag) return; + setDraggedSectionItem(null); + showToast(`Đã di chuyển ${drag.itemType === 'section' ? 'section' : 'MIDI item'}.`, 'success'); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); + + // ── Document-level mousemove/mouseup for Section/MIDI item resize ── + useEffect(() => { + const handleMouseMove = e => { + const resize = resizedSectionItemRef.current; + if (!resize) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = mouseX / zoom; + setTracks(prev => prev.map(t => { + if (t.id !== resize.trackId) return t; + const items = resize.itemType === 'section' ? [...(t.sections || [])] : [...(t.midiItems || [])]; + const idx = items.findIndex(it => it.id === resize.itemId); + if (idx === -1) return t; + const item = items[idx]; + if (resize.side === 'left') { + const newStart = Math.min(time, resize.originalStart + resize.originalDuration - 0.1); + const end = resize.originalStart + resize.originalDuration; + const newDuration = end - newStart; + if (newDuration < 0.1) return t; + items[idx] = resize.itemType === 'section' + ? { ...item, start: newStart, duration: newDuration } + : { ...item, startTime: newStart, duration: newDuration }; + } else { + const newDuration = Math.max(0.1, time - resize.originalStart); + items[idx] = { ...item, duration: newDuration }; + } + return resize.itemType === 'section' + ? { ...t, sections: items } + : { ...t, midiItems: items }; + })); + }; + const handleMouseUp = () => { + const resize = resizedSectionItemRef.current; + if (!resize) return; + setResizedSectionItem(null); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); + const handleSelectRange = (start, end, reset) => { const maxLen = maxDuration; const cleanStart = Math.max(0, Math.min(maxLen, start)); @@ -10352,6 +10559,8 @@ const App = () => { onContextMenu: handleContextMenu, onClipDragStart: handleClipDragStart, onClipStretchStart: handleClipStretchStart, + onSectionItemDragStart: handleSectionItemDragStart, + onSectionItemResizeStart: handleSectionItemResizeStart, onSelectionEdgeDragStart: handleSelectionEdgeDragStart, setSelectedClipId: setSelectedClipId, selectedClipId: selectedClipId, @@ -10406,7 +10615,7 @@ const App = () => { }), /*#__PURE__*/React.createElement("div", { className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800", onMouseEnter: () => { - if (draggedClipRef.current) { + if (draggedClipRef.current || draggedSectionItemRef.current) { setHoveredTrackId(addNewTrack()); } }, diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 5ce5fdb..8377727 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -184,6 +184,8 @@ const WaveformLane = ({ onContextMenu, onClipDragStart, onClipStretchStart, + onSectionItemDragStart, + onSectionItemResizeStart, onSelectionEdgeDragStart, setSelectedClipId, selectedClipId, @@ -555,6 +557,49 @@ const WaveformLane = ({ canvasRef.current.style.cursor = 'ew-resize'; return; } + + // Check section/MIDI item hover for resize or drag + const allSections = track.sections || []; + const allMidiItems = track.midiItems || []; + const sectionTolerance = 8 / zoom; + let foundSectionItem = null; + let sectionItemEdge = null; + const checkEdge = (item, startTime, dur) => { + const leftEdge = Math.abs(time - startTime) <= sectionTolerance; + const rightEdge = Math.abs(time - (startTime + dur)) <= sectionTolerance; + if (leftEdge || rightEdge) return leftEdge ? 'left' : 'right'; + return null; + }; + for (const sec of allSections) { + const edge = checkEdge(sec, sec.start, sec.duration); + if (edge) { foundSectionItem = { type: 'section', item: sec }; sectionItemEdge = edge; break; } + } + if (!foundSectionItem) { + for (const midi of allMidiItems) { + const edge = checkEdge(midi, midi.startTime, midi.duration); + if (edge) { foundSectionItem = { type: 'midiItem', item: midi }; sectionItemEdge = edge; break; } + } + } + if (foundSectionItem && sectionItemEdge) { + canvasRef.current.style.cursor = 'ew-resize'; + return; + } + // Check body hover for drag + if (!foundSectionItem) { + for (const sec of allSections) { + if (time >= sec.start && time < sec.start + sec.duration) { foundSectionItem = { type: 'section', item: sec }; break; } + } + } + if (!foundSectionItem) { + for (const midi of allMidiItems) { + if (time >= midi.startTime && time < midi.startTime + midi.duration) { foundSectionItem = { type: 'midiItem', item: midi }; break; } + } + } + if (foundSectionItem) { + canvasRef.current.style.cursor = 'grab'; + return; + } + const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); const isOverClip = !!hoveredClip; if (activeTool === 'pen') { @@ -647,6 +692,46 @@ const WaveformLane = ({ } return; } + + // Check section/MIDI item edge for resize, then body for drag + const secItems = track.sections || []; + const midiItemsArray = track.midiItems || []; + const secTol = 8 / zoom; + let hitItem = null; + let hitEdge = null; + for (const sec of secItems) { + if (Math.abs(time - sec.start) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'left'; break; } + if (Math.abs(time - (sec.start + sec.duration)) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'right'; break; } + } + if (!hitItem) { + for (const midi of midiItemsArray) { + if (Math.abs(time - midi.startTime) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'left'; break; } + if (Math.abs(time - (midi.startTime + midi.duration)) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'right'; break; } + } + } + if (hitItem && hitEdge) { + e.preventDefault(); + e.stopPropagation(); + if (onSectionItemResizeStart) onSectionItemResizeStart(track.id, hitItem.type, hitItem.id, hitEdge, time); + return; + } + if (!hitItem) { + for (const sec of secItems) { + if (time >= sec.start && time < sec.start + sec.duration) { hitItem = { type: 'section', id: sec.id, start: sec.start }; break; } + } + } + if (!hitItem) { + for (const midi of midiItemsArray) { + if (time >= midi.startTime && time < midi.startTime + midi.duration) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime }; break; } + } + } + if (hitItem && !e.altKey && !e.ctrlKey && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start); + return; + } + const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); // Set selected clip ID @@ -3611,6 +3696,8 @@ const App = () => { const [menuOpen, setMenuOpen] = useState(null); const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId } const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } + const [draggedSectionItem, setDraggedSectionItem] = useState(null); + const [resizedSectionItem, setResizedSectionItem] = useState(null); const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId } const [editNameInput, setEditNameInput] = useState(''); @@ -6213,6 +6300,10 @@ const App = () => { hoveredTrackIdRef.current = hoveredTrackId; const captureTrackSnapshotRef = useRef(null); captureTrackSnapshotRef.current = captureTrackSnapshot; + const draggedSectionItemRef = useRef(null); + draggedSectionItemRef.current = draggedSectionItem; + const resizedSectionItemRef = useRef(null); + resizedSectionItemRef.current = resizedSectionItem; let clipSeqCounter = 0; const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`; const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => { @@ -6497,6 +6588,122 @@ const App = () => { document.removeEventListener('mouseup', handleMouseUp); }; }, [zoom]); + + // ── Section / MIDI Item Drag Start ── + const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset) => { + setDraggedSectionItem({ trackId, itemType, itemId, clickOffset }); + }; + + // ── Section / MIDI Item Resize Start ── + const handleSectionItemResizeStart = (trackId, itemType, itemId, side, clickTime) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const items = itemType === 'section' ? track.sections : track.midiItems; + const item = (items || []).find(it => it.id === itemId); + if (!item) return; + const start = itemType === 'section' ? item.start : item.startTime; + setResizedSectionItem({ trackId, itemType, itemId, side, originalStart: start, originalDuration: item.duration }); + }; + + // ── Document-level mousemove/mouseup for Section/MIDI item drag ── + useEffect(() => { + const handleMouseMove = e => { + const drag = draggedSectionItemRef.current; + if (!drag) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = mouseX / zoom; + const newStart = Math.max(0, time - drag.clickOffset); + const targetTrackId = hoveredTrackIdRef.current || drag.trackId; + setTracks(prev => prev.map(t => { + const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []); + const updatedItems = items.filter(it => it.id !== drag.itemId); + if (t.id === targetTrackId) { + const movedItem = items.find(it => it.id === drag.itemId); + if (movedItem) { + updatedItems.push(drag.itemType === 'section' + ? { ...movedItem, start: newStart } + : { ...movedItem, startTime: newStart }); + } else { + if (drag.itemType === 'section') { + updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4, color: '#06b6d4' }); + } else { + updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4, notes: [], color: '#a78bfa' }); + } + } + } + return drag.itemType === 'section' + ? { ...t, sections: updatedItems } + : { ...t, midiItems: updatedItems }; + })); + if (drag.trackId !== targetTrackId) { + setDraggedSectionItem(prev => ({ ...prev, trackId: targetTrackId })); + } + }; + const handleMouseUp = () => { + const drag = draggedSectionItemRef.current; + if (!drag) return; + setDraggedSectionItem(null); + showToast(`Đã di chuyển ${drag.itemType === 'section' ? 'section' : 'MIDI item'}.`, 'success'); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); + + // ── Document-level mousemove/mouseup for Section/MIDI item resize ── + useEffect(() => { + const handleMouseMove = e => { + const resize = resizedSectionItemRef.current; + if (!resize) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = mouseX / zoom; + setTracks(prev => prev.map(t => { + if (t.id !== resize.trackId) return t; + const items = resize.itemType === 'section' ? [...(t.sections || [])] : [...(t.midiItems || [])]; + const idx = items.findIndex(it => it.id === resize.itemId); + if (idx === -1) return t; + const item = items[idx]; + if (resize.side === 'left') { + const newStart = Math.min(time, resize.originalStart + resize.originalDuration - 0.1); + const end = resize.originalStart + resize.originalDuration; + const newDuration = end - newStart; + if (newDuration < 0.1) return t; + items[idx] = resize.itemType === 'section' + ? { ...item, start: newStart, duration: newDuration } + : { ...item, startTime: newStart, duration: newDuration }; + } else { + const newDuration = Math.max(0.1, time - resize.originalStart); + items[idx] = { ...item, duration: newDuration }; + } + return resize.itemType === 'section' + ? { ...t, sections: items } + : { ...t, midiItems: items }; + })); + }; + const handleMouseUp = () => { + const resize = resizedSectionItemRef.current; + if (!resize) return; + setResizedSectionItem(null); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); + const handleSelectRange = (start, end, reset) => { const maxLen = maxDuration; const cleanStart = Math.max(0, Math.min(maxLen, start)); @@ -10913,6 +11120,8 @@ const App = () => { onContextMenu: handleContextMenu, onClipDragStart: handleClipDragStart, onClipStretchStart: handleClipStretchStart, + onSectionItemDragStart: handleSectionItemDragStart, + onSectionItemResizeStart: handleSectionItemResizeStart, onSelectionEdgeDragStart: handleSelectionEdgeDragStart, setSelectedClipId: setSelectedClipId, selectedClipId: selectedClipId, @@ -10967,7 +11176,7 @@ const App = () => { }), /*#__PURE__*/React.createElement("div", { className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800", onMouseEnter: () => { - if (draggedClipRef.current) { + if (draggedClipRef.current || draggedSectionItemRef.current) { setHoveredTrackId(addNewTrack()); } }, diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index f987ea57c3468f88136895dcd7f7b366074806f7..0fe61eefbcf1bd4ee3b38c03005456b939bbfc9e 100644 GIT binary patch delta 179 zcmZp8z|`=7X@WH4<%u%RjF&eiEX?O+U|?Y8l4Ias&%29DZZls&5$nbY^H@0BC3!gG zL+yKfH+Pn@F`4L8D`loASt*qmnp>Ed8(A2dn_3!JDCsEWCFZ6A#Y2h`ld~02WhV>N zhqEOb873#DP2OK;CuEGS+!(CF+`ur=+!Uz7Y;u1&KNqutGWbr~m^6fB+A#4Y~z@vkq`_1qp;D3<+OV yKZa$qCwc|~9bGIUb9G`=VPbP4I$K?R0jxpVm@4hFNjeMT~w3La|!