LayMeshDocs
中文

site/live/color-panel.mjs

site/live/color-panel.mjs
// Shared frontend picker: no conversion transport in the interaction loop.import {convertColor,parseHex} from './color-math.mjs';export function colorPanel(root, initial, apply, close, locale='en') { const zh=locale.startsWith('zh'),t=(a,b)=>zh?a:b; let model=['rgb','hsv','oklch'].includes(initial.space)?initial.space:'rgb'; let state={space:initial.space==='hex'?'rgb':initial.space,channels:[...(initial.channels||initial.rgba.slice(0,3).map(v=>v*255))],alpha:initial.alpha??initial.rgba[3]}; let result=null,destroyed=false,fields=[],alphaInput,hueMemory=0,dirty=false,rampsTimer,frame=0,pending=null; const rampCache=new Map(); const css=` .laymesh-color-panel{--picker-bg:var(--vscode-editor-background,var(--surface,#fff));--picker-soft:var(--vscode-input-background,var(--soft,#f5f7f9));--picker-text:var(--vscode-editor-foreground,var(--text,#20332f));--picker-line:var(--vscode-input-border,var(--line,#dce3e8));--picker-accent:var(--vscode-button-background,var(--accent,#376da8));box-sizing:border-box;width:min(760px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow:auto;padding:20px;border:1px solid var(--picker-line);border-radius:12px;background:var(--picker-bg);color:var(--picker-text);font:14px/1.5 system-ui;box-shadow:0 12px 50px #0002} .laymesh-color-panel *{box-sizing:border-box}.laymesh-color-panel button,.laymesh-color-panel input,.laymesh-color-panel select{font:inherit;color:inherit;border:1px solid var(--picker-line);border-radius:6px;background:var(--picker-soft);padding:7px 10px;min-width:0}.laymesh-color-panel button{cursor:pointer}.laymesh-color-panel :focus-visible{outline:2px solid var(--picker-accent);outline-offset:3px}.laymesh-color-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px}.laymesh-color-header strong{font-size:18px}.laymesh-color-close{font-size:22px!important;line-height:1;width:34px;height:34px;padding:0!important}.laymesh-color-editor{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:22px}.laymesh-color-picker,.laymesh-color-fields{min-width:0}.laymesh-color-plane-row{display:grid;grid-template-columns:minmax(0,1fr) 24px;gap:12px;height:242px}.laymesh-color-sv,.laymesh-color-brightness{position:relative;overflow:hidden;border:1px solid #8886;border-radius:8px;touch-action:none;cursor:crosshair}.laymesh-color-sv{background:linear-gradient(to top,#000,transparent),linear-gradient(to right,#fff,transparent),var(--picker-hue)}.laymesh-color-thumb{position:absolute;width:16px;height:16px;border:2px solid white;border-radius:50%;box-shadow:0 0 0 1px #172333,0 2px 5px #0005;transform:translate(-50%,-50%);pointer-events:none}.laymesh-color-brightness{background:linear-gradient(to bottom,#fff,#000);cursor:ns-resize}.laymesh-color-brightness .laymesh-color-thumb{left:50%;width:20px;height:6px;border-radius:4px;background:#253342}.laymesh-color-hue-row{display:grid;grid-template-columns:34px minmax(0,1fr) 44px;gap:8px;align-items:center;margin-top:14px}.laymesh-color-hue-row output{text-align:right;font-size:12px}.laymesh-color-range{appearance:none;width:100%;height:16px;min-width:0;padding:0!important;border:1px solid #8885!important;border-radius:5px!important;background:var(--track)!important;cursor:pointer}.laymesh-color-range::-webkit-slider-thumb{appearance:none;width:19px;height:19px;border:2px solid white;border-radius:50%;background:var(--thumb,#496783);box-shadow:0 0 0 1px #344254,0 2px 4px #0004}.laymesh-color-range::-moz-range-thumb{width:15px;height:15px;border:2px solid white;border-radius:50%;background:var(--thumb,#496783);box-shadow:0 0 0 1px #344254}.laymesh-color-hex-card{display:flex;gap:12px;align-items:center;padding:10px;border:1px solid var(--picker-line);border-radius:8px;background:var(--picker-soft)}.laymesh-color-preview{width:40px;height:40px;flex:none;border:1px solid #8885;border-radius:50%;overflow:hidden;background:conic-gradient(#bbc8c2 25%,#edf1ef 0 50%,#bbc8c2 0 75%,#edf1ef 0) 0 0/12px 12px}.laymesh-color-preview div{height:100%}.laymesh-color-hex-label{display:flex;gap:8px;align-items:center;min-width:0;flex:1}.laymesh-color-hex-label input{width:100%;font-family:ui-monospace,monospace;background:var(--picker-bg)}.laymesh-color-tabs{display:flex;margin:12px 0;gap:0}.laymesh-color-tabs button{flex:1;border-radius:0}.laymesh-color-tabs button:first-child{border-radius:6px 0 0 6px}.laymesh-color-tabs button:last-child{border-radius:0 6px 6px 0}.laymesh-color-tabs button[aria-pressed=true],.laymesh-color-apply{background:var(--picker-accent)!important;color:var(--vscode-button-foreground,#fff)!important}.laymesh-color-channel{display:grid;grid-template-columns:62px minmax(70px,1fr) 94px;gap:10px;align-items:center;margin:14px 0}.laymesh-color-channel>span{font-size:12px}.laymesh-color-channel input[type=number]{width:100%;padding:6px 7px}.laymesh-color-channel input[type=range]{width:100%}.laymesh-color-status{min-height:22px;font-size:12px;color:var(--vscode-descriptionForeground,var(--muted,#65766f));margin-top:12px}.laymesh-color-footer{display:flex;justify-content:space-between;align-items:center;gap:12px;border-top:1px solid var(--picker-line);padding-top:14px;margin-top:10px}.laymesh-color-output{display:flex;align-items:center;gap:8px;font-size:12px}.laymesh-color-actions{display:flex;gap:8px}.laymesh-color-panel button:disabled{opacity:.55;cursor:default}@media(max-width:660px){.laymesh-color-panel{padding:16px}.laymesh-color-editor{grid-template-columns:1fr;gap:16px}.laymesh-color-plane-row{height:190px}.laymesh-color-footer{flex-wrap:wrap}.laymesh-color-output{flex:1}.laymesh-color-actions{margin-left:auto}.laymesh-color-header{margin-bottom:12px}} `; const style=document.createElement('style');style.textContent=css;root.append(style); const panel=document.createElement('section');panel.className='laymesh-color-panel';panel.setAttribute('aria-label',t('编辑 LayMesh 颜色','Edit LayMesh color'));root.append(panel); const el=(tag,text,parent=panel,cls='')=>{const node=document.createElement(tag);if(text)node.textContent=text;if(cls)node.className=cls;parent.append(node);return node;}; const header=el('div','',panel,'laymesh-color-header');el('strong',t('编辑颜色','Edit color'),header); const closeButton=el('button','×',header,'laymesh-color-close');closeButton.setAttribute('aria-label',t('关闭','Close')); const editor=el('div','',panel,'laymesh-color-editor'),picker=el('div','',editor,'laymesh-color-picker'); const planeRow=el('div','',picker,'laymesh-color-plane-row'); const plane=el('div','',planeRow,'laymesh-color-sv');plane.tabIndex=0;plane.setAttribute('role','slider');plane.setAttribute('aria-label',t('饱和度与明度','Saturation and value')); const thumb=el('span','',plane,'laymesh-color-thumb'); const brightness=el('div','',planeRow,'laymesh-color-brightness');brightness.tabIndex=0;brightness.setAttribute('role','slider');brightness.setAttribute('aria-label',t('明度','Value'));brightness.setAttribute('aria-valuemin','0');brightness.setAttribute('aria-valuemax','100'); const valueThumb=el('span','',brightness,'laymesh-color-thumb'); const hueRow=el('label',t('色相','Hue'),picker,'laymesh-color-hue-row'); const hue=el('input','',hueRow,'laymesh-color-range');hue.type='range';hue.min='0';hue.max='360';hue.step='0.1';hue.setAttribute('aria-label',t('色相','Hue'));hue.style.setProperty('--track','linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)'); const hueOutput=el('output','',hueRow); const right=el('div','',editor,'laymesh-color-fields');const card=el('div','',right,'laymesh-color-hex-card');const preview=el('div','',card,'laymesh-color-preview');const paint=el('div','',preview); const hexLabel=el('label','HEX',card,'laymesh-color-hex-label');const hex=el('input','',hexLabel);hex.setAttribute('aria-label','HEX');hex.spellcheck=false;hex.value=initial.hex||'#000000'; const tabs=el('div','',right,'laymesh-color-tabs');const channelRoot=el('div','',right); const status=el('div','',panel,'laymesh-color-status');status.setAttribute('role','status'); const footer=el('div','',panel,'laymesh-color-footer');const output=el('label',t('输出','Output'),footer,'laymesh-color-output');const format=el('select','',output);format.setAttribute('aria-label',t('源码格式','Source format'));for(const name of ['hex','rgb','hsv','oklch']){const option=el('option',name.toUpperCase(),format);option.value=name;}format.value=initial.space; const actions=el('div','',footer,'laymesh-color-actions');const cancel=el('button',t('取消','Cancel'),actions);const ok=el('button',t('应用','Apply'),actions,'laymesh-color-apply');ok.disabled=true; function dispose(){if(destroyed)return;destroyed=true;cancelAnimationFrame(frame);clearTimeout(rampsTimer);panel.remove();style.remove();close?.();} const rgbaCss=rgba=>`rgba(${rgba[0]*255},${rgba[1]*255},${rgba[2]*255},${rgba[3]})`; const number=input=>{const raw=input.dataset.raw??input.value;return raw.trim()===''?NaN:Number(raw);}; const percent=(space,index)=>space==='hsv'&&index>0||space==='oklch'&&index===0; const channels=()=>model===state.space?[...state.channels]:[...(result?.[model]||[0,0,0])]; function pickerHsv(){const next=pending?.next;if(next?.space==='hsv')return [next.channels[0],next.channels[1],next.channels[2]];const values=result?.hsv||[0,0,0];return [state.space==='hsv'?values[0]:values[1]>1e-8&&values[2]>1e-8?values[0]:hueMemory,values[1],values[2]];} function inputValue(input,value){input.value=String(Number(value.toPrecision(6)));input.dataset.raw=String(value);} function row(label,value,max,step,isPercent=false){const row=el('label','',channelRoot,'laymesh-color-channel');el('span',label+(isPercent?' (%)':''),row);const range=el('input','',row,'laymesh-color-range');range.type='range';range.min='0';range.max=String(Math.max(max,value));range.step=String(step);const input=el('input','',row);input.type='number';input.step=String(step);input.min='0';if(isPercent)input.max='100';input.setAttribute('aria-label',label+(isPercent?' (%)':''));inputValue(input,value);range.value=String(value);input._range=range;range.addEventListener('pointerup',()=>{flush();ramps();});  const changed=()=>{input.dataset.raw=input.value;range.value=input.value;submitChannels();};input.addEventListener('input',changed);range.addEventListener('input',()=>{inputValue(input,Number(range.value));submitChannels();});return input; } function renderFields(){channelRoot.replaceChildren();fields=[];for(const tab of tabs.children)tab.setAttribute('aria-pressed',String(tab.dataset.space===model));const labels=model==='rgb'?['R','G','B']:model==='hsv'?['H','S','V']:['L','C','H'];const values=channels();fields=labels.map((label,i)=>row(label,values[i]*(percent(model,i)?100:1),model==='rgb'?255:label==='H'?360:percent(model,i)?100:0.4,model==='rgb'?1:label==='H'?0.1:percent(model,i)?0.1:0.001,percent(model,i)));alphaInput=row(t('透明度','Alpha'),state.alpha*100,100,0.1,true);alphaInput.dataset.channel='alpha';} function syncPicker(){if(!result)return;const [h,s,v]=pickerHsv();hueMemory=h;plane.style.setProperty('--picker-hue',`hsl(${h} 100% 50%)`);thumb.style.left=`${s*100}%`;thumb.style.top=`${(1-v)*100}%`;thumb.style.background=rgbaCss([...result.rgba.slice(0,3),1]);valueThumb.style.top=`${(1-v)*100}%`;brightness.setAttribute('aria-valuenow',String(v*100));plane.setAttribute('aria-valuetext',`${t('饱和度','Saturation')} ${Math.round(s*100)}%, ${t('明度','Value')} ${Math.round(v*100)}%`);hue.value=String(h);hueOutput.value=`${Math.round(h)}°`;hue.style.setProperty('--thumb',`hsl(${h} 100% 50%)`);paint.style.background=rgbaCss(result.rgba);for(const input of fields)input._range.style.setProperty('--thumb',rgbaCss([...result.rgba.slice(0,3),1]));alphaInput._range.style.setProperty('--track',`linear-gradient(to right,${rgbaCss([...result.rgba.slice(0,3),0])},${rgbaCss([...result.rgba.slice(0,3),1])}),conic-gradient(#bbc8c2 25%,#edf1ef 0 50%,#bbc8c2 0 75%,#edf1ef 0) 0 0/10px 10px`);if(document.activeElement!==hex)hex.value=dirty?result.hex:initial.hex||result.hex;} function syncFields(fromInputs=false){  const values=channels();fields.forEach((input,i)=>{const value=values[i]*(percent(model,i)?100:1);if(!fromInputs)inputValue(input,value);input._range.max=String(Math.max(Number(input._range.max),value));input._range.value=String(value);});  if(!fromInputs)inputValue(alphaInput,state.alpha*100);alphaInput._range.value=String(state.alpha*100); } function ramps(){  if(destroyed||!result)return;const values=channels();  for(const [index,input] of fields.entries()){   const max=Number(input._range.max)/(percent(model,index)?100:1),key=JSON.stringify([model,index,max,values]);let track=rampCache.get(key);   if(!track){const colors=Array.from({length:17},(_,i)=>{const c=[...values];c[index]=max*i/16;return rgbaCss(convertColor({space:model,channels:c,alpha:1}).rgba);});track=`linear-gradient(to right,${colors.join(',')})`;if(rampCache.size>=128)rampCache.delete(rampCache.keys().next().value);rampCache.set(key,track);}   input._range.style.setProperty('--track',track);input._range.style.setProperty('--thumb',rgbaCss([...result.rgba.slice(0,3),1]));  }  const c=result.rgba.slice(0,3);alphaInput._range.style.setProperty('--track',`linear-gradient(to right,${rgbaCss([...c,0])},${rgbaCss([...c,1])}),conic-gradient(#bbc8c2 25%,#edf1ef 0 50%,#bbc8c2 0 75%,#edf1ef 0) 0 0/10px 10px`); } function flush(){  cancelAnimationFrame(frame);frame=0;if(!pending||destroyed)return;const {next,fromInputs}=pending;pending=null;  try{const converted=convertColor(next);state=next;result=converted;status.textContent=converted.mapped?t('已映射到 sRGB;原始通道保留。','Mapped to sRGB; original channels are retained.'):'';if(!fields.length)renderFields();syncFields(fromInputs);syncPicker();ok.disabled=false;clearTimeout(rampsTimer);rampsTimer=setTimeout(ramps,100);}  catch(error){ok.disabled=true;status.textContent=String(error.message||error);} } function update(next,fromInputs=false){pending={next,fromInputs};if(!frame)frame=requestAnimationFrame(flush);} function submitChannels(){dirty=true;const c=fields.map((input,i)=>number(input)/(percent(model,i)?100:1));update({space:model,channels:c,alpha:number(alphaInput)/100},true);} function editHsv(h,s,v){dirty=true;hueMemory=h;update({space:'hsv',channels:[h,s,v],alpha:state.alpha});} function drag(node,action){let pointer=null;node.addEventListener('pointerdown',e=>{if(e.button!==0)return;e.preventDefault();node.focus();pointer=e.pointerId;node.setPointerCapture(e.pointerId);action(e,node.getBoundingClientRect());});node.addEventListener('pointermove',e=>{if(e.pointerId===pointer)action(e,node.getBoundingClientRect());});const end=()=>{pointer=null;flush();ramps();};node.addEventListener('pointerup',end);node.addEventListener('pointercancel',end);node.addEventListener('lostpointercapture',end);} const clamp=n=>Math.max(0,Math.min(1,n)); drag(plane,(e,r)=>{const [h]=pickerHsv();editHsv(h,clamp((e.clientX-r.left)/r.width),1-clamp((e.clientY-r.top)/r.height));}); drag(brightness,(e,r)=>{const [h,s]=pickerHsv();editHsv(h,s,1-clamp((e.clientY-r.top)/r.height));}); plane.addEventListener('keydown',e=>{let [h,s,v]=pickerHsv();if(e.key==='ArrowLeft')s-=0.01;else if(e.key==='ArrowRight')s+=0.01;else if(e.key==='ArrowUp')v+=0.01;else if(e.key==='ArrowDown')v-=0.01;else return;e.preventDefault();editHsv(h,clamp(s),clamp(v));}); brightness.addEventListener('keydown',e=>{let [h,s,v]=pickerHsv();if(e.key==='ArrowUp')v+=0.01;else if(e.key==='ArrowDown')v-=0.01;else if(e.key==='Home')v=1;else if(e.key==='End')v=0;else return;e.preventDefault();editHsv(h,s,clamp(v));}); hue.addEventListener('pointerup',()=>{flush();ramps();}); hue.addEventListener('input',()=>{const [,s,v]=pickerHsv();editHsv(Number(hue.value),s,v);}); hex.addEventListener('input',()=>{dirty=true;try{update(parseHex(hex.value));}catch(error){pending=null;cancelAnimationFrame(frame);frame=0;ok.disabled=true;status.textContent=String(error.message||error);}}); for(const space of ['rgb','hsv','oklch']){const button=el('button',space.toUpperCase(),tabs);button.dataset.space=space;button.addEventListener('click',()=>{flush();if(!result||ok.disabled)return;model=space;renderFields();ramps();});} cancel.addEventListener('click',dispose);closeButton.addEventListener('click',dispose); ok.addEventListener('click',async()=>{flush();if(!result||ok.disabled)return;ok.disabled=true;try{await apply({rgba:result.rgba,space:format.value,originalSpace:state.space,channels:state.channels,alpha:state.alpha,unchanged:!dirty&&format.value===initial.space});dispose();}catch(error){status.textContent=String(error.message||error);ok.disabled=false;}}); panel.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();dispose();}}); pending={next:state,fromInputs:false};flush();ramps();hex.focus(); return {dispose,element:panel};}