draply-dev 1.5.2 → 1.5.4

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.
Files changed (3) hide show
  1. package/bin/cli.js +5 -2
  2. package/package.json +1 -1
  3. package/src/overlay.js +1080 -1008
package/src/overlay.js CHANGED
@@ -824,1104 +824,1176 @@
824
824
  const subLay = document.getElementById('__sub_lay__');
825
825
  if (subLay) subLay.classList.remove('v');
826
826
  }
827
- }
827
+
828
828
 
829
829
  // ── HOVER ────────────────────────────────────────────────────────────────
830
830
  document.addEventListener('mouseover', e => {
831
- if (!state.tool || ps(e.target)) return;
832
- document.querySelectorAll('.__ph__').forEach(el => el.classList.remove('__ph__'));
833
- e.target.classList.add('__ph__');
834
- if (state.tool === 'ins') {
835
- const r = e.target.getBoundingClientRect(), cs = getComputedStyle(e.target);
836
- tip.textContent = `${Math.round(r.width)}×${Math.round(r.height)} ${cs.position} ${cs.display}`;
837
- tip.classList.add('v');
838
- }
839
- });
840
- document.addEventListener('mouseout', e => {
841
- if (!ps(e.target)) e.target.classList.remove('__ph__');
842
- if (state.tool === 'ins') tip.classList.remove('v');
843
- });
844
- document.addEventListener('mousemove', e => {
845
- if (state.tool === 'ins') { tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 28) + 'px'; }
846
- if (state.dragging || state.resizing) { tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 28) + 'px'; }
847
- });
848
-
849
- // ── CLICK SELECT ─────────────────────────────────────────────────────────
850
- document.addEventListener('click', e => {
851
- if (!state.tool || ps(e.target) || state.tool === 'ins') return;
852
- e.preventDefault(); e.stopPropagation();
853
-
854
- if (state.tool === 'mov' && e.ctrlKey) {
855
- // Ctrl+click: toggle element in multi-select
856
- const t = e.target;
857
- const idx = state.selectedEls.indexOf(t);
858
- if (idx >= 0) {
859
- // Already in selection — remove it
860
- state.selectedEls.splice(idx, 1);
861
- t.classList.remove('__ps_multi__');
862
- t.classList.remove('__ps__');
863
- // If we removed the primary, promote the first remaining to primary
864
- if (t === state.selectedEl) {
865
- state.selectedEl = state.selectedEls[0] || null;
866
- if (state.selectedEl) {
867
- state.selectedEl.classList.remove('__ps_multi__');
868
- state.selectedEl.classList.add('__ps__');
869
- }
870
- }
871
- } else {
872
- // Add to selection
873
- state.selectedEls.push(t);
874
- t.classList.add('__ps_multi__');
875
- // If no primary yet, make this primary
876
- if (!state.selectedEl) {
877
- state.selectedEl = t;
878
- t.classList.remove('__ps_multi__');
879
- t.classList.add('__ps__');
831
+ if (!state.tool || ps(e.target)) return;
832
+ document.querySelectorAll('.__ph__').forEach(el => el.classList.remove('__ph__'));
833
+ e.target.classList.add('__ph__');
834
+ if (state.tool === 'ins') {
835
+ const r = e.target.getBoundingClientRect(), cs = getComputedStyle(e.target);
836
+ tip.textContent = `${Math.round(r.width)}×${Math.round(r.height)} ${cs.position} ${cs.display}`;
837
+ tip.classList.add('v');
838
+ }
839
+ });
840
+ document.addEventListener('mouseout', e => {
841
+ if (!ps(e.target)) e.target.classList.remove('__ph__');
842
+ if (state.tool === 'ins') tip.classList.remove('v');
843
+ });
844
+ document.addEventListener('mousemove', e => {
845
+ if (state.tool === 'ins') { tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 28) + 'px'; }
846
+ if (state.dragging || state.resizing) { tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 28) + 'px'; }
847
+ });
848
+
849
+ // ── CLICK SELECT ─────────────────────────────────────────────────────────
850
+ document.addEventListener('click', e => {
851
+ if (!state.tool || ps(e.target) || state.tool === 'ins') return;
852
+ e.preventDefault(); e.stopPropagation();
853
+
854
+ if (state.tool === 'mov' && e.ctrlKey) {
855
+ // Ctrl+click: toggle element in multi-select
856
+ const t = e.target;
857
+ const idx = state.selectedEls.indexOf(t);
858
+ if (idx >= 0) {
859
+ // Already in selection — remove it
860
+ state.selectedEls.splice(idx, 1);
861
+ t.classList.remove('__ps_multi__');
862
+ t.classList.remove('__ps__');
863
+ // If we removed the primary, promote the first remaining to primary
864
+ if (t === state.selectedEl) {
865
+ state.selectedEl = state.selectedEls[0] || null;
866
+ if (state.selectedEl) {
867
+ state.selectedEl.classList.remove('__ps_multi__');
868
+ state.selectedEl.classList.add('__ps__');
880
869
  }
881
870
  }
882
- // Show handle on primary if any selected
883
- if (state.selectedEl) placeHdl(state.selectedEl);
884
- else hdl.classList.remove('v');
885
- return;
871
+ } else {
872
+ // Add to selection
873
+ state.selectedEls.push(t);
874
+ t.classList.add('__ps_multi__');
875
+ // If no primary yet, make this primary
876
+ if (!state.selectedEl) {
877
+ state.selectedEl = t;
878
+ t.classList.remove('__ps_multi__');
879
+ t.classList.add('__ps__');
880
+ }
886
881
  }
887
-
888
- // Normal click (no Ctrl): clear all and select just this
889
- document.querySelectorAll('.__ps__, .__ps_multi__').forEach(el => {
890
- el.classList.remove('__ps__');
891
- el.classList.remove('__ps_multi__');
892
- });
893
- state.selectedEls = [e.target];
894
- state.selectedEl = e.target;
895
- e.target.classList.add('__ps__');
896
-
897
- if (state.tool === 'mov') { dEl = null; placeHdl(e.target); }
898
- if (state.tool === 'rsz') { rzEl = null; rzDir = null; placeRH(e.target); }
899
- if (state.tool === 'clr') populateColors(e.target);
900
- if (state.tool === 'typ') populateTypo(e.target);
901
- }, true);
902
-
903
- // ══════════════════════════════════════════
904
- // MOVE
905
- // ══════════════════════════════════════════
906
- function placeHdl(el) {
907
- const r = el.getBoundingClientRect();
908
- const hdlW = 80; // approximate handle width
909
- // Center above element, but clamp inside viewport
910
- let left = r.left + r.width / 2 - hdlW / 2;
911
- let top = r.top - 36;
912
- // Clamp horizontally
913
- left = Math.max(8, Math.min(left, window.innerWidth - hdlW - 8));
914
- // If element is at top, show handle below instead
915
- if (top < 8) top = r.bottom + 8;
916
- hdl.style.left = left + 'px';
917
- hdl.style.top = top + 'px';
918
- hdl.classList.add('v');
882
+ // Show handle on primary if any selected
883
+ if (state.selectedEl) placeHdl(state.selectedEl);
884
+ else hdl.classList.remove('v');
885
+ return;
919
886
  }
920
887
 
921
- let dEl;
922
- hdl.addEventListener('mousedown', e => {
923
- if (!state.selectedEl) return;
924
- e.preventDefault();
925
- dEl = state.selectedEl;
926
- state.dragSX = e.clientX; state.dragSY = e.clientY;
927
-
928
- // Snapshot original positions for ALL selected elements
929
- const els = state.selectedEls.length > 0 ? state.selectedEls : [state.selectedEl];
930
- state.dragOrigPositions = els.map(el => {
931
- const cs = getComputedStyle(el);
932
- const origL = parseFloat(cs.left) || 0;
933
- const origT = parseFloat(cs.top) || 0;
934
- if (cs.position === 'static') el.style.position = 'relative';
935
- el.style.transition = 'none';
936
- return { el, origL, origT, prevL: origL + 'px', prevT: origT + 'px' };
937
- });
938
-
939
- state.dragging = true;
940
- });
941
- document.addEventListener('mousemove', e => {
942
- if (!state.dragging) return;
943
- const dx = e.clientX - state.dragSX, dy = e.clientY - state.dragSY;
944
- state.dragOrigPositions.forEach(({ el, origL, origT }) => {
945
- el.style.left = (origL + dx) + 'px';
946
- el.style.top = (origT + dy) + 'px';
947
- });
948
- if (dEl) {
949
- placeHdl(dEl);
950
- const { origL, origT } = state.dragOrigPositions[0];
951
- tip.textContent = `x:${Math.round(origL + dx)} y:${Math.round(origT + dy)}`;
952
- tip.classList.add('v');
953
- }
888
+ // Normal click (no Ctrl): clear all and select just this
889
+ document.querySelectorAll('.__ps__, .__ps_multi__').forEach(el => {
890
+ el.classList.remove('__ps__');
891
+ el.classList.remove('__ps_multi__');
954
892
  });
955
- document.addEventListener('mouseup', () => {
956
- if (!state.dragging) return;
957
- state.dragging = false; tip.classList.remove('v');
958
- // Record each element separately
959
- state.dragOrigPositions.forEach(({ el, prevL, prevT }) => {
960
- const cs = getComputedStyle(el);
961
- const newLeft = Math.round(parseFloat(cs.left)) + 'px';
962
- const newTop = Math.round(parseFloat(cs.top)) + 'px';
963
- if (newLeft !== prevL || newTop !== prevT) {
964
- rec(el, { left: newLeft, top: newTop }, { left: prevL, top: prevT });
965
- }
966
- });
967
- state.dragOrigPositions = [];
968
- dEl = null;
893
+ state.selectedEls = [e.target];
894
+ state.selectedEl = e.target;
895
+ e.target.classList.add('__ps__');
896
+
897
+ if (state.tool === 'mov') { dEl = null; placeHdl(e.target); }
898
+ if (state.tool === 'rsz') { rzEl = null; rzDir = null; placeRH(e.target); }
899
+ if (state.tool === 'clr') populateColors(e.target);
900
+ if (state.tool === 'typ') populateTypo(e.target);
901
+ }, true);
902
+
903
+ // ══════════════════════════════════════════
904
+ // MOVE
905
+ // ══════════════════════════════════════════
906
+ function placeHdl(el) {
907
+ const r = el.getBoundingClientRect();
908
+ const hdlW = 80; // approximate handle width
909
+ // Center above element, but clamp inside viewport
910
+ let left = r.left + r.width / 2 - hdlW / 2;
911
+ let top = r.top - 36;
912
+ // Clamp horizontally
913
+ left = Math.max(8, Math.min(left, window.innerWidth - hdlW - 8));
914
+ // If element is at top, show handle below instead
915
+ if (top < 8) top = r.bottom + 8;
916
+ hdl.style.left = left + 'px';
917
+ hdl.style.top = top + 'px';
918
+ hdl.classList.add('v');
919
+ }
920
+
921
+ let dEl;
922
+ hdl.addEventListener('mousedown', e => {
923
+ if (!state.selectedEl) return;
924
+ e.preventDefault();
925
+ dEl = state.selectedEl;
926
+ state.dragSX = e.clientX; state.dragSY = e.clientY;
927
+
928
+ // Snapshot original positions for ALL selected elements
929
+ const els = state.selectedEls.length > 0 ? state.selectedEls : [state.selectedEl];
930
+ state.dragOrigPositions = els.map(el => {
931
+ const cs = getComputedStyle(el);
932
+ const origL = parseFloat(cs.left) || 0;
933
+ const origT = parseFloat(cs.top) || 0;
934
+ if (cs.position === 'static') el.style.position = 'relative';
935
+ el.style.transition = 'none';
936
+ return { el, origL, origT, prevL: origL + 'px', prevT: origT + 'px' };
969
937
  });
970
938
 
971
- // ══════════════════════════════════════════
972
- // RESIZE
973
- // ══════════════════════════════════════════
974
- function placeRH(el) {
975
- const r = el.getBoundingClientRect();
976
- // Use viewport coords (fixed position handles need viewport coords, not page)
977
- const t = r.top, l = r.left;
978
- const hw = r.width, hh = r.height;
979
- const hs = 5; // half handle size for centering
980
-
981
- const pos = {
982
- nw: [l - hs, t - hs],
983
- n: [l + hw / 2 - hs, t - hs],
984
- ne: [l + hw - hs, t - hs],
985
- w: [l - hs, t + hh / 2 - hs],
986
- e: [l + hw - hs, t + hh / 2 - hs],
987
- sw: [l - hs, t + hh - hs],
988
- s: [l + hw / 2 - hs, t + hh - hs],
989
- se: [l + hw - hs, t + hh - hs],
990
- };
991
- rhDirs.forEach(d => {
992
- rhs[d].style.left = pos[d][0] + 'px';
993
- rhs[d].style.top = pos[d][1] + 'px';
994
- rhs[d].classList.add('v');
995
- });
996
- }
997
-
998
- let rzEl = null, rzDir = null;
999
- rhDirs.forEach(d => {
1000
- rhs[d].addEventListener('mousedown', e => {
1001
- if (!state.selectedEl) return;
1002
- e.preventDefault(); e.stopPropagation();
1003
- rzEl = state.selectedEl; rzDir = d;
1004
- const r = rzEl.getBoundingClientRect();
1005
- const cs = getComputedStyle(rzEl);
1006
- state.resizeOrigW = r.width; state.resizeOrigH = r.height;
1007
- state.resizeOrigX = e.clientX; state.resizeOrigY = e.clientY;
1008
- state.resizeOrigL = parseFloat(cs.left) || 0;
1009
- state.resizeOrigT = parseFloat(cs.top) || 0;
1010
- if (cs.position === 'static') rzEl.style.position = 'relative';
1011
- rzEl.style.transition = 'none';
1012
- state.resizing = true;
1013
- });
939
+ state.dragging = true;
940
+ });
941
+ document.addEventListener('mousemove', e => {
942
+ if (!state.dragging) return;
943
+ const dx = e.clientX - state.dragSX, dy = e.clientY - state.dragSY;
944
+ state.dragOrigPositions.forEach(({ el, origL, origT }) => {
945
+ el.style.left = (origL + dx) + 'px';
946
+ el.style.top = (origT + dy) + 'px';
1014
947
  });
1015
-
1016
- document.addEventListener('mousemove', e => {
1017
- if (!state.resizing || !rzEl) return;
1018
- const dx = e.clientX - state.resizeOrigX, dy = e.clientY - state.resizeOrigY;
1019
- let w = state.resizeOrigW, h = state.resizeOrigH;
1020
- let l = state.resizeOrigL, t = state.resizeOrigT;
1021
-
1022
- if (rzDir.includes('e')) w = Math.max(20, w + dx);
1023
- if (rzDir.includes('s')) h = Math.max(20, h + dy);
1024
- if (rzDir.includes('w')) { w = Math.max(20, w - dx); l = state.resizeOrigL + (state.resizeOrigW - w); }
1025
- if (rzDir.includes('n')) { h = Math.max(20, h - dy); t = state.resizeOrigT + (state.resizeOrigH - h); }
1026
-
1027
- rzEl.style.width = Math.round(w) + 'px';
1028
- rzEl.style.height = Math.round(h) + 'px';
1029
- rzEl.style.left = Math.round(l) + 'px';
1030
- rzEl.style.top = Math.round(t) + 'px';
1031
- placeRH(rzEl);
1032
- tip.textContent = `${Math.round(w)}×${Math.round(h)}`;
948
+ if (dEl) {
949
+ placeHdl(dEl);
950
+ const { origL, origT } = state.dragOrigPositions[0];
951
+ tip.textContent = `x:${Math.round(origL + dx)} y:${Math.round(origT + dy)}`;
1033
952
  tip.classList.add('v');
953
+ }
954
+ });
955
+ document.addEventListener('mouseup', () => {
956
+ if (!state.dragging) return;
957
+ state.dragging = false; tip.classList.remove('v');
958
+ // Record each element separately
959
+ state.dragOrigPositions.forEach(({ el, prevL, prevT }) => {
960
+ const cs = getComputedStyle(el);
961
+ const newLeft = Math.round(parseFloat(cs.left)) + 'px';
962
+ const newTop = Math.round(parseFloat(cs.top)) + 'px';
963
+ if (newLeft !== prevL || newTop !== prevT) {
964
+ rec(el, { left: newLeft, top: newTop }, { left: prevL, top: prevT });
965
+ }
1034
966
  });
967
+ state.dragOrigPositions = [];
968
+ dEl = null;
969
+ });
970
+
971
+ // ══════════════════════════════════════════
972
+ // RESIZE
973
+ // ══════════════════════════════════════════
974
+ function placeRH(el) {
975
+ const r = el.getBoundingClientRect();
976
+ // Use viewport coords (fixed position handles need viewport coords, not page)
977
+ const t = r.top, l = r.left;
978
+ const hw = r.width, hh = r.height;
979
+ const hs = 5; // half handle size for centering
980
+
981
+ const pos = {
982
+ nw: [l - hs, t - hs],
983
+ n: [l + hw / 2 - hs, t - hs],
984
+ ne: [l + hw - hs, t - hs],
985
+ w: [l - hs, t + hh / 2 - hs],
986
+ e: [l + hw - hs, t + hh / 2 - hs],
987
+ sw: [l - hs, t + hh - hs],
988
+ s: [l + hw / 2 - hs, t + hh - hs],
989
+ se: [l + hw - hs, t + hh - hs],
990
+ };
991
+ rhDirs.forEach(d => {
992
+ rhs[d].style.left = pos[d][0] + 'px';
993
+ rhs[d].style.top = pos[d][1] + 'px';
994
+ rhs[d].classList.add('v');
995
+ });
996
+ }
1035
997
 
1036
- document.addEventListener('mouseup', () => {
1037
- if (!state.resizing || !rzEl) return;
1038
- state.resizing = false; tip.classList.remove('v');
998
+ let rzEl = null, rzDir = null;
999
+ rhDirs.forEach(d => {
1000
+ rhs[d].addEventListener('mousedown', e => {
1001
+ if (!state.selectedEl) return;
1002
+ e.preventDefault(); e.stopPropagation();
1003
+ rzEl = state.selectedEl; rzDir = d;
1004
+ const r = rzEl.getBoundingClientRect();
1039
1005
  const cs = getComputedStyle(rzEl);
1040
- // prevProps = original values before resize started
1041
- const prevProps = {
1042
- width: Math.round(state.resizeOrigW) + 'px',
1043
- height: Math.round(state.resizeOrigH) + 'px',
1044
- left: Math.round(state.resizeOrigL) + 'px',
1045
- top: Math.round(state.resizeOrigT) + 'px',
1046
- };
1047
- const newSize = {
1048
- width: Math.round(parseFloat(cs.width)) + 'px',
1049
- height: Math.round(parseFloat(cs.height)) + 'px',
1050
- left: Math.round(parseFloat(cs.left)) + 'px',
1051
- top: Math.round(parseFloat(cs.top)) + 'px',
1052
- };
1053
- if (newSize.width !== prevProps.width || newSize.height !== prevProps.height || newSize.left !== prevProps.left || newSize.top !== prevProps.top) {
1054
- rec(rzEl, newSize, prevProps);
1055
- }
1056
- rzEl = null; rzDir = null;
1006
+ state.resizeOrigW = r.width; state.resizeOrigH = r.height;
1007
+ state.resizeOrigX = e.clientX; state.resizeOrigY = e.clientY;
1008
+ state.resizeOrigL = parseFloat(cs.left) || 0;
1009
+ state.resizeOrigT = parseFloat(cs.top) || 0;
1010
+ if (cs.position === 'static') rzEl.style.position = 'relative';
1011
+ rzEl.style.transition = 'none';
1012
+ state.resizing = true;
1057
1013
  });
1058
-
1059
- // ══════════════════════════════════════════
1060
- // COLORS
1061
- // ══════════════════════════════════════════
1062
- const clrElName = document.getElementById('__clr_el_name__');
1063
- const swBg = document.getElementById('__sw_bg__'), cpBg = document.getElementById('__cp_bg__');
1064
- const swFg = document.getElementById('__sw_fg__'), cpFg = document.getElementById('__cp_fg__');
1065
- const swBd = document.getElementById('__sw_bd__'), cpBd = document.getElementById('__cp_bd__');
1066
- const cpBgTrans = document.getElementById('__cp_bg_trans__');
1067
- const cpBdTrans = document.getElementById('__cp_bd_trans__');
1068
-
1069
- function setupSwatch(sw, cp) {
1070
- sw.onclick = () => cp.click();
1071
- cp.oninput = () => { sw.style.background = cp.value; };
1072
- }
1073
- setupSwatch(swBg, cpBg);
1074
- setupSwatch(swFg, cpFg);
1075
- setupSwatch(swBd, cpBd);
1076
-
1077
- // Transparent checkbox toggles swatch appearance
1078
- cpBgTrans.onchange = () => {
1079
- swBg.style.background = cpBgTrans.checked
1080
- ? 'repeating-conic-gradient(#444 0% 25%, #222 0% 50%) 0 0/10px 10px'
1081
- : cpBg.value;
1082
- swBg.style.opacity = cpBgTrans.checked ? '0.6' : '1';
1014
+ });
1015
+
1016
+ document.addEventListener('mousemove', e => {
1017
+ if (!state.resizing || !rzEl) return;
1018
+ const dx = e.clientX - state.resizeOrigX, dy = e.clientY - state.resizeOrigY;
1019
+ let w = state.resizeOrigW, h = state.resizeOrigH;
1020
+ let l = state.resizeOrigL, t = state.resizeOrigT;
1021
+
1022
+ if (rzDir.includes('e')) w = Math.max(20, w + dx);
1023
+ if (rzDir.includes('s')) h = Math.max(20, h + dy);
1024
+ if (rzDir.includes('w')) { w = Math.max(20, w - dx); l = state.resizeOrigL + (state.resizeOrigW - w); }
1025
+ if (rzDir.includes('n')) { h = Math.max(20, h - dy); t = state.resizeOrigT + (state.resizeOrigH - h); }
1026
+
1027
+ rzEl.style.width = Math.round(w) + 'px';
1028
+ rzEl.style.height = Math.round(h) + 'px';
1029
+ rzEl.style.left = Math.round(l) + 'px';
1030
+ rzEl.style.top = Math.round(t) + 'px';
1031
+ placeRH(rzEl);
1032
+ tip.textContent = `${Math.round(w)}×${Math.round(h)}`;
1033
+ tip.classList.add('v');
1034
+ });
1035
+
1036
+ document.addEventListener('mouseup', () => {
1037
+ if (!state.resizing || !rzEl) return;
1038
+ state.resizing = false; tip.classList.remove('v');
1039
+ const cs = getComputedStyle(rzEl);
1040
+ // prevProps = original values before resize started
1041
+ const prevProps = {
1042
+ width: Math.round(state.resizeOrigW) + 'px',
1043
+ height: Math.round(state.resizeOrigH) + 'px',
1044
+ left: Math.round(state.resizeOrigL) + 'px',
1045
+ top: Math.round(state.resizeOrigT) + 'px',
1083
1046
  };
1084
- cpBdTrans.onchange = () => {
1085
- swBd.style.background = cpBdTrans.checked
1086
- ? 'repeating-conic-gradient(#444 0% 25%, #222 0% 50%) 0 0/10px 10px'
1087
- : cpBd.value;
1088
- swBd.style.opacity = cpBdTrans.checked ? '0.6' : '1';
1047
+ const newSize = {
1048
+ width: Math.round(parseFloat(cs.width)) + 'px',
1049
+ height: Math.round(parseFloat(cs.height)) + 'px',
1050
+ left: Math.round(parseFloat(cs.left)) + 'px',
1051
+ top: Math.round(parseFloat(cs.top)) + 'px',
1089
1052
  };
1090
-
1091
- function isTransparent(color) {
1092
- return !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)';
1053
+ if (newSize.width !== prevProps.width || newSize.height !== prevProps.height || newSize.left !== prevProps.left || newSize.top !== prevProps.top) {
1054
+ rec(rzEl, newSize, prevProps);
1093
1055
  }
1094
-
1095
- function populateColors(el) {
1096
- const cs = getComputedStyle(el);
1097
- clrElName.textContent = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + [...el.classList][0] : '');
1098
- let bg = cs.backgroundColor;
1099
- let fg = cs.color || '#000000';
1100
- let bd = cs.borderColor || '#cccccc';
1101
- const bw = cs.borderWidth;
1102
-
1103
- // #14: Support SVG fill/stroke (#14)
1104
- const isSvg = el.namespaceURI === 'http://www.w3.org/2000/svg';
1105
- if (isSvg) {
1106
- fg = cs.fill;
1107
- bd = cs.stroke;
1108
- }
1109
-
1110
- // Background — detect transparent
1111
- if (isTransparent(bg)) {
1112
- cpBgTrans.checked = true;
1113
- swBg.style.background = 'repeating-conic-gradient(#444 0% 25%, #222 0% 50%) 0 0/10px 10px';
1114
- swBg.style.opacity = '0.6';
1115
- } else {
1116
- cpBgTrans.checked = false;
1117
- cpBg.value = rgb2hex(bg);
1118
- swBg.style.background = cpBg.value;
1119
- swBg.style.opacity = '1';
1120
- }
1121
- cpFg.value = rgb2hex(fg); swFg.style.background = cpFg.value;
1122
- // Border detect none/transparent
1123
- if (isTransparent(bd) || bw === '0px') {
1124
- cpBdTrans.checked = true;
1125
- swBd.style.background = 'repeating-conic-gradient(#444 0% 25%, #222 0% 50%) 0 0/10px 10px';
1126
- swBd.style.opacity = '0.6';
1127
- } else {
1128
- cpBdTrans.checked = false;
1129
- cpBd.value = rgb2hex(bd);
1130
- swBd.style.background = cpBd.value;
1131
- swBd.style.opacity = '1';
1132
- }
1133
-
1134
- // #11: Advanced styles detection
1135
- const opVal = parseFloat(cs.opacity) || 1;
1136
- document.getElementById('__st_opacity__').value = opVal;
1137
- document.getElementById('__st_opacity_val__').textContent = opVal.toFixed(2);
1138
- document.getElementById('__st_radius__').value = parseInt(cs.borderRadius) || 0;
1139
- document.getElementById('__st_shadow__').value = cs.boxShadow === 'none' ? 'none' : '0 4px 12px rgba(0,0,0,0.15)'; // simple match
1056
+ rzEl = null; rzDir = null;
1057
+ });
1058
+
1059
+ // ══════════════════════════════════════════
1060
+ // COLORS
1061
+ // ══════════════════════════════════════════
1062
+ const clrElName = document.getElementById('__clr_el_name__');
1063
+ const swBg = document.getElementById('__sw_bg__'), cpBg = document.getElementById('__cp_bg__');
1064
+ const swFg = document.getElementById('__sw_fg__'), cpFg = document.getElementById('__cp_fg__');
1065
+ const swBd = document.getElementById('__sw_bd__'), cpBd = document.getElementById('__cp_bd__');
1066
+ const cpBgTrans = document.getElementById('__cp_bg_trans__');
1067
+ const cpBdTrans = document.getElementById('__cp_bd_trans__');
1068
+
1069
+ function setupSwatch(sw, cp) {
1070
+ sw.onclick = () => cp.click();
1071
+ cp.oninput = () => { sw.style.background = cp.value; };
1072
+ }
1073
+ setupSwatch(swBg, cpBg);
1074
+ setupSwatch(swFg, cpFg);
1075
+ setupSwatch(swBd, cpBd);
1076
+
1077
+ // Transparent checkbox toggles swatch appearance
1078
+ cpBgTrans.onchange = () => {
1079
+ swBg.style.background = cpBgTrans.checked
1080
+ ? 'repeating-conic-gradient(#444 0% 25%, #222 0% 50%) 0 0/10px 10px'
1081
+ : cpBg.value;
1082
+ swBg.style.opacity = cpBgTrans.checked ? '0.6' : '1';
1083
+ };
1084
+ cpBdTrans.onchange = () => {
1085
+ swBd.style.background = cpBdTrans.checked
1086
+ ? 'repeating-conic-gradient(#444 0% 25%, #222 0% 50%) 0 0/10px 10px'
1087
+ : cpBd.value;
1088
+ swBd.style.opacity = cpBdTrans.checked ? '0.6' : '1';
1089
+ };
1090
+
1091
+ function isTransparent(color) {
1092
+ return !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)';
1093
+ }
1094
+
1095
+ function populateColors(el) {
1096
+ const cs = getComputedStyle(el);
1097
+ clrElName.textContent = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + [...el.classList][0] : '');
1098
+ let bg = cs.backgroundColor;
1099
+ let fg = cs.color || '#000000';
1100
+ let bd = cs.borderColor || '#cccccc';
1101
+ const bw = cs.borderWidth;
1102
+
1103
+ // #14: Support SVG fill/stroke (#14)
1104
+ const isSvg = el.namespaceURI === 'http://www.w3.org/2000/svg';
1105
+ if (isSvg) {
1106
+ fg = cs.fill;
1107
+ bd = cs.stroke;
1140
1108
  }
1141
1109
 
1142
- // #11: Live preview for advanced styles
1143
- document.getElementById('__st_opacity__').oninput = e => {
1144
- const v = e.target.value;
1145
- document.getElementById('__st_opacity_val__').textContent = parseFloat(v).toFixed(2);
1146
- if (state.selectedEl) state.selectedEl.style.opacity = v;
1147
- };
1148
- document.getElementById('__st_radius__').oninput = e => {
1149
- if (state.selectedEl) state.selectedEl.style.borderRadius = e.target.value + 'px';
1150
- };
1151
- document.getElementById('__st_shadow__').onchange = e => {
1152
- if (state.selectedEl) state.selectedEl.style.boxShadow = e.target.value === 'none' ? '' : e.target.value;
1153
- };
1154
-
1155
- document.getElementById('__clr_apply__').onclick = () => {
1156
- if (!state.selectedEl) { toast('⚠ Select an element first'); return; }
1157
- const el = state.selectedEl;
1158
- const bgVal = cpBgTrans.checked ? 'transparent' : cpBg.value;
1159
- const bdVal = cpBdTrans.checked ? 'none' : cpBd.value;
1160
- const opVal = document.getElementById('__st_opacity__').value;
1161
- const radVal = document.getElementById('__st_radius__').value + 'px';
1162
- const shVal = document.getElementById('__st_shadow__').value;
1163
-
1164
- const prevProps = {
1165
- 'background-color': el.style.backgroundColor || '',
1166
- 'color': el.style.color || '',
1167
- 'border': el.style.border || '',
1168
- 'opacity': el.style.opacity || '',
1169
- 'border-radius': el.style.borderRadius || '',
1170
- 'box-shadow': el.style.boxShadow || ''
1171
- };
1172
-
1173
- if (el.namespaceURI === 'http://www.w3.org/2000/svg') {
1174
- el.style.fill = cpFg.value;
1175
- el.style.stroke = cpBdTrans.checked ? 'none' : bdVal;
1176
- }
1177
-
1178
- el.style.backgroundColor = bgVal;
1179
- el.style.color = cpFg.value;
1180
- el.style.opacity = opVal;
1181
- el.style.borderRadius = radVal;
1182
- el.style.boxShadow = shVal === 'none' ? '' : shVal;
1183
-
1184
- if (cpBdTrans.checked) {
1185
- el.style.border = 'none';
1186
- } else {
1187
- el.style.borderColor = bdVal;
1188
- if (getComputedStyle(el).borderWidth === '0px') el.style.borderWidth = '1px';
1189
- if (getComputedStyle(el).borderStyle === 'none') el.style.borderStyle = 'solid';
1190
- }
1191
- rec(el, {
1192
- 'background-color': bgVal, color: cpFg.value,
1193
- 'border': cpBdTrans.checked ? 'none' : `1px solid ${bdVal}`,
1194
- 'opacity': opVal, 'border-radius': radVal, 'box-shadow': shVal === 'none' ? '' : shVal
1195
- }, prevProps);
1196
- toast('🎨 Styles applied!');
1197
- };
1198
-
1199
- function rgb2hex(rgb) {
1200
- if (rgb.startsWith('#')) return rgb;
1201
- const m = rgb.match(/\d+/g);
1202
- if (!m || m.length < 3) return '#000000';
1203
- return '#' + m.slice(0, 3).map(x => parseInt(x).toString(16).padStart(2, '0')).join('');
1204
- }
1205
-
1206
- // ══════════════════════════════════════════
1207
- // TYPOGRAPHY
1208
- // ══════════════════════════════════════════
1209
- const typElName = document.getElementById('__typ_el_name__');
1210
- const typSz = document.getElementById('__typ_sz__');
1211
- const typLh = document.getElementById('__typ_lh__');
1212
- const typLs = document.getElementById('__typ_ls__');
1213
- const typFont = document.getElementById('__typ_font__');
1214
- const typBold = document.getElementById('__typ_bold__');
1215
- const typItalic = document.getElementById('__typ_italic__');
1216
- const typUnder = document.getElementById('__typ_under__');
1217
- const typStrike = document.getElementById('__typ_strike__');
1218
- const typUpper = document.getElementById('__typ_upper__');
1219
- const typLower = document.getElementById('__typ_lower__');
1220
-
1221
- // Track toggle states
1222
- const typState = { bold: false, italic: false, under: false, strike: false, upper: false, lower: false };
1223
-
1224
- function setStyleBtn(btn, key, val) {
1225
- typState[key] = val;
1226
- btn.classList.toggle('on', val);
1110
+ // Background detect transparent
1111
+ if (isTransparent(bg)) {
1112
+ cpBgTrans.checked = true;
1113
+ swBg.style.background = 'repeating-conic-gradient(#444 0% 25%, #222 0% 50%) 0 0/10px 10px';
1114
+ swBg.style.opacity = '0.6';
1115
+ } else {
1116
+ cpBgTrans.checked = false;
1117
+ cpBg.value = rgb2hex(bg);
1118
+ swBg.style.background = cpBg.value;
1119
+ swBg.style.opacity = '1';
1227
1120
  }
1228
-
1229
- typBold.onclick = () => setStyleBtn(typBold, 'bold', !typState.bold);
1230
- typItalic.onclick = () => setStyleBtn(typItalic, 'italic', !typState.italic);
1231
- typUnder.onclick = () => setStyleBtn(typUnder, 'under', !typState.under);
1232
- typStrike.onclick = () => setStyleBtn(typStrike, 'strike', !typState.strike);
1233
- typUpper.onclick = () => { setStyleBtn(typUpper, 'upper', !typState.upper); if (typState.upper) setStyleBtn(typLower, 'lower', false); };
1234
- typLower.onclick = () => { setStyleBtn(typLower, 'lower', !typState.lower); if (typState.lower) setStyleBtn(typUpper, 'upper', false); };
1235
-
1236
- // Loaded Google Fonts cache
1237
- const loadedFonts = new Set();
1238
- function loadGoogleFont(name) {
1239
- if (!name || loadedFonts.has(name)) return;
1240
- loadedFonts.add(name);
1241
- const link = document.createElement('link');
1242
- link.rel = 'stylesheet';
1243
- link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(name)}:wght@400;700&display=swap`;
1244
- document.head.appendChild(link);
1121
+ cpFg.value = rgb2hex(fg); swFg.style.background = cpFg.value;
1122
+ // Border detect none/transparent
1123
+ if (isTransparent(bd) || bw === '0px') {
1124
+ cpBdTrans.checked = true;
1125
+ swBd.style.background = 'repeating-conic-gradient(#444 0% 25%, #222 0% 50%) 0 0/10px 10px';
1126
+ swBd.style.opacity = '0.6';
1127
+ } else {
1128
+ cpBdTrans.checked = false;
1129
+ cpBd.value = rgb2hex(bd);
1130
+ swBd.style.background = cpBd.value;
1131
+ swBd.style.opacity = '1';
1245
1132
  }
1246
1133
 
1247
- // Live preview when font changes
1248
- typFont.onchange = () => {
1249
- if (typFont.value) loadGoogleFont(typFont.value);
1134
+ // #11: Advanced styles detection
1135
+ const opVal = parseFloat(cs.opacity) || 1;
1136
+ document.getElementById('__st_opacity__').value = opVal;
1137
+ document.getElementById('__st_opacity_val__').textContent = opVal.toFixed(2);
1138
+ document.getElementById('__st_radius__').value = parseInt(cs.borderRadius) || 0;
1139
+ document.getElementById('__st_shadow__').value = cs.boxShadow === 'none' ? 'none' : '0 4px 12px rgba(0,0,0,0.15)'; // simple match
1140
+ }
1141
+
1142
+ // #11: Live preview for advanced styles
1143
+ document.getElementById('__st_opacity__').oninput = e => {
1144
+ const v = e.target.value;
1145
+ document.getElementById('__st_opacity_val__').textContent = parseFloat(v).toFixed(2);
1146
+ if (state.selectedEl) state.selectedEl.style.opacity = v;
1147
+ };
1148
+ document.getElementById('__st_radius__').oninput = e => {
1149
+ if (state.selectedEl) state.selectedEl.style.borderRadius = e.target.value + 'px';
1150
+ };
1151
+ document.getElementById('__st_shadow__').onchange = e => {
1152
+ if (state.selectedEl) state.selectedEl.style.boxShadow = e.target.value === 'none' ? '' : e.target.value;
1153
+ };
1154
+
1155
+ document.getElementById('__clr_apply__').onclick = () => {
1156
+ if (!state.selectedEl) { toast('⚠ Select an element first'); return; }
1157
+ const el = state.selectedEl;
1158
+ const bgVal = cpBgTrans.checked ? 'transparent' : cpBg.value;
1159
+ const bdVal = cpBdTrans.checked ? 'none' : cpBd.value;
1160
+ const opVal = document.getElementById('__st_opacity__').value;
1161
+ const radVal = document.getElementById('__st_radius__').value + 'px';
1162
+ const shVal = document.getElementById('__st_shadow__').value;
1163
+
1164
+ const prevProps = {
1165
+ 'background-color': el.style.backgroundColor || '',
1166
+ 'color': el.style.color || '',
1167
+ 'border': el.style.border || '',
1168
+ 'opacity': el.style.opacity || '',
1169
+ 'border-radius': el.style.borderRadius || '',
1170
+ 'box-shadow': el.style.boxShadow || ''
1250
1171
  };
1251
1172
 
1252
- function populateTypo(el) {
1253
- // Make text directly editable
1254
- if (!el.isContentEditable) {
1255
- el.contentEditable = 'true';
1256
- el.dataset.origText = el.innerHTML || '';
1257
- el.focus();
1258
-
1259
- const finishEdit = () => {
1260
- el.contentEditable = 'false';
1261
- el.removeEventListener('blur', finishEdit);
1262
- if (el.innerHTML !== el.dataset.origText) {
1263
- rec(el, { innerHTML: el.innerHTML }, { innerHTML: el.dataset.origText });
1264
- toast('Text updated!');
1265
- }
1266
- };
1267
- el.addEventListener('blur', finishEdit);
1268
- }
1269
-
1270
- const cs = getComputedStyle(el);
1271
- typElName.textContent = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + [...el.classList].filter(c => !c.startsWith('__'))[0] : '');
1272
- typSz.value = Math.round(parseFloat(cs.fontSize)) || 16;
1273
- typLh.value = parseFloat(cs.lineHeight) ? (parseFloat(cs.lineHeight) / parseFloat(cs.fontSize)).toFixed(1) : '1.5';
1274
- typLs.value = parseFloat(cs.letterSpacing) || 0;
1275
- typFont.value = '';
1276
-
1277
- // Detect current styles
1278
- setStyleBtn(typBold, 'bold', parseInt(cs.fontWeight) >= 700);
1279
- setStyleBtn(typItalic, 'italic', cs.fontStyle === 'italic');
1280
- setStyleBtn(typUnder, 'under', cs.textDecoration.includes('underline'));
1281
- setStyleBtn(typStrike, 'strike', cs.textDecoration.includes('line-through'));
1282
- setStyleBtn(typUpper, 'upper', cs.textTransform === 'uppercase');
1283
- setStyleBtn(typLower, 'lower', cs.textTransform === 'lowercase');
1284
-
1285
- // Live preview on input change
1286
- [typSz, typLh, typLs].forEach(inp => { inp.oninput = () => previewTypo(el); });
1173
+ if (el.namespaceURI === 'http://www.w3.org/2000/svg') {
1174
+ el.style.fill = cpFg.value;
1175
+ el.style.stroke = cpBdTrans.checked ? 'none' : bdVal;
1287
1176
  }
1288
1177
 
1289
- function previewTypo(el) {
1290
- el.style.fontSize = typSz.value + 'px';
1291
- el.style.lineHeight = typLh.value;
1292
- el.style.letterSpacing = typLs.value + 'px';
1293
- el.style.setProperty('font-weight', typState.bold ? '700' : '400', 'important');
1294
- el.style.setProperty('font-style', typState.italic ? 'italic' : 'normal', 'important');
1295
- const deco = [typState.under && 'underline', typState.strike && 'line-through'].filter(Boolean).join(' ') || 'none';
1296
- el.style.setProperty('text-decoration', deco, 'important');
1297
- el.style.textTransform = typState.upper ? 'uppercase' : typState.lower ? 'lowercase' : 'none';
1298
- if (typFont.value) {
1299
- loadGoogleFont(typFont.value);
1300
- el.style.fontFamily = `'${typFont.value}', sans-serif`;
1301
- }
1178
+ el.style.backgroundColor = bgVal;
1179
+ el.style.color = cpFg.value;
1180
+ el.style.opacity = opVal;
1181
+ el.style.borderRadius = radVal;
1182
+ el.style.boxShadow = shVal === 'none' ? '' : shVal;
1183
+
1184
+ if (cpBdTrans.checked) {
1185
+ el.style.border = 'none';
1186
+ } else {
1187
+ el.style.borderColor = bdVal;
1188
+ if (getComputedStyle(el).borderWidth === '0px') el.style.borderWidth = '1px';
1189
+ if (getComputedStyle(el).borderStyle === 'none') el.style.borderStyle = 'solid';
1302
1190
  }
1303
-
1304
- document.getElementById('__typ_apply__').onclick = () => {
1305
- if (!state.selectedEl) { toast(' Select a text element first'); return; }
1306
- const el = state.selectedEl;
1307
- const deco = [typState.under && 'underline', typState.strike && 'line-through'].filter(Boolean).join(' ') || 'none';
1308
- const props = {
1309
- 'font-size': typSz.value + 'px',
1310
- 'font-weight': typState.bold ? '700' : '400',
1311
- 'font-style': typState.italic ? 'italic' : 'normal',
1312
- 'text-decoration': deco,
1313
- 'text-transform': typState.upper ? 'uppercase' : typState.lower ? 'lowercase' : 'none',
1314
- 'line-height': typLh.value,
1315
- 'letter-spacing': typLs.value + 'px',
1316
- };
1317
- if (typFont.value) {
1318
- props['font-family'] = `'${typFont.value}', sans-serif`;
1319
- props['googleFont'] = typFont.value;
1320
- }
1321
- // Snapshot BEFORE previewTypo applies styles
1322
- const prevProps = {};
1323
- Object.keys(props).forEach(p => {
1324
- const camel = p.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1325
- prevProps[p] = el.style[camel] || '';
1326
- });
1327
- previewTypo(el);
1328
- rec(el, props, prevProps);
1329
- toast('📝 Typography applied!');
1330
- };
1331
-
1332
- // ══════════════════════════════════════════
1333
- // ASSETS
1334
- // ══════════════════════════════════════════
1335
- const astDrop = document.getElementById('__ast_drop__');
1336
- const astFile = document.getElementById('__ast_file__');
1337
- const astList = document.getElementById('__ast_list__');
1338
- const astHint = document.getElementById('__ast_hint__');
1339
- const astStore = []; // { id, name, src, url }
1340
- let pendingAsset = null; // asset waiting to be placed
1341
- let placingEl = null; // the ghost element following cursor
1342
- let astDragEl = null; // placed asset being moved
1343
- let astDragSX, astDragSY, astDragOL, astDragOT;
1344
-
1345
- // Upload via click or drop
1346
- astDrop.onclick = () => astFile.click();
1347
- astFile.onchange = e => loadAssets(e.target.files);
1348
- astDrop.ondragover = e => { e.preventDefault(); astDrop.style.borderColor = '#7fff6e'; };
1349
- astDrop.ondragleave = () => { astDrop.style.borderColor = ''; };
1350
- astDrop.ondrop = e => {
1351
- e.preventDefault(); astDrop.style.borderColor = '';
1352
- loadAssets(e.dataTransfer.files);
1353
- };
1354
-
1355
- function loadAssets(files) {
1356
- [...files].forEach(file => {
1357
- if (!file.type.startsWith('image/')) return;
1358
- const reader = new FileReader();
1359
- reader.onload = async ev => {
1360
- try {
1361
- const res = await fetch('/draply-upload', {
1362
- method: 'POST',
1363
- body: JSON.stringify({ name: file.name, base64: ev.target.result })
1364
- });
1365
- const data = await res.json();
1366
- if (data.ok) {
1367
- const asset = { id: Date.now() + Math.random(), name: file.name, src: data.url };
1368
- astStore.push(asset);
1369
- addThumb(asset);
1370
- astHint.style.display = 'block';
1371
- toast('🖼️ ' + file.name + ' loaded');
1372
- } else {
1373
- toast(' Upload failed');
1374
- }
1375
- } catch(e) {
1376
- toast(' Upload failed');
1377
- }
1378
- };
1379
- reader.readAsDataURL(file);
1380
- });
1381
- }
1382
-
1383
- function addThumb(asset) {
1384
- const img = document.createElement('img');
1385
- img.className = 'ps-ast-thumb';
1386
- img.src = asset.src;
1387
- img.title = asset.name;
1388
- img.dataset.assetId = asset.id;
1389
- img.onclick = () => {
1390
- // Deselect all thumbs
1391
- document.querySelectorAll('.ps-ast-thumb').forEach(t => t.classList.remove('active'));
1392
- img.classList.add('active');
1393
- startPlacing(asset);
1191
+ rec(el, {
1192
+ 'background-color': bgVal, color: cpFg.value,
1193
+ 'border': cpBdTrans.checked ? 'none' : `1px solid ${bdVal}`,
1194
+ 'opacity': opVal, 'border-radius': radVal, 'box-shadow': shVal === 'none' ? '' : shVal
1195
+ }, prevProps);
1196
+ toast('🎨 Styles applied!');
1197
+ };
1198
+
1199
+ function rgb2hex(rgb) {
1200
+ if (rgb.startsWith('#')) return rgb;
1201
+ const m = rgb.match(/\d+/g);
1202
+ if (!m || m.length < 3) return '#000000';
1203
+ return '#' + m.slice(0, 3).map(x => parseInt(x).toString(16).padStart(2, '0')).join('');
1204
+ }
1205
+
1206
+ // ══════════════════════════════════════════
1207
+ // TYPOGRAPHY
1208
+ // ══════════════════════════════════════════
1209
+ const typElName = document.getElementById('__typ_el_name__');
1210
+ const typSz = document.getElementById('__typ_sz__');
1211
+ const typLh = document.getElementById('__typ_lh__');
1212
+ const typLs = document.getElementById('__typ_ls__');
1213
+ const typFont = document.getElementById('__typ_font__');
1214
+ const typBold = document.getElementById('__typ_bold__');
1215
+ const typItalic = document.getElementById('__typ_italic__');
1216
+ const typUnder = document.getElementById('__typ_under__');
1217
+ const typStrike = document.getElementById('__typ_strike__');
1218
+ const typUpper = document.getElementById('__typ_upper__');
1219
+ const typLower = document.getElementById('__typ_lower__');
1220
+
1221
+ // Track toggle states
1222
+ const typState = { bold: false, italic: false, under: false, strike: false, upper: false, lower: false };
1223
+
1224
+ function setStyleBtn(btn, key, val) {
1225
+ typState[key] = val;
1226
+ btn.classList.toggle('on', val);
1227
+ }
1228
+
1229
+ typBold.onclick = () => setStyleBtn(typBold, 'bold', !typState.bold);
1230
+ typItalic.onclick = () => setStyleBtn(typItalic, 'italic', !typState.italic);
1231
+ typUnder.onclick = () => setStyleBtn(typUnder, 'under', !typState.under);
1232
+ typStrike.onclick = () => setStyleBtn(typStrike, 'strike', !typState.strike);
1233
+ typUpper.onclick = () => { setStyleBtn(typUpper, 'upper', !typState.upper); if (typState.upper) setStyleBtn(typLower, 'lower', false); };
1234
+ typLower.onclick = () => { setStyleBtn(typLower, 'lower', !typState.lower); if (typState.lower) setStyleBtn(typUpper, 'upper', false); };
1235
+
1236
+ // Loaded Google Fonts cache
1237
+ const loadedFonts = new Set();
1238
+ function loadGoogleFont(name) {
1239
+ if (!name || loadedFonts.has(name)) return;
1240
+ loadedFonts.add(name);
1241
+ const link = document.createElement('link');
1242
+ link.rel = 'stylesheet';
1243
+ link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(name)}:wght@400;700&display=swap`;
1244
+ document.head.appendChild(link);
1245
+ }
1246
+
1247
+ // Live preview when font changes
1248
+ typFont.onchange = () => {
1249
+ if (typFont.value) loadGoogleFont(typFont.value);
1250
+ };
1251
+
1252
+ function populateTypo(el) {
1253
+ // Make text directly editable
1254
+ if (!el.isContentEditable) {
1255
+ el.contentEditable = 'true';
1256
+ el.dataset.origText = el.innerHTML || '';
1257
+ el.focus();
1258
+
1259
+ const finishEdit = () => {
1260
+ el.contentEditable = 'false';
1261
+ el.removeEventListener('blur', finishEdit);
1262
+ if (el.innerHTML !== el.dataset.origText) {
1263
+ rec(el, { innerHTML: el.innerHTML }, { innerHTML: el.dataset.origText });
1264
+ toast('Text updated!');
1265
+ }
1394
1266
  };
1395
- astList.appendChild(img);
1267
+ el.addEventListener('blur', finishEdit);
1396
1268
  }
1397
1269
 
1398
- // Start placing: attach ghost image to cursor
1399
- function startPlacing(asset) {
1400
- cancelPlacing();
1401
- pendingAsset = asset;
1402
-
1403
- // Create ghost
1404
- placingEl = document.createElement('div');
1405
- placingEl.className = 'ps-asset-placed placing';
1406
- placingEl.style.cssText = 'width:120px;height:120px;pointer-events:none;opacity:.75;';
1407
- placingEl.innerHTML = `<img src="${asset.src}" draggable="false">`;
1408
- document.body.appendChild(placingEl);
1409
-
1410
- toast('Click anywhere on page to place');
1411
- document.addEventListener('mousemove', onPlacingMove);
1412
- document.addEventListener('click', onPlacingClick, true);
1413
- document.addEventListener('keydown', onPlacingCancel);
1270
+ const cs = getComputedStyle(el);
1271
+ typElName.textContent = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + [...el.classList].filter(c => !c.startsWith('__'))[0] : '');
1272
+ typSz.value = Math.round(parseFloat(cs.fontSize)) || 16;
1273
+ typLh.value = parseFloat(cs.lineHeight) ? (parseFloat(cs.lineHeight) / parseFloat(cs.fontSize)).toFixed(1) : '1.5';
1274
+ typLs.value = parseFloat(cs.letterSpacing) || 0;
1275
+ typFont.value = '';
1276
+
1277
+ // Detect current styles
1278
+ setStyleBtn(typBold, 'bold', parseInt(cs.fontWeight) >= 700);
1279
+ setStyleBtn(typItalic, 'italic', cs.fontStyle === 'italic');
1280
+ setStyleBtn(typUnder, 'under', cs.textDecoration.includes('underline'));
1281
+ setStyleBtn(typStrike, 'strike', cs.textDecoration.includes('line-through'));
1282
+ setStyleBtn(typUpper, 'upper', cs.textTransform === 'uppercase');
1283
+ setStyleBtn(typLower, 'lower', cs.textTransform === 'lowercase');
1284
+
1285
+ // Live preview on input change
1286
+ [typSz, typLh, typLs].forEach(inp => { inp.oninput = () => previewTypo(el); });
1287
+ }
1288
+
1289
+ function previewTypo(el) {
1290
+ el.style.fontSize = typSz.value + 'px';
1291
+ el.style.lineHeight = typLh.value;
1292
+ el.style.letterSpacing = typLs.value + 'px';
1293
+ el.style.setProperty('font-weight', typState.bold ? '700' : '400', 'important');
1294
+ el.style.setProperty('font-style', typState.italic ? 'italic' : 'normal', 'important');
1295
+ const deco = [typState.under && 'underline', typState.strike && 'line-through'].filter(Boolean).join(' ') || 'none';
1296
+ el.style.setProperty('text-decoration', deco, 'important');
1297
+ el.style.textTransform = typState.upper ? 'uppercase' : typState.lower ? 'lowercase' : 'none';
1298
+ if (typFont.value) {
1299
+ loadGoogleFont(typFont.value);
1300
+ el.style.fontFamily = `'${typFont.value}', sans-serif`;
1414
1301
  }
1415
-
1416
- function onPlacingMove(e) {
1417
- if (!placingEl) return;
1418
- placingEl.style.left = (e.clientX - 60) + 'px';
1419
- placingEl.style.top = (e.clientY - 60) + 'px';
1302
+ }
1303
+
1304
+ document.getElementById('__typ_apply__').onclick = () => {
1305
+ if (!state.selectedEl) { toast('⚠ Select a text element first'); return; }
1306
+ const el = state.selectedEl;
1307
+ const deco = [typState.under && 'underline', typState.strike && 'line-through'].filter(Boolean).join(' ') || 'none';
1308
+ const props = {
1309
+ 'font-size': typSz.value + 'px',
1310
+ 'font-weight': typState.bold ? '700' : '400',
1311
+ 'font-style': typState.italic ? 'italic' : 'normal',
1312
+ 'text-decoration': deco,
1313
+ 'text-transform': typState.upper ? 'uppercase' : typState.lower ? 'lowercase' : 'none',
1314
+ 'line-height': typLh.value,
1315
+ 'letter-spacing': typLs.value + 'px',
1316
+ };
1317
+ if (typFont.value) {
1318
+ props['font-family'] = `'${typFont.value}', sans-serif`;
1319
+ props['googleFont'] = typFont.value;
1420
1320
  }
1421
-
1422
- function onPlacingClick(e) {
1423
- if (ps(e.target)) return; // ignore clicks inside our panel
1424
- e.preventDefault(); e.stopPropagation();
1425
- if (!pendingAsset || !placingEl) return;
1426
-
1427
- if (e.shiftKey || e.altKey) {
1428
- // #6: Better insertion targeting
1429
- const target = e.target;
1430
- if (target.tagName.toLowerCase() === 'img') {
1431
- const prevSrc = target.getAttribute('src');
1432
- target.src = pendingAsset.src;
1433
- rec(target, { src: pendingAsset.src }, { src: prevSrc || '' });
1434
- toast('🖼️ Image replaced!');
1435
- } else {
1436
- // Option to insert INSIDE clicked container if Alt is held
1437
- if (e.altKey) {
1438
- placeAsset(pendingAsset, 0, 0, 120, 120, target);
1439
- toast('✦ Placed inside ' + target.tagName.toLowerCase());
1321
+ // Snapshot BEFORE previewTypo applies styles
1322
+ const prevProps = {};
1323
+ Object.keys(props).forEach(p => {
1324
+ const camel = p.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1325
+ prevProps[p] = el.style[camel] || '';
1326
+ });
1327
+ previewTypo(el);
1328
+ rec(el, props, prevProps);
1329
+ toast('📝 Typography applied!');
1330
+ };
1331
+
1332
+ // ══════════════════════════════════════════
1333
+ // ASSETS
1334
+ // ══════════════════════════════════════════
1335
+ const astDrop = document.getElementById('__ast_drop__');
1336
+ const astFile = document.getElementById('__ast_file__');
1337
+ const astList = document.getElementById('__ast_list__');
1338
+ const astHint = document.getElementById('__ast_hint__');
1339
+ const astStore = []; // { id, name, src, url }
1340
+ let pendingAsset = null; // asset waiting to be placed
1341
+ let placingEl = null; // the ghost element following cursor
1342
+ let astDragEl = null; // placed asset being moved
1343
+ let astDragSX, astDragSY, astDragOL, astDragOT;
1344
+
1345
+ // Upload via click or drop
1346
+ astDrop.onclick = () => astFile.click();
1347
+ astFile.onchange = e => loadAssets(e.target.files);
1348
+ astDrop.ondragover = e => { e.preventDefault(); astDrop.style.borderColor = '#7fff6e'; };
1349
+ astDrop.ondragleave = () => { astDrop.style.borderColor = ''; };
1350
+ astDrop.ondrop = e => {
1351
+ e.preventDefault(); astDrop.style.borderColor = '';
1352
+ loadAssets(e.dataTransfer.files);
1353
+ };
1354
+
1355
+ function loadAssets(files) {
1356
+ [...files].forEach(file => {
1357
+ if (!file.type.startsWith('image/')) return;
1358
+ const reader = new FileReader();
1359
+ reader.onload = async ev => {
1360
+ try {
1361
+ const res = await fetch('/draply-upload', {
1362
+ method: 'POST',
1363
+ body: JSON.stringify({ name: file.name, base64: ev.target.result })
1364
+ });
1365
+ const data = await res.json();
1366
+ if (data.ok) {
1367
+ const asset = { id: Date.now() + Math.random(), name: file.name, src: data.url };
1368
+ astStore.push(asset);
1369
+ addThumb(asset);
1370
+ astHint.style.display = 'block';
1371
+ toast('🖼️ ' + file.name + ' loaded');
1440
1372
  } else {
1441
- const cs = getComputedStyle(target);
1442
- const prevBg = cs.backgroundImage;
1443
- target.style.backgroundImage = `url('${pendingAsset.src}')`;
1444
- target.style.backgroundSize = 'cover';
1445
- rec(target, { backgroundImage: `url('${pendingAsset.src}')`, backgroundSize: 'cover' }, { backgroundImage: prevBg });
1446
- toast('🖼️ Background set!');
1373
+ toast('⚠ Upload failed');
1447
1374
  }
1375
+ } catch (e) {
1376
+ toast('⚠ Upload failed');
1448
1377
  }
1378
+ };
1379
+ reader.readAsDataURL(file);
1380
+ });
1381
+ }
1382
+
1383
+ function addThumb(asset) {
1384
+ const img = document.createElement('img');
1385
+ img.className = 'ps-ast-thumb';
1386
+ img.src = asset.src;
1387
+ img.title = asset.name;
1388
+ img.dataset.assetId = asset.id;
1389
+ img.onclick = () => {
1390
+ // Deselect all thumbs
1391
+ document.querySelectorAll('.ps-ast-thumb').forEach(t => t.classList.remove('active'));
1392
+ img.classList.add('active');
1393
+ startPlacing(asset);
1394
+ };
1395
+ astList.appendChild(img);
1396
+ }
1397
+
1398
+ // Start placing: attach ghost image to cursor
1399
+ function startPlacing(asset) {
1400
+ cancelPlacing();
1401
+ pendingAsset = asset;
1402
+
1403
+ // Create ghost
1404
+ placingEl = document.createElement('div');
1405
+ placingEl.className = 'ps-asset-placed placing';
1406
+ placingEl.style.cssText = 'width:120px;height:120px;pointer-events:none;opacity:.75;';
1407
+ placingEl.innerHTML = `<img src="${asset.src}" draggable="false">`;
1408
+ document.body.appendChild(placingEl);
1409
+
1410
+ toast('Click anywhere on page to place');
1411
+ document.addEventListener('mousemove', onPlacingMove);
1412
+ document.addEventListener('click', onPlacingClick, true);
1413
+ document.addEventListener('keydown', onPlacingCancel);
1414
+ }
1415
+
1416
+ function onPlacingMove(e) {
1417
+ if (!placingEl) return;
1418
+ placingEl.style.left = (e.clientX - 60) + 'px';
1419
+ placingEl.style.top = (e.clientY - 60) + 'px';
1420
+ }
1421
+
1422
+ function onPlacingClick(e) {
1423
+ if (ps(e.target)) return; // ignore clicks inside our panel
1424
+ e.preventDefault(); e.stopPropagation();
1425
+ if (!pendingAsset || !placingEl) return;
1426
+
1427
+ if (e.shiftKey || e.altKey) {
1428
+ // #6: Better insertion targeting
1429
+ const target = e.target;
1430
+ if (target.tagName.toLowerCase() === 'img') {
1431
+ const prevSrc = target.getAttribute('src');
1432
+ target.src = pendingAsset.src;
1433
+ rec(target, { src: pendingAsset.src }, { src: prevSrc || '' });
1434
+ toast('🖼️ Image replaced!');
1449
1435
  } else {
1450
- // PLACE mode (standard)
1451
- const x = e.clientX - 60;
1452
- const y = e.clientY - 60;
1453
- placeAsset(pendingAsset, x, y, 120, 120);
1436
+ // Option to insert INSIDE clicked container if Alt is held
1437
+ if (e.altKey) {
1438
+ placeAsset(pendingAsset, 0, 0, 120, 120, target);
1439
+ toast('✦ Placed inside ' + target.tagName.toLowerCase());
1440
+ } else {
1441
+ const cs = getComputedStyle(target);
1442
+ const prevBg = cs.backgroundImage;
1443
+ target.style.backgroundImage = `url('${pendingAsset.src}')`;
1444
+ target.style.backgroundSize = 'cover';
1445
+ rec(target, { backgroundImage: `url('${pendingAsset.src}')`, backgroundSize: 'cover' }, { backgroundImage: prevBg });
1446
+ toast('🖼️ Background set!');
1447
+ }
1454
1448
  }
1455
- cancelPlacing();
1456
- }
1457
- }
1458
-
1459
- function onPlacingCancel(e) {
1460
- if (e.key === 'Escape') cancelPlacing();
1461
- }
1462
-
1463
- function cancelPlacing() {
1464
- if (placingEl) { placingEl.remove(); placingEl = null; }
1465
- pendingAsset = null;
1466
- document.querySelectorAll('.ps-ast-thumb').forEach(t => t.classList.remove('active'));
1467
- document.removeEventListener('mousemove', onPlacingMove);
1468
- document.removeEventListener('click', onPlacingClick, true);
1469
- document.removeEventListener('keydown', onPlacingCancel);
1449
+ } else {
1450
+ // PLACE mode (standard)
1451
+ const x = e.clientX - 60;
1452
+ const y = e.clientY - 60;
1453
+ placeAsset(pendingAsset, x, y, 120, 120);
1470
1454
  }
1455
+ cancelPlacing();
1456
+ }
1457
+
1458
+ function onPlacingCancel(e) {
1459
+ if (e.key === 'Escape') cancelPlacing();
1460
+ }
1461
+
1462
+ function cancelPlacing() {
1463
+ if (placingEl) { placingEl.remove(); placingEl = null; }
1464
+ pendingAsset = null;
1465
+ document.querySelectorAll('.ps-ast-thumb').forEach(t => t.classList.remove('active'));
1466
+ document.removeEventListener('mousemove', onPlacingMove);
1467
+ document.removeEventListener('click', onPlacingClick, true);
1468
+ document.removeEventListener('keydown', onPlacingCancel);
1469
+ }
1470
+
1471
+ // #6: Place asset permanently on page (optionally inside a parent)
1472
+ function placeAsset(asset, x, y, w, h, parent = document.body) {
1473
+ const wrap = document.createElement('div');
1474
+ wrap.className = 'ps-asset-placed';
1475
+ const uid = 'ps-asset-' + Date.now();
1476
+ wrap.id = uid;
1477
+
1478
+ // If we have a specific parent, use relative positioning if needed
1479
+ const posType = parent === document.body ? 'fixed' : 'absolute';
1480
+ wrap.style.cssText = `position:${posType};left:${Math.round(x)}px;top:${Math.round(y)}px;width:${w}px;height:${h}px;z-index:1;`;
1481
+ wrap.innerHTML = `<img src="${asset.src}" draggable="false" alt="${asset.name}">`;
1482
+ parent.appendChild(wrap);
1483
+
1484
+
1485
+ // Click to select this placed asset (for z-index control)
1486
+ wrap.addEventListener('click', e => {
1487
+ if (ps(e.target)) return;
1488
+ selectPlaced(wrap);
1489
+ });
1471
1490
 
1472
- // #6: Place asset permanently on page (optionally inside a parent)
1473
- function placeAsset(asset, x, y, w, h, parent = document.body) {
1474
- const wrap = document.createElement('div');
1475
- wrap.className = 'ps-asset-placed';
1476
- const uid = 'ps-asset-' + Date.now();
1477
- wrap.id = uid;
1478
-
1479
- // If we have a specific parent, use relative positioning if needed
1480
- const posType = parent === document.body ? 'fixed' : 'absolute';
1481
- wrap.style.cssText = `position:${posType};left:${Math.round(x)}px;top:${Math.round(y)}px;width:${w}px;height:${h}px;z-index:1;`;
1482
- wrap.innerHTML = `<img src="${asset.src}" draggable="false" alt="${asset.name}">`;
1483
- parent.appendChild(wrap);
1484
-
1485
-
1486
- // Click to select this placed asset (for z-index control)
1487
- wrap.addEventListener('click', e => {
1488
- if (ps(e.target)) return;
1489
- selectPlaced(wrap);
1490
- });
1491
-
1492
- // Make placed asset draggable
1493
- wrap.addEventListener('mousedown', e => {
1494
- if (ps(e.target)) return;
1495
- e.preventDefault();
1496
- selectPlaced(wrap);
1497
- astDragEl = wrap;
1498
- astDragSX = e.clientX; astDragSY = e.clientY;
1499
- astDragOL = parseFloat(wrap.style.left) || 0;
1500
- astDragOT = parseFloat(wrap.style.top) || 0;
1501
- wrap.style.cursor = 'grabbing';
1502
- state.dragging = true;
1503
- });
1504
-
1505
- rec(wrap, {
1506
- src: asset.src,
1507
- left: Math.round(x) + 'px',
1508
- top: Math.round(y) + 'px',
1509
- width: w + 'px',
1510
- height: h + 'px',
1511
- 'z-index': '1',
1512
- }, null, true); // true = isCreate
1513
-
1514
- toast('✦ Placed — drag to reposition');
1491
+ // Make placed asset draggable
1492
+ wrap.addEventListener('mousedown', e => {
1493
+ if (ps(e.target)) return;
1494
+ e.preventDefault();
1515
1495
  selectPlaced(wrap);
1516
- return wrap;
1517
- }
1496
+ astDragEl = wrap;
1497
+ astDragSX = e.clientX; astDragSY = e.clientY;
1498
+ astDragOL = parseFloat(wrap.style.left) || 0;
1499
+ astDragOT = parseFloat(wrap.style.top) || 0;
1500
+ wrap.style.cursor = 'grabbing';
1501
+ state.dragging = true;
1502
+ });
1518
1503
 
1519
- // Track selected placed asset for z-index controls
1520
- let selectedPlaced = null;
1521
- const zCtrl = document.getElementById('__ast_zctrl__');
1522
- const zVal = document.getElementById('__z_val__');
1523
- const zFront = document.getElementById('__z_front__');
1524
- const zBack = document.getElementById('__z_back__');
1525
- const zSet = document.getElementById('__z_set__');
1526
-
1527
- function selectPlaced(wrap) {
1528
- // Deselect previous
1529
- if (selectedPlaced) selectedPlaced.style.outline = '';
1530
- selectedPlaced = wrap;
1531
- wrap.style.outline = '2px solid #7fff6e';
1532
- zVal.value = parseInt(wrap.style.zIndex) || 1;
1533
- zCtrl.style.display = 'block';
1504
+ rec(wrap, {
1505
+ src: asset.src,
1506
+ left: Math.round(x) + 'px',
1507
+ top: Math.round(y) + 'px',
1508
+ width: w + 'px',
1509
+ height: h + 'px',
1510
+ 'z-index': '1',
1511
+ }, null, true); // true = isCreate
1512
+
1513
+ toast('✦ Placed — drag to reposition');
1514
+ selectPlaced(wrap);
1515
+ return wrap;
1516
+ }
1517
+
1518
+ // Track selected placed asset for z-index controls
1519
+ let selectedPlaced = null;
1520
+ const zCtrl = document.getElementById('__ast_zctrl__');
1521
+ const zVal = document.getElementById('__z_val__');
1522
+ const zFront = document.getElementById('__z_front__');
1523
+ const zBack = document.getElementById('__z_back__');
1524
+ const zSet = document.getElementById('__z_set__');
1525
+
1526
+ function selectPlaced(wrap) {
1527
+ // Deselect previous
1528
+ if (selectedPlaced) selectedPlaced.style.outline = '';
1529
+ selectedPlaced = wrap;
1530
+ wrap.style.outline = '2px solid #7fff6e';
1531
+ zVal.value = parseInt(wrap.style.zIndex) || 1;
1532
+ zCtrl.style.display = 'block';
1533
+ }
1534
+
1535
+ zFront.onclick = () => {
1536
+ if (!selectedPlaced) return;
1537
+ // Find max z-index of all placed assets
1538
+ const max = Math.max(0, ...[...document.querySelectorAll('.ps-asset-placed')].map(el => parseInt(el.style.zIndex) || 0));
1539
+ const nz = max + 1;
1540
+ selectedPlaced.style.zIndex = nz;
1541
+ zVal.value = nz;
1542
+ rec(selectedPlaced, { 'z-index': String(nz) });
1543
+ toast('▲ Moved to front (z:' + nz + ')');
1544
+ };
1545
+
1546
+ zBack.onclick = () => {
1547
+ if (!selectedPlaced) return;
1548
+ const min = Math.min(0, ...[...document.querySelectorAll('.ps-asset-placed')].map(el => parseInt(el.style.zIndex) || 0));
1549
+ const nz = min - 1;
1550
+ selectedPlaced.style.zIndex = nz;
1551
+ zVal.value = nz;
1552
+ rec(selectedPlaced, { 'z-index': String(nz) });
1553
+ toast('▼ Moved to back (z:' + nz + ')');
1554
+ };
1555
+
1556
+ // #12: LAYERS PANEL LOGIC
1557
+ function updateLayers() {
1558
+ const list = document.getElementById('__lay_list__');
1559
+ list.innerHTML = '';
1560
+
1561
+ // Find interesting elements (headers, sections, divs with classes, images)
1562
+ const items = document.querySelectorAll('body > *:not(#__ps__), section *, header *, .container *');
1563
+ const filtered = [...items].filter(el => {
1564
+ if (ps(el)) return false;
1565
+ return el.tagName !== 'SCRIPT' && el.tagName !== 'STYLE' && (el.children.length === 0 || el.id || el.className);
1566
+ }).slice(0, 50); // limit for performance
1567
+
1568
+ if (!filtered.length) {
1569
+ list.innerHTML = '<div style="color:#444;font-size:10px;text-align:center;padding:10px">No elements found</div>';
1570
+ return;
1534
1571
  }
1535
1572
 
1536
- zFront.onclick = () => {
1537
- if (!selectedPlaced) return;
1538
- // Find max z-index of all placed assets
1539
- const max = Math.max(0, ...[...document.querySelectorAll('.ps-asset-placed')].map(el => parseInt(el.style.zIndex) || 0));
1540
- const nz = max + 1;
1541
- selectedPlaced.style.zIndex = nz;
1542
- zVal.value = nz;
1543
- rec(selectedPlaced, { 'z-index': String(nz) });
1544
- toast('▲ Moved to front (z:' + nz + ')');
1545
- };
1546
-
1547
- zBack.onclick = () => {
1548
- if (!selectedPlaced) return;
1549
- const min = Math.min(0, ...[...document.querySelectorAll('.ps-asset-placed')].map(el => parseInt(el.style.zIndex) || 0));
1550
- const nz = min - 1;
1551
- selectedPlaced.style.zIndex = nz;
1552
- zVal.value = nz;
1553
- rec(selectedPlaced, { 'z-index': String(nz) });
1554
- toast('▼ Moved to back (z:' + nz + ')');
1555
- };
1556
-
1557
- // #12: LAYERS PANEL LOGIC
1558
- function updateLayers() {
1559
- const list = document.getElementById('__lay_list__');
1560
- list.innerHTML = '';
1561
-
1562
- // Find interesting elements (headers, sections, divs with classes, images)
1563
- const items = document.querySelectorAll('body > *:not(#__ps__), section *, header *, .container *');
1564
- const filtered = [...items].filter(el => {
1565
- if (ps(el)) return false;
1566
- return el.tagName !== 'SCRIPT' && el.tagName !== 'STYLE' && (el.children.length === 0 || el.id || el.className);
1567
- }).slice(0, 50); // limit for performance
1568
-
1569
- if (!filtered.length) {
1570
- list.innerHTML = '<div style="color:#444;font-size:10px;text-align:center;padding:10px">No elements found</div>';
1571
- return;
1573
+ filtered.forEach(el => {
1574
+ const row = document.createElement('div');
1575
+ row.style.cssText = 'padding:6px 8px;border-radius:4px;background:#1a1a2a;border:1px solid #2a2a3a;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:10px;color:#8080a8';
1576
+ if (state.selectedEl === el) {
1577
+ row.style.borderColor = '#7fff6e';
1578
+ row.style.background = 'rgba(127,255,110,0.1)';
1579
+ row.style.color = '#7fff6e';
1572
1580
  }
1573
-
1574
- filtered.forEach(el => {
1575
- const row = document.createElement('div');
1576
- row.style.cssText = 'padding:6px 8px;border-radius:4px;background:#1a1a2a;border:1px solid #2a2a3a;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:10px;color:#8080a8';
1577
- if (state.selectedEl === el) {
1578
- row.style.borderColor = '#7fff6e';
1579
- row.style.background = 'rgba(127,255,110,0.1)';
1580
- row.style.color = '#7fff6e';
1581
- }
1582
-
1583
- const icon = el.tagName === 'IMG' ? '🖼️' : el.tagName.match(/H[1-6]/) ? 'Tt' : '📦';
1584
- const name = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + el.className.split(' ')[0] : '');
1585
-
1586
- row.innerHTML = `<span>${icon}</span> <span style="flex:1;overflow:hidden;text-overflow:ellipsis">${name}</span>`;
1587
- row.onclick = () => {
1588
- select(el);
1589
- updateLayers();
1590
- };
1591
- list.appendChild(row);
1592
- });
1593
- }
1594
-
1595
- zSet.onclick = () => {
1596
- if (!selectedPlaced) return;
1597
- const nz = parseInt(zVal.value) || 0;
1598
- selectedPlaced.style.zIndex = nz;
1599
- rec(selectedPlaced, { 'z-index': String(nz) });
1600
- toast('z-index set to ' + nz);
1601
- };
1602
-
1603
- zVal.onkeydown = e => { if (e.key === 'Enter') zSet.click(); };
1604
-
1605
- // Drag placed assets
1606
- document.addEventListener('mousemove', e => {
1607
- if (!astDragEl || !state.dragging) return;
1608
- const dx = e.clientX - astDragSX, dy = e.clientY - astDragSY;
1609
- astDragEl.style.left = (astDragOL + dx) + 'px';
1610
- astDragEl.style.top = (astDragOT + dy) + 'px';
1611
- tip.textContent = `x:${Math.round(astDragOL + dx)} y:${Math.round(astDragOT + dy)}`;
1612
- tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 28) + 'px';
1613
- tip.classList.add('v');
1614
- });
1615
1581
 
1616
- document.addEventListener('mouseup', () => {
1617
- if (!astDragEl) return;
1618
- tip.classList.remove('v');
1619
- const cs = getComputedStyle(astDragEl);
1620
- const newLeft = Math.round(parseFloat(cs.left)) + 'px';
1621
- const newTop = Math.round(parseFloat(cs.top)) + 'px';
1622
- if (newLeft !== (astDragOL + 'px') || newTop !== (astDragOT + 'px')) {
1623
- rec(astDragEl, { left: newLeft, top: newTop });
1624
- }
1625
- astDragEl.style.cursor = 'grab';
1626
- astDragEl = null;
1627
- });
1582
+ const icon = el.tagName === 'IMG' ? '🖼️' : el.tagName.match(/H[1-6]/) ? 'Tt' : '📦';
1583
+ const name = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + el.className.split(' ')[0] : '');
1628
1584
 
1629
- // ══════════════════════════════════════════
1630
- // RECORD + SAVE
1631
- // ══════════════════════════════════════════
1632
- // Full history — every individual action
1633
- const history = [];
1634
-
1635
- function rec(el, props, prevPropsOverride, isCreate = false) {
1636
- const selector = el.dataset.pixelshiftId ? null : gsel(el);
1637
- const key = el.dataset.pixelshiftId || selector;
1638
-
1639
- // Use provided prevProps if given (snapshot taken before style was applied),
1640
- // otherwise snapshot current inline values (for tools like Colors/Typography that call rec before apply)
1641
- const prevProps = prevPropsOverride || {};
1642
- if (!prevPropsOverride) {
1643
- Object.keys(props).forEach(p => {
1644
- const camel = p.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1645
- prevProps[p] = el.style[camel] || '';
1585
+ row.innerHTML = `<span>${icon}</span> <span style="flex:1;overflow:hidden;text-overflow:ellipsis">${name}</span>`;
1586
+ row.onclick = () => {
1587
+ // Select element (inline logic — no separate select() function)
1588
+ document.querySelectorAll('.__ps__, .__ps_multi__').forEach(el2 => {
1589
+ el2.classList.remove('__ps__', '__ps_multi__');
1646
1590
  });
1647
- }
1591
+ state.selectedEls = [el];
1592
+ state.selectedEl = el;
1593
+ el.classList.add('__ps__');
1594
+ if (state.tool === 'mov') placeHdl(el);
1595
+ if (state.tool === 'rsz') placeRH(el);
1596
+ if (state.tool === 'clr') populateColors(el);
1597
+ if (state.tool === 'typ') populateTypo(el);
1598
+ updateLayers();
1599
+ };
1600
+ list.appendChild(row);
1601
+ });
1602
+ }
1603
+
1604
+ zSet.onclick = () => {
1605
+ if (!selectedPlaced) return;
1606
+ const nz = parseInt(zVal.value) || 0;
1607
+ selectedPlaced.style.zIndex = nz;
1608
+ rec(selectedPlaced, { 'z-index': String(nz) });
1609
+ toast('z-index set to ' + nz);
1610
+ };
1611
+
1612
+ zVal.onkeydown = e => { if (e.key === 'Enter') zSet.click(); };
1613
+
1614
+ // Drag placed assets
1615
+ document.addEventListener('mousemove', e => {
1616
+ if (!astDragEl || !state.dragging) return;
1617
+ const dx = e.clientX - astDragSX, dy = e.clientY - astDragSY;
1618
+ astDragEl.style.left = (astDragOL + dx) + 'px';
1619
+ astDragEl.style.top = (astDragOT + dy) + 'px';
1620
+ tip.textContent = `x:${Math.round(astDragOL + dx)} y:${Math.round(astDragOT + dy)}`;
1621
+ tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 28) + 'px';
1622
+ tip.classList.add('v');
1623
+ });
1624
+
1625
+ document.addEventListener('mouseup', () => {
1626
+ if (!astDragEl) return;
1627
+ tip.classList.remove('v');
1628
+ const cs = getComputedStyle(astDragEl);
1629
+ const newLeft = Math.round(parseFloat(cs.left)) + 'px';
1630
+ const newTop = Math.round(parseFloat(cs.top)) + 'px';
1631
+ if (newLeft !== (astDragOL + 'px') || newTop !== (astDragOT + 'px')) {
1632
+ rec(astDragEl, { left: newLeft, top: newTop });
1633
+ }
1634
+ astDragEl.style.cursor = 'grab';
1635
+ astDragEl = null;
1636
+ });
1637
+
1638
+ // ══════════════════════════════════════════
1639
+ // RECORD + SAVE
1640
+ // ══════════════════════════════════════════
1641
+ // Full history — every individual action
1642
+ const history = [];
1643
+ const redoHistory = [];
1644
+
1645
+ function rec(el, props, prevPropsOverride, isCreate = false) {
1646
+ const selector = el.dataset.pixelshiftId ? null : gsel(el);
1647
+ const key = el.dataset.pixelshiftId || selector;
1648
+
1649
+ // Use provided prevProps if given (snapshot taken before style was applied),
1650
+ // otherwise snapshot current inline values (for tools like Colors/Typography that call rec before apply)
1651
+ const prevProps = prevPropsOverride || {};
1652
+ if (!prevPropsOverride) {
1653
+ Object.keys(props).forEach(p => {
1654
+ const camel = p.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1655
+ prevProps[p] = el.style[camel] || '';
1656
+ });
1657
+ }
1648
1658
 
1649
- // Extract exact file from React Fiber if available
1650
- function getReactSource(element) {
1651
- for (const key in element) {
1652
- if (key.startsWith('__reactFiber$')) {
1653
- let fiber = element[key];
1654
- while (fiber) {
1655
- if (fiber._debugSource && fiber._debugSource.fileName) return fiber._debugSource.fileName;
1656
- fiber = fiber.return;
1657
- }
1659
+ // Extract exact file from React Fiber if available
1660
+ function getReactSource(element) {
1661
+ for (const key in element) {
1662
+ if (key.startsWith('__reactFiber$')) {
1663
+ let fiber = element[key];
1664
+ while (fiber) {
1665
+ if (fiber._debugSource && fiber._debugSource.fileName) return fiber._debugSource.fileName;
1666
+ fiber = fiber.return;
1658
1667
  }
1659
1668
  }
1660
- return null;
1661
1669
  }
1670
+ return null;
1671
+ }
1662
1672
 
1663
- // Merge into state.changes (for save/apply)
1664
- const ch = {
1665
- type: isCreate ? 'create' : (el.dataset.pixelshiftId ? 'inline' : 'css'),
1666
- isCreate,
1667
- pixelshiftId: el.dataset.pixelshiftId || null,
1668
- selector,
1669
- exactFile: getReactSource(el) || window.location.pathname.replace(/^\//, '') || null, // fallback for vanilla HTML (#7)
1670
- file: el.dataset.pixelshiftFile || null,
1671
- props,
1672
- tagName: el.tagName.toLowerCase(),
1673
- outerHTML: el.outerHTML // Send the whole element for 'create' actions
1674
- };
1675
- const i = state.changes.findIndex(c => (c.pixelshiftId || c.selector) === key);
1676
- if (i >= 0) Object.assign(state.changes[i].props, props); else state.changes.push(ch);
1673
+ // Merge into state.changes (for save/apply)
1674
+ const ch = {
1675
+ type: isCreate ? 'create' : (el.dataset.pixelshiftId ? 'inline' : 'css'),
1676
+ isCreate,
1677
+ pixelshiftId: el.dataset.pixelshiftId || null,
1678
+ selector,
1679
+ exactFile: getReactSource(el) || window.location.pathname.replace(/^\//, '') || null, // fallback for vanilla HTML (#7)
1680
+ file: el.dataset.pixelshiftFile || null,
1681
+ props,
1682
+ tagName: el.tagName.toLowerCase(),
1683
+ outerHTML: el.outerHTML // Send the whole element for 'create' actions
1684
+ };
1685
+ const i = state.changes.findIndex(c => (c.pixelshiftId || c.selector) === key);
1686
+ if (i >= 0) Object.assign(state.changes[i].props, props); else state.changes.push(ch);
1677
1687
 
1678
- // Deep check: only record if at least one property actually changed
1679
- let changed = isCreate;
1680
- if (!isCreate) {
1681
- for (const p in props) {
1682
- if (String(props[p]) !== String(prevProps[p])) { changed = true; break; }
1683
- }
1688
+ // Deep check: only record if at least one property actually changed
1689
+ let changed = isCreate;
1690
+ if (!isCreate) {
1691
+ for (const p in props) {
1692
+ if (String(props[p]) !== String(prevProps[p])) { changed = true; break; }
1684
1693
  }
1685
- if (!changed) return;
1686
-
1687
- // Push to history
1688
- const hid = Date.now() + Math.random();
1689
- history.push({ hid, el, props, prevProps, selector: key, isCreate });
1690
-
1691
- updateUnsUI();
1692
1694
  }
1693
-
1694
- function updateUnsUI() {
1695
- const n = state.changes.length;
1696
- sv.disabled = n === 0; nb.textContent = history.length;
1697
- uns.style.display = history.length ? 'flex' : 'none';
1698
-
1699
- // Rebuild history list (newest first)
1700
- unsList.innerHTML = '';
1701
- [...history].reverse().forEach(h => {
1702
- const propStr = Object.entries(h.props).map(([k, v]) => `${k}: ${v}`).join(', ');
1703
- const row = document.createElement('div');
1704
- row.style.cssText = 'display:flex;align-items:flex-start;gap:6px;background:#0d0d1a;border:1px solid #1e1e3a;border-radius:5px;padding:6px 8px;font-size:9px;';
1705
- row.innerHTML = `
1695
+ if (!changed) return;
1696
+
1697
+ // Push to history
1698
+ const hid = Date.now() + Math.random();
1699
+ history.push({
1700
+ hid, el, props, prevProps, selector: key, isCreate,
1701
+ parent: isCreate ? el.parentElement : null,
1702
+ nextSibling: isCreate ? el.nextElementSibling : null
1703
+ });
1704
+
1705
+ // Clear redo stack on new action
1706
+ redoHistory.length = 0;
1707
+
1708
+ updateUnsUI();
1709
+ }
1710
+
1711
+ function updateUnsUI() {
1712
+ const n = state.changes.length;
1713
+ sv.disabled = n === 0; nb.textContent = history.length;
1714
+ uns.style.display = history.length ? 'flex' : 'none';
1715
+
1716
+ // Rebuild history list (newest first)
1717
+ unsList.innerHTML = '';
1718
+ [...history].reverse().forEach(h => {
1719
+ const propStr = Object.entries(h.props).map(([k, v]) => `${k}: ${v}`).join(', ');
1720
+ const row = document.createElement('div');
1721
+ row.style.cssText = 'display:flex;align-items:flex-start;gap:6px;background:#0d0d1a;border:1px solid #1e1e3a;border-radius:5px;padding:6px 8px;font-size:9px;';
1722
+ row.innerHTML = `
1706
1723
  <div style="flex:1;overflow:hidden">
1707
1724
  <div style="color:#7fff6e;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${h.selector || 'placed element'}</div>
1708
1725
  <div style="color:#555577;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${propStr}</div>
1709
1726
  </div>
1710
1727
  <button data-hid="${h.hid}" style="background:none;border:1px solid #2a2a44;color:#555577;border-radius:3px;padding:2px 6px;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0">↩ revert</button>
1711
1728
  `;
1712
- row.querySelector('button').onclick = () => revertChange(h);
1713
- unsList.appendChild(row);
1729
+ row.querySelector('button').onclick = () => revertChange(h);
1730
+ unsList.appendChild(row);
1731
+ });
1732
+ }
1733
+
1734
+ function revertChange(h) {
1735
+ if (h.isCreate) {
1736
+ h.el.remove();
1737
+ } else {
1738
+ // Re-apply previous inline values
1739
+ Object.entries(h.prevProps).forEach(([prop, val]) => {
1740
+ const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1741
+ h.el.style[camel] = val;
1714
1742
  });
1743
+ // Also handle text/html
1744
+ if (h.prevProps.innerHTML !== undefined) h.el.innerHTML = h.prevProps.innerHTML;
1745
+ if (h.prevProps.innerText !== undefined) h.el.innerText = h.prevProps.innerText;
1715
1746
  }
1716
-
1717
- function revertChange(h) {
1718
- if (h.isCreate) {
1719
- h.el.remove();
1747
+ // Remove from history and add to redo stack
1748
+ const idx = history.findIndex(x => x.hid === h.hid);
1749
+ if (idx >= 0) {
1750
+ redoHistory.push(history[idx]);
1751
+ history.splice(idx, 1);
1752
+ }
1753
+ // Rebuild state.changes — preserve all original fields (#1, #2)
1754
+ state.changes = [];
1755
+ history.forEach(x => {
1756
+ const key = x.selector;
1757
+ const i = state.changes.findIndex(c => (c.pixelshiftId || c.selector) === key);
1758
+ if (i >= 0) {
1759
+ Object.assign(state.changes[i].props, x.props);
1720
1760
  } else {
1721
- // Re-apply previous inline values
1722
- Object.entries(h.prevProps).forEach(([prop, val]) => {
1723
- const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1724
- h.el.style[camel] = val;
1761
+ state.changes.push({
1762
+ type: x.isCreate ? 'create' : 'css',
1763
+ isCreate: x.isCreate || false,
1764
+ selector: key,
1765
+ props: { ...x.props },
1766
+ outerHTML: x.isCreate && x.el ? x.el.outerHTML : undefined,
1767
+ tagName: x.el ? x.el.tagName?.toLowerCase() : undefined
1725
1768
  });
1726
- // Also handle text/html
1727
- if (h.prevProps.innerHTML !== undefined) h.el.innerHTML = h.prevProps.innerHTML;
1728
- if (h.prevProps.innerText !== undefined) h.el.innerText = h.prevProps.innerText;
1729
1769
  }
1730
- // Remove from history
1731
- const idx = history.findIndex(x => x.hid === h.hid);
1732
- if (idx >= 0) history.splice(idx, 1);
1733
- // Rebuild state.changes — preserve all original fields (#1, #2)
1734
- state.changes = [];
1735
- history.forEach(x => {
1736
- const key = x.selector;
1737
- const i = state.changes.findIndex(c => (c.pixelshiftId || c.selector) === key);
1738
- if (i >= 0) {
1739
- Object.assign(state.changes[i].props, x.props);
1770
+ });
1771
+ updateUnsUI();
1772
+ toast('↩ Reverted');
1773
+ }
1774
+
1775
+ function redoChange() {
1776
+ if (redoHistory.length === 0) {
1777
+ toast('Nothing to redo');
1778
+ return;
1779
+ }
1780
+ const h = redoHistory.pop();
1781
+
1782
+ if (h.isCreate) {
1783
+ if (h.parent) {
1784
+ if (h.nextSibling) {
1785
+ h.parent.insertBefore(h.el, h.nextSibling);
1740
1786
  } else {
1741
- state.changes.push({
1742
- type: x.isCreate ? 'create' : 'css',
1743
- isCreate: x.isCreate || false,
1744
- selector: key,
1745
- props: { ...x.props },
1746
- outerHTML: x.isCreate && x.el ? x.el.outerHTML : undefined,
1747
- tagName: x.el ? x.el.tagName?.toLowerCase() : undefined
1748
- });
1787
+ h.parent.appendChild(h.el);
1749
1788
  }
1789
+ } else {
1790
+ document.body.appendChild(h.el);
1791
+ }
1792
+ } else {
1793
+ // Re-apply properties
1794
+ Object.entries(h.props).forEach(([prop, val]) => {
1795
+ const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1796
+ h.el.style[camel] = val;
1750
1797
  });
1751
- updateUnsUI();
1752
- toast('↩ Reverted');
1798
+ if (h.props.innerHTML !== undefined) h.el.innerHTML = h.props.innerHTML;
1799
+ if (h.props.innerText !== undefined) h.el.innerText = h.props.innerText;
1753
1800
  }
1801
+
1802
+ history.push(h);
1754
1803
 
1755
- sv.addEventListener('click', async () => {
1756
- // Check key config status
1757
- let hasKey = false;
1758
- let cfgProvider = 'groq';
1759
- try {
1760
- const cfgRes = await fetch('/draply-config');
1761
- const cfg = await cfgRes.json();
1762
- hasKey = cfg.hasKey;
1763
- cfgProvider = cfg.provider || 'groq';
1764
- } catch (e) {}
1765
-
1766
- if (!hasKey) {
1767
- // Ask for provider first (#8)
1768
- const provider = prompt('Draply AI Save: Choose provider (groq / openai / anthropic / ollama):', 'groq');
1769
- if (!provider) { toast('Save aborted'); return; }
1770
- const key = provider === 'ollama' ? 'local' : prompt(`Enter your ${provider.toUpperCase()} API key:`);
1771
- if (key) {
1772
- sv.disabled = true; sv.textContent = 'Validating...';
1773
- try {
1774
- const vRes = await fetch('/draply-validate-key', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ apiKey: key.trim(), provider: provider.trim() }) });
1775
- const vData = await vRes.json();
1776
- if (!vData.valid && provider !== 'ollama') {
1777
- toast('⚠ Invalid API key'); sv.disabled = false; sv.textContent = 'Save'; return;
1778
- }
1779
- } catch { /* allow through if validation endpoint unavailable */ }
1780
- await fetch('/draply-config', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ apiKey: key.trim(), provider: provider.trim() }) });
1781
- sv.disabled = false; sv.textContent = 'Save';
1782
- } else {
1783
- toast('Save aborted: API Key required');
1784
- return;
1785
- }
1786
- }
1787
-
1788
- // Progress indicator with animation (#9)
1789
- sv.disabled = true;
1790
- sv.textContent = '';
1791
- sv.innerHTML = '<span style="display:inline-flex;align-items:center;gap:4px"><span style="animation:spin 1s linear infinite;display:inline-block">@</span> Applying...</span>';
1792
- // Add spin keyframe if not exists
1793
- if (!document.getElementById('__ps_spin_style__')) {
1794
- const spinStyle = document.createElement('style');
1795
- spinStyle.id = '__ps_spin_style__';
1796
- spinStyle.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
1797
- document.head.appendChild(spinStyle);
1798
- }
1799
- try {
1800
- const r = await fetch('/draply-ai-apply', {
1801
- method: 'POST', headers: { 'Content-Type': 'application/json' },
1802
- body: JSON.stringify({ changes: state.changes })
1804
+ // Rebuild state.changes
1805
+ state.changes = [];
1806
+ history.forEach(x => {
1807
+ const key = x.selector;
1808
+ const i = state.changes.findIndex(c => (c.pixelshiftId || c.selector) === key);
1809
+ if (i >= 0) {
1810
+ Object.assign(state.changes[i].props, x.props);
1811
+ } else {
1812
+ state.changes.push({
1813
+ type: x.isCreate ? 'create' : 'css',
1814
+ isCreate: x.isCreate || false,
1815
+ selector: key,
1816
+ props: { ...x.props },
1817
+ outerHTML: x.isCreate && x.el ? x.el.outerHTML : undefined,
1818
+ tagName: x.el ? x.el.tagName?.toLowerCase() : undefined
1803
1819
  });
1804
- const d = await r.json();
1805
- if (d.ok) {
1806
- const msg = d.fallback ? 'Saved to draply.css (No AI Key)' : `✅ AI Applied ${d.applied || ''}/${d.total || ''} to Source!`;
1807
- toast(msg);
1808
- } else toast('⚠ Error: ' + (d.error || 'unknown'));
1809
- } catch {
1810
- toast('⚠ Server unreachable');
1811
1820
  }
1812
-
1813
- state.changes = []; history.length = 0;
1814
- sv.innerHTML = 'Save'; sv.textContent = 'Save';
1815
- updateUnsUI();
1816
1821
  });
1817
1822
 
1818
- document.getElementById('__uns_clear__').onclick = () => {
1819
- state.changes = []; history.length = 0; updateUnsUI();
1820
- toast('🗑 History cleared');
1821
- };
1823
+ updateUnsUI();
1824
+ toast('↷ Redone');
1825
+ }
1826
+
1827
+ sv.addEventListener('click', async () => {
1828
+ // Check key config status
1829
+ let hasKey = false;
1830
+ let cfgProvider = 'groq';
1831
+ try {
1832
+ const cfgRes = await fetch('/draply-config');
1833
+ const cfg = await cfgRes.json();
1834
+ hasKey = cfg.hasKey;
1835
+ cfgProvider = cfg.provider || 'groq';
1836
+ } catch (e) { }
1837
+
1838
+ if (!hasKey) {
1839
+ // Ask for provider first (#8)
1840
+ const provider = prompt('Draply AI Save: Choose provider (groq / openai / anthropic / ollama):', 'groq');
1841
+ if (!provider) { toast('Save aborted'); return; }
1842
+ const key = provider === 'ollama' ? 'local' : prompt(`Enter your ${provider.toUpperCase()} API key:`);
1843
+ if (key) {
1844
+ sv.disabled = true; sv.textContent = 'Validating...';
1845
+ try {
1846
+ const vRes = await fetch('/draply-validate-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: key.trim(), provider: provider.trim() }) });
1847
+ const vData = await vRes.json();
1848
+ if (!vData.valid && provider !== 'ollama') {
1849
+ toast('⚠ Invalid API key'); sv.disabled = false; sv.textContent = 'Save'; return;
1850
+ }
1851
+ } catch { /* allow through if validation endpoint unavailable */ }
1852
+ await fetch('/draply-config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: key.trim(), provider: provider.trim() }) });
1853
+ sv.disabled = false; sv.textContent = 'Save';
1854
+ } else {
1855
+ toast('Save aborted: API Key required');
1856
+ return;
1857
+ }
1858
+ }
1859
+
1860
+ // Progress indicator with animation (#9)
1861
+ sv.disabled = true;
1862
+ sv.textContent = '';
1863
+ sv.innerHTML = '<span style="display:inline-flex;align-items:center;gap:4px"><span style="animation:spin 1s linear infinite;display:inline-block">@</span> Applying...</span>';
1864
+ // Add spin keyframe if not exists
1865
+ if (!document.getElementById('__ps_spin_style__')) {
1866
+ const spinStyle = document.createElement('style');
1867
+ spinStyle.id = '__ps_spin_style__';
1868
+ spinStyle.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
1869
+ document.head.appendChild(spinStyle);
1870
+ }
1871
+ try {
1872
+ const r = await fetch('/draply-ai-apply', {
1873
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1874
+ body: JSON.stringify({ changes: state.changes })
1875
+ });
1876
+ const d = await r.json();
1877
+ if (d.ok) {
1878
+ const msg = d.fallback ? 'Saved to draply.css (No AI Key)' : `✅ AI Applied ${d.applied || ''}/${d.total || ''} to Source!`;
1879
+ toast(msg);
1880
+ } else toast('⚠ Error: ' + (d.error || 'unknown'));
1881
+ } catch {
1882
+ toast('⚠ Server unreachable');
1883
+ }
1822
1884
 
1823
- document.getElementById('__uns_save__').onclick = () => sv.click();
1885
+ state.changes = []; history.length = 0; redoHistory.length = 0;
1886
+ sv.innerHTML = 'Save'; sv.textContent = 'Save';
1887
+ updateUnsUI();
1888
+ });
1824
1889
 
1825
- // ══════════════════════════════════════════
1826
- // KEYBOARD SHORTCUTS (#4, #5, #15)
1827
- // ══════════════════════════════════════════
1828
- document.addEventListener('keydown', e => {
1829
- // Ignore if typing in an input/textarea/contenteditable
1830
- const tag = e.target.tagName.toLowerCase();
1831
- if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
1890
+ document.getElementById('__uns_clear__').onclick = () => {
1891
+ state.changes = []; history.length = 0; redoHistory.length = 0; updateUnsUI();
1892
+ toast('🗑 History cleared');
1893
+ };
1832
1894
 
1833
- // Ctrl+Z Undo last change (#4)
1834
- if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
1835
- e.preventDefault();
1836
- if (history.length > 0) {
1837
- revertChange(history[history.length - 1]);
1838
- } else {
1839
- toast('Nothing to undo');
1840
- }
1841
- return;
1842
- }
1895
+ document.getElementById('__uns_save__').onclick = () => sv.click();
1843
1896
 
1844
- // Ctrl+Shift+Z — Redo (not implemented yet, just prevent default)
1845
- if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') {
1846
- e.preventDefault();
1847
- toast('Redo not available yet');
1848
- return;
1849
- }
1897
+ // ══════════════════════════════════════════
1898
+ // KEYBOARD SHORTCUTS (#4, #5, #15)
1899
+ // ══════════════════════════════════════════
1900
+ document.addEventListener('keydown', e => {
1901
+ // Ignore if typing in an input/textarea/contenteditable
1902
+ const tag = e.target.tagName.toLowerCase();
1903
+ if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
1850
1904
 
1851
- // Delete / Backspace remove selected element (#5)
1852
- if (e.key === 'Delete' || e.key === 'Backspace') {
1853
- if (state.selectedEl && !ps(state.selectedEl)) {
1854
- e.preventDefault();
1855
- const el = state.selectedEl;
1856
- // Record removal in history
1857
- rec(el, { display: 'none' }, { display: el.style.display || '' });
1858
- el.style.display = 'none';
1859
- // Deselect
1860
- el.classList.remove('__ps__', '__ps_multi__');
1861
- state.selectedEl = null;
1862
- state.selectedEls = [];
1863
- hdl.classList.remove('v');
1864
- toast('🗑 Element hidden (Delete)');
1865
- }
1866
- return;
1905
+ // Ctrl+ZUndo last change (#4)
1906
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'z') {
1907
+ e.preventDefault();
1908
+ if (history.length > 0) {
1909
+ revertChange(history[history.length - 1]);
1910
+ } else {
1911
+ toast('Nothing to undo');
1867
1912
  }
1913
+ return;
1914
+ }
1868
1915
 
1869
- // Preview mode toggle: P key (#15)
1870
- if (e.key === 'p' || e.key === 'P') {
1871
- if (!e.ctrlKey && !e.metaKey) {
1872
- e.preventDefault();
1873
- togglePreview();
1874
- }
1875
- return;
1876
- }
1877
- });
1916
+ // Ctrl+Shift+Z Redo (implemented)
1917
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'z') {
1918
+ e.preventDefault();
1919
+ redoChange();
1920
+ return;
1921
+ }
1878
1922
 
1879
- // ══════════════════════════════════════════
1880
- // PREVIEW MODE (#15)
1881
- // ══════════════════════════════════════════
1882
- let previewMode = false;
1883
- function togglePreview() {
1884
- previewMode = !previewMode;
1885
- const psRoot = document.getElementById('__ps__');
1886
- if (previewMode) {
1887
- psRoot.style.display = 'none';
1888
- // Remove all selection highlights
1889
- document.querySelectorAll('.__ps__, .__ps_multi__, .__ph__').forEach(el => {
1890
- el.classList.remove('__ps__', '__ps_multi__', '__ph__');
1891
- });
1923
+ // Delete / Backspace — remove selected element (#5)
1924
+ if (e.key === 'Delete' || e.key === 'Backspace') {
1925
+ if (state.selectedEl && !ps(state.selectedEl)) {
1926
+ e.preventDefault();
1927
+ const el = state.selectedEl;
1928
+ // Record removal in history
1929
+ rec(el, { display: 'none' }, { display: el.style.display || '' });
1930
+ el.style.display = 'none';
1931
+ // Deselect
1932
+ el.classList.remove('__ps__', '__ps_multi__');
1933
+ state.selectedEl = null;
1934
+ state.selectedEls = [];
1892
1935
  hdl.classList.remove('v');
1893
- Object.values(rhs).forEach(h => h.classList.remove('v'));
1894
- toast('👁 Preview mode — press P to exit');
1895
- } else {
1896
- psRoot.style.display = '';
1897
- toast('Editor restored');
1936
+ toast('🗑 Element hidden (Delete)');
1898
1937
  }
1938
+ return;
1899
1939
  }
1900
1940
 
1901
- // ══════════════════════════════════════════
1902
- // UTILS
1903
- // ══════════════════════════════════════════
1904
- function ps(el) { return el && el.closest('#__ps__'); }
1905
- function gsel(el) {
1906
- if (el.id) return '#' + el.id;
1907
- // Build unique path up the DOM
1908
- const parts = [];
1909
- let cur = el;
1910
- while (cur && cur !== document.body && cur !== document.documentElement) {
1911
- if (cur.id) { parts.unshift('#' + cur.id); break; }
1912
- const tag = cur.tagName.toLowerCase();
1913
- const cls = [...cur.classList].filter(c => !c.startsWith('__') && !c.startsWith('ps-')).join('.');
1914
- const siblings = cur.parentElement
1915
- ? [...cur.parentElement.children].filter(c => c.tagName === cur.tagName)
1916
- : [];
1917
- const idx = siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(cur) + 1})` : '';
1918
- parts.unshift(cls ? `${tag}.${cls}${idx}` : `${tag}${idx}`);
1919
- cur = cur.parentElement;
1941
+ // Preview mode toggle: P key (#15)
1942
+ if (e.key === 'p' || e.key === 'P') {
1943
+ if (!e.ctrlKey && !e.metaKey) {
1944
+ e.preventDefault();
1945
+ togglePreview();
1920
1946
  }
1921
- return parts.join(' > ');
1947
+ return;
1948
+ }
1949
+ });
1950
+
1951
+ // ══════════════════════════════════════════
1952
+ // PREVIEW MODE (#15)
1953
+ // ══════════════════════════════════════════
1954
+ let previewMode = false;
1955
+ function togglePreview() {
1956
+ previewMode = !previewMode;
1957
+ const psRoot = document.getElementById('__ps__');
1958
+ if (previewMode) {
1959
+ psRoot.style.display = 'none';
1960
+ // Remove all selection highlights
1961
+ document.querySelectorAll('.__ps__, .__ps_multi__, .__ph__').forEach(el => {
1962
+ el.classList.remove('__ps__', '__ps_multi__', '__ph__');
1963
+ });
1964
+ hdl.classList.remove('v');
1965
+ Object.values(rhs).forEach(h => h.classList.remove('v'));
1966
+ toast('👁 Preview mode — press P to exit');
1967
+ } else {
1968
+ psRoot.style.display = '';
1969
+ toast('Editor restored');
1922
1970
  }
1923
- function toast(msg) {
1924
- tst.textContent = msg; tst.classList.add('v');
1925
- clearTimeout(tst._t); tst._t = setTimeout(() => tst.classList.remove('v'), 2800);
1971
+ }
1972
+
1973
+ // ══════════════════════════════════════════
1974
+ // UTILS
1975
+ // ══════════════════════════════════════════
1976
+ function ps(el) { return el && el.closest('#__ps__'); }
1977
+ function gsel(el) {
1978
+ if (el.id) return '#' + el.id;
1979
+ // Build unique path up the DOM
1980
+ const parts = [];
1981
+ let cur = el;
1982
+ while (cur && cur !== document.body && cur !== document.documentElement) {
1983
+ if (cur.id) { parts.unshift('#' + cur.id); break; }
1984
+ const tag = cur.tagName.toLowerCase();
1985
+ const cls = [...cur.classList].filter(c => !c.startsWith('__') && !c.startsWith('ps-')).join('.');
1986
+ const siblings = cur.parentElement
1987
+ ? [...cur.parentElement.children].filter(c => c.tagName === cur.tagName)
1988
+ : [];
1989
+ const idx = siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(cur) + 1})` : '';
1990
+ parts.unshift(cls ? `${tag}.${cls}${idx}` : `${tag}${idx}`);
1991
+ cur = cur.parentElement;
1926
1992
  }
1927
- })();
1993
+ return parts.join(' > ');
1994
+ }
1995
+ function toast(msg) {
1996
+ tst.textContent = msg; tst.classList.add('v');
1997
+ clearTimeout(tst._t); tst._t = setTimeout(() => tst.classList.remove('v'), 2800);
1998
+ }
1999
+ }) ();