Files
terminal-lab/terminal/apps/miniprogram/components/terminal-core-view/index.js
douboer@gmail.com 3b7c1d558a first commit
2026-03-03 13:23:14 +08:00

370 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* terminal-core-view/index.js
*
* 微信小程序终端渲染组件。
* 消费 @remoteconn/terminal-core 构建产物dist-miniprogram/index.js
* 通过 setData({rows, cursor}) 驱动 rich-text 渲染。
*
* 外部属性properties
* - gatewayUrl {string} 网关 WebSocket 地址
* - gatewayToken {string} 网关鉴权 Token
*
* 外部方法(通过组件引用调用):
* - connect(params) 开始连接params: { host, port, username, password? }
* - disconnect(reason?) 断开连接
* - sendInput(data) 发送输入
*/
const path = '../../../../packages/terminal-core/dist-miniprogram/index';
let TerminalCore, InputBridge, OutputBuffer, SessionMachine, sanitizeTerminalOutput, calcSize;
try {
const core = require(path);
TerminalCore = core.TerminalCore;
InputBridge = core.InputBridge;
OutputBuffer = core.OutputBuffer;
SessionMachine = core.SessionMachine;
sanitizeTerminalOutput = core.sanitizeTerminalOutput;
calcSize = core.calcSize;
} catch(e) {
console.error('[terminal-core-view] 无法加载 terminal-core请先执行 build:miniprogram', e);
}
const { WxTransport } = require('../../utils/wxTransport');
const { createWxInputBridge } = require('../../utils/wxInputBridge');
const { createWxMeasureAdapter } = require('../../utils/wxMeasureAdapter');
// ── 颜色渲染 ──────────────────────────────────────────────────────────────────
const ANSI16 = [
'#000000','#cc0000','#00aa00','#aaaa00',
'#0000ee','#cc00cc','#00aaaa','#aaaaaa',
'#555555','#ff5555','#55ff55','#ffff55',
'#5555ff','#ff55ff','#55ffff','#ffffff',
];
function p256ToCss(idx) {
if (idx < 16) return ANSI16[idx] || '#000';
if (idx >= 232) {
const v = 8 + (idx - 232) * 10;
return 'rgb(' + v + ',' + v + ',' + v + ')';
}
const i = idx - 16;
const b = i % 6, g = Math.floor(i / 6) % 6, r = Math.floor(i / 36);
const ch = function(x) { return x === 0 ? 0 : 55 + x * 40; };
return 'rgb(' + ch(r) + ',' + ch(g) + ',' + ch(b) + ')';
}
function colorToCss(c, isFg) {
if (!c) return isFg ? 'inherit' : 'transparent';
switch (c.mode) {
case 'default': return isFg ? 'inherit' : 'transparent';
case 'p16': return ANSI16[c.value] || 'inherit';
case 'p256': return p256ToCss(c.value);
case 'rgb': return '#' + ((c.value & 0xffffff) | 0x1000000).toString(16).slice(1);
default: return 'inherit';
}
}
const FLAG_BOLD = 1;
const FLAG_DIM = 2;
const FLAG_ITALIC = 4;
const FLAG_UNDERLINE = 8;
const FLAG_INVERSE = 16;
const FLAG_INVISIBLE = 32;
const FLAG_STRIKETHROUGH = 64;
const FLAG_OVERLINE = 128;
/** 将一行 cells 转换为 rich-text 节点数组 */
function lineToNodes(cells) {
const nodes = [];
let i = 0;
while (i < cells.length) {
const cell = cells[i];
if (!cell) { i++; continue; }
const flags = cell.flags || 0;
const inverse = !!(flags & FLAG_INVERSE);
let fg = cell.fg, bg = cell.bg;
if (inverse) { const tmp = fg; fg = bg; bg = tmp; }
let style = '';
const fgCss = colorToCss(fg, true);
const bgCss = colorToCss(bg, false);
if (fgCss !== 'inherit') style += 'color:' + fgCss + ';';
if (bgCss !== 'transparent') style += 'background:' + bgCss + ';';
if (flags & FLAG_BOLD) style += 'font-weight:bold;';
if (flags & FLAG_DIM) style += 'opacity:0.6;';
if (flags & FLAG_ITALIC) style += 'font-style:italic;';
if (flags & FLAG_UNDERLINE) style += 'text-decoration:underline;';
if (flags & FLAG_STRIKETHROUGH) style += 'text-decoration:line-through;';
if (flags & FLAG_INVISIBLE) style += 'color:transparent;';
// 合并相邻相同 style 的 cells
let text = cell.char || ' ';
i++;
while (i < cells.length) {
const next = cells[i];
if (!next) break;
const nFlags = next.flags || 0;
// 简单比较:只有标志和颜色模式全相同才合并
if (nFlags !== flags ||
JSON.stringify(next.fg) !== JSON.stringify(fg) ||
JSON.stringify(next.bg) !== JSON.stringify(bg)) break;
text += next.char || ' ';
i++;
}
nodes.push({
type: 'node',
name: 'span',
attrs: style ? { style: style } : {},
children: [{ type: 'text', text: text }],
});
}
return nodes;
}
// ── 组件定义 ──────────────────────────────────────────────────────────────────
Component({
properties: {
gatewayUrl: { type: String, value: '' },
gatewayToken: { type: String, value: '' },
},
data: {
rows: [], // Array<{ html: Array }>
cursor: { row: -1, left: 0, visible: false },
scrollTop: 9999999, // 滚到底
state: 'idle',
latencyMs: 0,
title: '',
inputValue: '',
},
lifetimes: {
attached: function() {
this._init();
},
detached: function() {
this._dispose();
},
},
methods: {
// ── 初始化 ───────────────────────────────────────────────────────────────
_init: function() {
const self = this;
// Core
self._core = TerminalCore ? new TerminalCore(80, 24) : null;
self._buffer = OutputBuffer ? new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 }) : null;
self._machine = SessionMachine ? new SessionMachine() : null;
self._inputBridge = InputBridge ? new InputBridge() : null;
self._wxBridge = createWxInputBridge();
self._transport = null;
self._offTransport = null;
self._lastRevision = -1;
self._charW = 7.8; // 默认字符宽度 px (monospace 13px 近似)
self._lineH = 16.25; // 行高 px (13px * 1.25)
// 订阅 core 事件
if (self._core) {
self._core.on('bell', function() { wx.vibrateShort({ type: 'light' }); });
self._core.on('titleChange', function(t) {
self.setData({ title: t });
});
}
// 测量适配器
self._measure = createWxMeasureAdapter(self, '#tc-viewport', self._charW, self._lineH);
self._measure.onResize(function() {
self._doFit();
});
// 输入桥接事件订阅
self._wxBridge.on('key', function(p) {
if (!self._inputBridge) return;
const seq = self._inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey);
if (seq !== null) self.sendInput(seq);
});
self._wxBridge.on('input', function(p) {
if (p.isComposing) return;
if (p.data) self.sendInput(p.data);
self.setData({ inputValue: '' });
});
self._wxBridge.on('paste', function(p) {
if (!self._inputBridge) return;
const seq = self._inputBridge.mapPaste(p.text);
self.sendInput(seq);
});
self._wxBridge.on('compositionend', function(p) {
if (p.data) self.sendInput(p.data);
self.setData({ inputValue: '' });
});
// 启动渲染循环50ms ≈ 20fpssetData 有限流)
self._renderTimer = setInterval(function() { self._renderFrame(); }, 50);
// 启动测量轮询
self._measure.startPoll(600);
},
_dispose: function() {
if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; }
this._measure && this._measure.stopPoll();
this._offTransport && this._offTransport();
this._transport && this._transport._cleanup && this._transport._cleanup();
},
// ── 尺寸适配 ─────────────────────────────────────────────────────────────
_doFit: function() {
if (!calcSize || !this._core) return;
const { widthPx: contW, heightPx: contH } = this._measure.measureContainer();
if (contW < 10 || contH < 10) return;
const { widthPx: charW, heightPx: lineH } = this._measure.measureChar();
const { cols, rows } = calcSize(contW, contH, charW, lineH);
this._core.resize(cols, rows);
this._charW = charW;
if (this._transport && this.data.state === 'connected') {
this._transport.resize(cols, rows);
}
},
// ── 渲染帧 ───────────────────────────────────────────────────────────────
_renderFrame: function() {
if (!this._core) return;
const snap = this._core.snapshot();
if (snap.revision === this._lastRevision) return;
this._lastRevision = snap.revision;
const rows = snap.lines.map(function(line) {
return { html: lineToNodes(line.cells) };
});
// 光标位置(像素)
const cursorLeft = snap.cursor.x * this._charW;
const cursorRow = snap.cursor.y; // 相对可视区行号
this.setData({
rows: rows,
cursor: { row: cursorRow, left: cursorLeft, visible: snap.cursor.visible },
title: snap.title || this.data.title,
});
},
// ── 公开 API ──────────────────────────────────────────────────────────────
connect: function(params) {
const self = this;
if (!WxTransport) { console.error('[tc-view] WxTransport 未加载'); return; }
self._transport = new WxTransport({
gatewayUrl: self.properties.gatewayUrl,
gatewayToken: self.properties.gatewayToken,
});
self._offTransport = self._transport.on(function(event) {
if (event.type === 'stdout' || event.type === 'stderr') {
const sanitized = sanitizeTerminalOutput ? sanitizeTerminalOutput(event.data) : event.data;
if (sanitized) {
self._buffer && self._buffer.push(sanitized);
self._core && self._core.write(sanitized);
}
} else if (event.type === 'control') {
if (event.action === 'connected') {
self._machine && self._machine.tryTransition('connected');
self.setData({ state: 'connected', latencyMs: 0 });
self._doFit();
} else if (event.action === 'pong') {
// latency 由 WxTransport 内部 _pingAt 计算
} else if (event.action === 'disconnect') {
self._machine && self._machine.tryTransition('disconnected');
self.setData({ state: 'disconnected' });
}
} else if (event.type === 'error') {
self._machine && self._machine.tryTransition('error');
self.setData({ state: 'error' });
}
});
self._machine && self._machine.tryTransition('connecting');
self.setData({ state: 'connecting' });
self._transport.connect(params).catch(function(err) {
console.error('[tc-view] connect error', err);
self.setData({ state: 'error' });
});
},
disconnect: function(reason) {
if (this._transport) {
this._transport.disconnect(reason || 'manual');
}
this._machine && this._machine.tryTransition('disconnected');
this.setData({ state: 'disconnected' });
},
sendInput: function(data) {
if (this.data.state !== 'connected' || !this._transport) return;
this._transport.send(data);
},
// ── WXML 事件处理 ─────────────────────────────────────────────────────────
onInput: function(e) {
const value = e.detail.value || '';
const prev = this._prevInputValue || '';
// 计算增量字符
if (value.length > prev.length) {
const delta = value.slice(prev.length);
this._wxBridge.triggerInput(delta, e.detail.isComposing);
}
this._prevInputValue = value;
},
onConfirm: function() {
this._prevInputValue = '';
this._wxBridge.triggerConfirm();
},
onSend: function() {
const val = this.data.inputValue;
if (val) {
this._wxBridge.triggerInput(val, false);
this._wxBridge.triggerConfirm();
this.setData({ inputValue: '' });
this._prevInputValue = '';
}
},
onInputBlur: function() { /* no-op */ },
onKeyboardChange: function() { /* no-op */ },
// ── 滚动事件 ──────────────────────────────────────────────────────────────
onScrollToLower: function() {
this._autoScroll = true;
},
onScrollToUpper: function() {
this._autoScroll = false;
},
onScroll: function() { /* handled by upper/lower events */ },
// ── 触控工具栏 ────────────────────────────────────────────────────────────
onTapUp: function() { this._wxBridge.triggerKey('ArrowUp', 'ArrowUp'); },
onTapDown: function() { this._wxBridge.triggerKey('ArrowDown', 'ArrowDown'); },
onTapLeft: function() { this._wxBridge.triggerKey('ArrowLeft', 'ArrowLeft'); },
onTapRight: function() { this._wxBridge.triggerKey('ArrowRight', 'ArrowRight'); },
onTapTab: function() { this._wxBridge.triggerKey('Tab', 'Tab'); },
onTapEnter: function() { this._wxBridge.triggerConfirm(); },
onTapCtrlC: function() { this.sendInput('\u0003'); },
onTapCtrlD: function() { this.sendInput('\u0004'); },
onTapEsc: function() { this.sendInput('\x1b'); },
onTapPaste: function() {
const self = this;
wx.getClipboardData({
success: function(res) {
if (res.data) self._wxBridge.triggerPaste(res.data);
},
});
},
},
});