Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#28] 블록 커스텀 #121

Merged
merged 11 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions apps/client/src/core/customConstantProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import * as Blockly from 'blockly/core';

const svgPaths = Blockly.utils.svgPaths;
type Shape = Blockly.blockRendering.BaseShape | Blockly.blockRendering.DynamicShape;

export class CustomConstantProvider extends Blockly.zelos.ConstantProvider {
constructor() {
super();

this.NOTCH_WIDTH = 6 * this.GRID_UNIT; // 블록 연결 부분 너비
this.NOTCH_HEIGHT = 2 * this.GRID_UNIT; // 블록 연결 부분 높이
this.NOTCH_OFFSET_LEFT = (2 * this.GRID_UNIT) / 3; // 블록 연결 부분을 기준으로 좌측 길이
this.CORNER_RADIUS = (2 * this.GRID_UNIT) / 3;
this.FIELD_TEXT_FONTFAMILY = 'SUIT Variable';
this.FIELD_TEXT_FONTWEIGHT = 'normal';
this.EMPTY_INLINE_INPUT_PADDING = 50; // inline text input field가 비어져 있을 경우, 해당 필드 너비
this.MIN_BLOCK_WIDTH = 56; // 블록 최소 너비 (클래스명 블록의 최소 너비를 정하기 위해)
this.FIELD_BORDER_RECT_X_PADDING = 7; // inlin text input field의 좌우 padding값 (클래스명 블록 및 텍스트 블록)
}

// constructor 시 constant 값을 초기값을 해도, 해당 메소드를 통해 draw 동작 전 notch에 대한 constant 값이 다시 매겨집니다.
override makeNotch(): {
type: number;
width: number;
height: number;
pathLeft: string;
pathRight: string;
} {
const width = this.NOTCH_WIDTH; // 24 기준
const height = this.NOTCH_HEIGHT; // 8 기준

const pathRight = 'c -2 0 -3 0 -5 1 l -10 6 c -2 1 -3 1 -4 0 l -3 -5 c -0.5 -1 -1 -2 -2 -2'; // 좌측방향으로 그릴 시 연결 부분 path값
const pathLeft = 'h 0 c 1 0 1 0.5 2 2 l 3 5 c 1 1 2 1 4 0 l 10 -6 c 2 -1 3 -1 5 -1'; // 우측방향으로 그릴 시 연결 부분 path값

return {
type: this.SHAPES.NOTCH,
width,
height,
pathLeft,
pathRight,
};
}

/*
makeNotch와 똑같이 draw 동작 전 rounded에 대한 cosntant 값이 다시 매겨집니다.
이 메소드가 쓰이는 곳은 inlint input field가 있을 경우 이에 대한 내부 path 경로를 그릴 때 사용됩니다.
즉, boolock내에서는 클래스명 블록과 text 블록 내 input field, html 태그 블록들의 비어져있는 클래스명 field 부분을 그릴 때 사용됩니다.
해당 메소드는 메소드의 내부 로직중 일부를 고쳐야함으로, super.makeRounded 이후 사용하기 어려워 해당 메소드를 그대로 복붙해와서 사용중입니다.
*/
protected override makeRounded(): Shape {
const maxWidth = this.MAX_DYNAMIC_CONNECTION_SHAPE_WIDTH; // parent 블록과 연결된 내부 블록의 최대 너비 값
const maxHeight = maxWidth * 1.5;

// 해당 함수는 field를 그릴 때 연결 부위 및 좌우 세로선을 그릴 때 호출됩니다.
function makeMainPath(blockHeight: number, up: boolean, right: boolean): string {
let remainingHeight = blockHeight > maxHeight ? blockHeight - maxHeight : 0;
const height = blockHeight > maxHeight ? maxHeight : blockHeight;
const radius = height / 8;
remainingHeight += (height / 8) * 6;
const sweep = right === up ? '0' : '1'; // 좌측, 우측 세로선 중 어느 선을 그리냐에 따라 값이 다르게 들어갑니다.
const temp =
svgPaths.arc(
'a',
'0 0,' + sweep,
radius,
svgPaths.point((right ? 1 : -1) * radius, (up ? -1 : 1) * radius)
) +
svgPaths.lineOnAxis('v', (up ? -1 : 1) * remainingHeight) +
svgPaths.arc(
'a',
'0 0,' + sweep,
radius,
svgPaths.point((right ? -1 : 1) * radius, (up ? -1 : 1) * radius)
);
return temp;
}

return {
type: this.SHAPES.ROUND,
isDynamic: true,
width(height: number): number {
const halfHeight = height / 3.5;
return halfHeight > maxWidth ? maxWidth : halfHeight - 6;
},
height(height: number): number {
return height;
},
connectionOffsetY(connectionHeight: number): number {
return connectionHeight / 2;
},
connectionOffsetX(connectionWidth: number): number {
return -connectionWidth;
},
pathDown(height: number): string {
return makeMainPath(height, false, false);
},
pathUp(height: number): string {
return makeMainPath(height, true, false);
},
pathRightDown(height: number): string {
return makeMainPath(height, false, true);
},
pathRightUp(height: number): string {
return makeMainPath(height, false, true);
},
};
}

/*
renderer가 동작하면서 블록이 만들어질 때 해당 메소드를 통해 블록들의 스타일이 적용됩니다.
기존 메소드로 만들어진 list중 blocklyText 부분의 색상만 변경하였습니다.
*/
override getCSS_(selector: string): string[] {
const cssList = super.getCSS_(selector);

return [...cssList, `${selector} .blocklyText {`, `fill: #F4F8FA;`, `}`];
}
}
64 changes: 64 additions & 0 deletions apps/client/src/core/customFieldLabel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as Blockly from 'blockly/core';

const dom = Blockly.utils.dom;
const Svg = Blockly.utils.Svg;

// 블록의 좌측 블록 이름에 대한 라벨을 실제 돔에 올려주는 클래스입니다.
class CustomFieldLabel extends Blockly.FieldLabel {
protected backgroundRect_!: SVGRectElement;

/*
기존 메소드는 textElement만 그리고 관리하나,
boolock 커스텀에서는 배경도 같이 포함되어있어야하기 때문에 backgroundRect라는 svgElement를 추가로 만들어주고,
이를 해당 라벨을 묶는<g> 태그 역할을 하는 fieldGroup_에 할당합니다.
*/
protected override createTextElement_(): void {
this.backgroundRect_ = dom.createSvgElement(
Svg.RECT,
{
class: 'blocklyTextBackground',
x: 0,
y: 0,
rx: 10,
ry: 10,
fill: '#1E272E',
stroke: 'none',
},
this.fieldGroup_
);

this.textElement_ = dom.createSvgElement(
Svg.TEXT,
{
class: 'blocklyText',
x: 0,
y: 0,
'dominant-baseline': 'central',
},
this.fieldGroup_
);

this.textContent_ = document.createTextNode('');
this.textElement_.appendChild(this.textContent_);
}

/*
실제 dom에 올려주는 역할을 하는 메소드입니다.
블록 태그 내에 합쳐서 올릴 때는 블록을 배경으로 약간 좌측에 padding넣어준 것처럼
해당 필드가 위치해있어야하기 때문에 이에 대한 x좌표 및 배경에 대한 style 적용을 해두었습니다.
*/
protected override render_(): void {
super.render_();

const bbox = this.textElement_!.getBBox();
this.backgroundRect_.setAttribute('width', (bbox.width + 12).toString());
this.backgroundRect_.setAttribute('height', (bbox.height + 4).toString());
this.backgroundRect_.setAttribute('x', (bbox.x + 6).toString());
this.backgroundRect_.setAttribute('y', (bbox.y - 2).toString());
this.textElement_?.setAttribute('x', (bbox.x + 12).toString());
}
}

// 이 fieldLabel을 다른 곳에서 계속 부르면서 사용하기에 불편함도 있고, 실제 field_label에 등록시켜두어도 무리 없이 동작하여 레지스터로 등록해두었습니다.
Blockly.fieldRegistry.unregister('field_label');
Blockly.fieldRegistry.register('field_label', CustomFieldLabel);
18 changes: 18 additions & 0 deletions apps/client/src/core/customFieldLabelSerializable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Blockly from 'blockly/core';

/*
해당 field 라벨의 text에 클래스가 이름이, 일반 블록의 blocklyText 클래스 이름과 같아 일반 블록에 적용된 gray-50 색상과 동일하게 text 색상이 적용되었습니다.
이를 해결하기 위한 용도의 커스텀클래스입니다. initView()에서 텍스트 색상만 변경해주는 용도입니다.
*/
export class CustomFieldLabelSerializable extends Blockly.FieldLabelSerializable {
constructor(value?: string, textClass?: string, config?: Blockly.FieldLabelConfig) {
super(String(value ?? ''), textClass, config);
}

override initView(): void {
super.initView();
if (this.textElement_) {
this.textElement_.style.fill = `#41505B`;
}
}
}
48 changes: 48 additions & 0 deletions apps/client/src/core/customFieldTextInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as Blockly from 'blockly/core';

const dom = Blockly.utils.dom;

/*
text블록의 inputField에서 사용되고 있습니다.
text블록의 inputField처럼 내부에서 수정이 불가능한 상태 및 고정된 상태로 사용된다고 했을때, 해당 필드에 대해 minwidth가 잘 먹히지 않았습니다.
updateSize_()의 내부 로직을 일부 수정하여 minWidth를 설정해주었습니다.
super.updateSize_() 이후 설정해주기 어려워 해당 메소드를 그대로 복붙해와서 사용중입니다.
만약 text블록 이외의 다른 곳에서 사용된다고 했을 때, widgetCreate_() 메소드에 인해 포커싱이 되어도 블록 모양이 망가지지 않고 잘 작동됩니다.
*/
export class CustomFieldTextInput extends Blockly.FieldTextInput {
protected textGroup_!: SVGGElement;
protected backgroundRect_!: SVGRectElement;
protected isFixed: boolean = false;

protected override updateSize_(margin?: number): void {
const constants = this.getConstants();
const xOffset =
margin !== undefined
? margin
: !this.isFullBlockField()
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
: 0;
let totalWidth = xOffset * 2;
let totalHeight = constants!.FIELD_TEXT_HEIGHT;

let contentWidth = 0;
if (this.textElement_) {
contentWidth = dom.getFastTextWidth(
this.textElement_,
constants!.FIELD_TEXT_FONTSIZE,
constants!.FIELD_TEXT_FONTWEIGHT,
constants!.FIELD_TEXT_FONTFAMILY
);
totalWidth += contentWidth;
}
if (!this.isFullBlockField()) {
totalHeight = Math.max(totalHeight, constants!.FIELD_BORDER_RECT_HEIGHT);
}

this.size_.height = totalHeight;
this.size_.width = Math.max(totalWidth, constants!.EMPTY_INLINE_INPUT_PADDING);

this.positionTextElement_(xOffset, contentWidth);
this.positionBorderRect_();
}
}
102 changes: 102 additions & 0 deletions apps/client/src/core/customRenderInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as Blockly from 'blockly/core';
import { CustomRenderer } from './customRenderer';
import { CustomFieldTextInput } from './customFieldTextInput';

const Types = Blockly.blockRendering.Types;

/*
블록내에서 inlineInputField 와 blockl label field를 space-between처럼 좌우에 고정시키기 위해 커스텀한 renderInfo 클래스입니다.
renderInfo는 블록 좌표, 어떤 블록 타입인지, 어떤 길이를 가지는지 등등 블록에 대한 정보를 전부 설정해주는 클래스로,
drawer 이전 마지막으로 호출되는 클래스이기에 이 부분에서 field가 그려질 좌표를 수정해주었습니다.
*/
export class CustomRenderInfo extends Blockly.zelos.RenderInfo {
constructor(renderer: CustomRenderer, block: Blockly.BlockSvg) {
super(renderer, block);
}

override finalize_(): void {
super.finalize_();

let finalizeMaxWidth = this.topRow.width;
const MIN_ROW_WIDTH = 150;
const MAX_DYNAMIC_WIDTH = this.constants_.MAX_DYNAMIC_CONNECTION_SHAPE_WIDTH;
const MAX_DYNAMIC_HEIGHT = MAX_DYNAMIC_WIDTH * 1.5;
const EMPTY_PADDING = this.constants_.EMPTY_INLINE_INPUT_PADDING;

const calculateRowWidth = (
row: Blockly.blockRendering.Row,
fieldLabel: { width: number; xPos: number },
inputField: { width: number; height: number; xPos: number }
) => {
const height = Math.min(inputField.height, MAX_DYNAMIC_HEIGHT);
const radius = height / 4;

let totalRowWidth = Math.max(row.width, MIN_ROW_WIDTH);
const difference = inputField.width - (radius + EMPTY_PADDING);

if (difference) {
totalRowWidth += difference > 40 ? difference / 2 + 20 : difference;
}

const remainingSpace =
totalRowWidth -
(fieldLabel.width + inputField.width) -
(difference > 40 ? difference / 2 - 20 : 0);
inputField.xPos = fieldLabel.width + remainingSpace - fieldLabel.xPos;

return {
rowWidth: difference > 40 ? totalRowWidth - (difference / 2 + 20 - 40) : totalRowWidth,
blockWidthIncrease: difference > 40 ? 40 : difference,
};
};

let isProcessedBetween = false;

this.rows.forEach((row) => {
if (row.hasInlineInput && row.elements.length === 5) {
const fieldLabel = row.elements[1];
const inputField = row.elements[row.elements.length - 2];

const { rowWidth, blockWidthIncrease } = calculateRowWidth(row, fieldLabel, inputField);
row.width = rowWidth;
this.block_.width += blockWidthIncrease;
finalizeMaxWidth = Math.max(finalizeMaxWidth, row.width);
isProcessedBetween = true;
} else {
let isInlineCustomInput = row.elements.some(
(elem) =>
Types.isField(elem) &&
(elem as Blockly.blockRendering.Field).field instanceof CustomFieldTextInput
);

if (isInlineCustomInput) {
const inputField = row.elements[row.elements.length - 2];
const fieldLabel = row.elements.length > 3 ? row.elements[1] : { width: 0, xPos: 8 };

const { rowWidth, blockWidthIncrease } = calculateRowWidth(row, fieldLabel, inputField);
row.width = rowWidth;
this.block_.width += blockWidthIncrease;
finalizeMaxWidth = Math.max(finalizeMaxWidth, row.width);
isProcessedBetween = true;
} else {
finalizeMaxWidth = Math.max(finalizeMaxWidth, MIN_ROW_WIDTH);
}
}
});

if (finalizeMaxWidth > this.topRow.width) {
const difference = finalizeMaxWidth - this.topRow.width;
const additionalWidth =
difference > 40 && isProcessedBetween ? difference / 2 + 20 : difference;

this.topRow.elements[this.topRow.elements.length - 2].width += additionalWidth;
this.bottomRow.elements[this.bottomRow.elements.length - 2].width += additionalWidth;

this.rows.forEach((row) => {
if (row.hasStatement) {
row.width += additionalWidth;
}
});
}
}
}
18 changes: 18 additions & 0 deletions apps/client/src/core/customRenderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Blockly from 'blockly/core';
import { CustomConstantProvider } from './customConstantProvider';
import { CustomRenderInfo } from './customRenderInfo';

// 커스텀한 constant 및 renderInfo를 custom renderer에 등록시켜 블록 생성할 시 사용합니다.
export class CustomRenderer extends Blockly.zelos.Renderer {
constructor(name: string) {
super(name);
}

protected override makeConstants_(): CustomConstantProvider {
return new CustomConstantProvider();
}

protected override makeRenderInfo_(block: Blockly.BlockSvg): Blockly.zelos.RenderInfo {
return new CustomRenderInfo(this, block);
}
}
12 changes: 12 additions & 0 deletions apps/client/src/shared/utils/boolockConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
클래스명 지정 시 블록타입과 이름이 같으면 해당 블록 타입에 대한 블록 모양이 생성되는 오류가 있습니다.
이를 해결하기 위해 모든 블록타입을 지정할 때는 해당 함수를 통해 PreviousTypeName을 붙이게 하였습니다.
앞으로 블록 타입을 지정할때는 꼭..! 이 함수를 이용해서 블록 타입명을 지정해주세요.
*/
export const PREVIOUS_TYPE_NAME = 'BOOLOCK_SYSTEM_';

export const addPreviousTypeName = (type: string) => {
return `${PREVIOUS_TYPE_NAME}${type}`;
};

// TODO: css 클래스명 만들때 해당 previousTypeName으로 시작할 경우 블록 생성이 안 되도록 막는 로직도 추가해주시길 바랍니다.
1 change: 1 addition & 0 deletions apps/client/src/shared/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export { CATEGORY_ICONS } from './categoryIcons';
export { getUserId } from './userId';
export { formatRelativeOrAbsoluteDate } from './dateFormat';
export { w, h, foreground, background } from './spinnerStyle';
export { addPreviousTypeName, PREVIOUS_TYPE_NAME } from './boolockConstants';
export { debounce } from './debounce';
export { cssCategoryList } from './cssCategoryList';
Loading
Loading