Compare commits

...

4 Commits

Author SHA1 Message Date
92583141c9 fixes
Some checks failed
CI / rust (push) Failing after 36s
CI / frontend (push) Failing after 1m53s
CI / wasm (push) Successful in 1m46s
CI / publish-crates (push) Has been skipped
CI / publish-npm (push) Has been skipped
2026-04-09 02:16:27 +03:00
58a59f2609 refactor 2026-04-09 01:40:37 +03:00
aa27228d08 refactor 2026-04-09 00:36:23 +03:00
4fda0e7d98 repofactor 2026-04-09 00:15:05 +03:00
53 changed files with 3924 additions and 4911 deletions

View File

@@ -230,6 +230,26 @@ pub struct ChartAxis {
pub y_label: Option<String>,
pub show_grid: Option<bool>,
pub grid_color: Option<String>,
/// Show vertical grid lines at each category (line charts). Defaults to true.
pub show_vertical_grid: Option<bool>,
pub vertical_grid_color: Option<String>,
#[serde(default)]
pub reference_lines: Vec<ChartReferenceLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartReferenceLine {
/// Category index (0-based) where the vertical line is drawn
pub category_index: usize,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub width: Option<f64>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub dash: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -247,11 +267,8 @@ pub struct ChartStyle {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub chart_type: ChartType,
pub data_source: ArrayBinding,
pub category_field: String,
@@ -272,6 +289,138 @@ pub struct ChartElement {
pub style: ChartStyle,
}
// --- Element Base (ortak alanlar) ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ElementBase {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
#[serde(default)]
pub position: PositionMode,
#[serde(default)]
pub size: SizeConstraint,
}
impl ElementBase {
/// Flow pozisyonlu, condition'sız, verilen size ile base oluştur
pub fn flow(id: String, size: SizeConstraint) -> Self {
Self {
id,
condition: None,
position: PositionMode::Flow,
size,
}
}
}
pub trait HasBase {
fn base(&self) -> &ElementBase;
fn base_mut(&mut self) -> &mut ElementBase;
}
macro_rules! impl_has_base {
($($t:ty),+ $(,)?) => {
$(impl HasBase for $t {
fn base(&self) -> &ElementBase { &self.base }
fn base_mut(&mut self) -> &mut ElementBase { &mut self.base }
})+
};
}
impl_has_base!(
ContainerElement,
StaticTextElement,
TextElement,
LineElement,
ImageElement,
PageNumberElement,
BarcodeElement,
RepeatingTableElement,
PageBreakElement,
CurrentDateElement,
ShapeElement,
CheckboxElement,
CalculatedTextElement,
RichTextElement,
ChartElement,
);
pub trait ElementTypeStr {
fn type_str(&self) -> &'static str;
}
macro_rules! impl_type_str {
($($t:ty => $s:literal),+ $(,)?) => {
$(impl ElementTypeStr for $t {
fn type_str(&self) -> &'static str { $s }
})+
};
}
impl_type_str!(
ContainerElement => "container",
StaticTextElement => "static_text",
TextElement => "text",
LineElement => "line",
ImageElement => "image",
PageNumberElement => "page_number",
BarcodeElement => "barcode",
RepeatingTableElement => "repeating_table",
PageBreakElement => "page_break",
CurrentDateElement => "current_date",
ShapeElement => "shape",
CheckboxElement => "checkbox",
CalculatedTextElement => "calculated_text",
RichTextElement => "rich_text",
ChartElement => "chart",
);
pub trait HasTextStyle {
fn text_style(&self) -> &TextStyle;
}
macro_rules! impl_has_text_style {
($($t:ty),+ $(,)?) => {
$(impl HasTextStyle for $t {
fn text_style(&self) -> &TextStyle { &self.style }
})+
};
}
impl_has_text_style!(
StaticTextElement,
TextElement,
PageNumberElement,
CurrentDateElement,
CalculatedTextElement,
RichTextElement,
);
pub trait HasOptionalBinding {
fn binding(&self) -> Option<&ScalarBinding>;
fn static_value(&self) -> Option<&str>;
}
impl HasOptionalBinding for ImageElement {
fn binding(&self) -> Option<&ScalarBinding> {
self.binding.as_ref()
}
fn static_value(&self) -> Option<&str> {
self.src.as_deref()
}
}
impl HasOptionalBinding for BarcodeElement {
fn binding(&self) -> Option<&ScalarBinding> {
self.binding.as_ref()
}
fn static_value(&self) -> Option<&str> {
self.value.as_deref()
}
}
// --- Element tipleri ---
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -316,91 +465,59 @@ pub enum TemplateElement {
}
impl TemplateElement {
pub fn id(&self) -> &str {
fn inner_base(&self) -> &ElementBase {
match self {
Self::Container(e) => &e.id,
Self::StaticText(e) => &e.id,
Self::Text(e) => &e.id,
Self::Line(e) => &e.id,
Self::RepeatingTable(e) => &e.id,
Self::Image(e) => &e.id,
Self::PageNumber(e) => &e.id,
Self::Barcode(e) => &e.id,
Self::PageBreak(e) => &e.id,
Self::CurrentDate(e) => &e.id,
Self::Shape(e) => &e.id,
Self::Checkbox(e) => &e.id,
Self::CalculatedText(e) => &e.id,
Self::RichText(e) => &e.id,
Self::Chart(e) => &e.id,
Self::Container(e) => e.base(),
Self::StaticText(e) => e.base(),
Self::Text(e) => e.base(),
Self::Line(e) => e.base(),
Self::RepeatingTable(e) => e.base(),
Self::Image(e) => e.base(),
Self::PageNumber(e) => e.base(),
Self::Barcode(e) => e.base(),
Self::PageBreak(e) => e.base(),
Self::CurrentDate(e) => e.base(),
Self::Shape(e) => e.base(),
Self::Checkbox(e) => e.base(),
Self::CalculatedText(e) => e.base(),
Self::RichText(e) => e.base(),
Self::Chart(e) => e.base(),
}
}
pub fn id(&self) -> &str {
&self.inner_base().id
}
pub fn position(&self) -> &PositionMode {
match self {
Self::Container(e) => &e.position,
Self::StaticText(e) => &e.position,
Self::Text(e) => &e.position,
Self::Line(e) => &e.position,
Self::RepeatingTable(e) => &e.position,
Self::Image(e) => &e.position,
Self::PageNumber(e) => &e.position,
Self::Barcode(e) => &e.position,
Self::PageBreak(_) => &PositionMode::Flow,
Self::CurrentDate(e) => &e.position,
Self::Shape(e) => &e.position,
Self::Checkbox(e) => &e.position,
Self::CalculatedText(e) => &e.position,
Self::RichText(e) => &e.position,
Self::Chart(e) => &e.position,
}
&self.inner_base().position
}
pub fn condition(&self) -> Option<&Condition> {
match self {
Self::Container(e) => e.condition.as_ref(),
Self::StaticText(e) => e.condition.as_ref(),
Self::Text(e) => e.condition.as_ref(),
Self::Line(e) => e.condition.as_ref(),
Self::RepeatingTable(e) => e.condition.as_ref(),
Self::Image(e) => e.condition.as_ref(),
Self::PageNumber(e) => e.condition.as_ref(),
Self::Barcode(e) => e.condition.as_ref(),
Self::PageBreak(e) => e.condition.as_ref(),
Self::CurrentDate(e) => e.condition.as_ref(),
Self::Shape(e) => e.condition.as_ref(),
Self::Checkbox(e) => e.condition.as_ref(),
Self::CalculatedText(e) => e.condition.as_ref(),
Self::RichText(e) => e.condition.as_ref(),
Self::Chart(e) => e.condition.as_ref(),
}
self.inner_base().condition.as_ref()
}
pub fn size(&self) -> &SizeConstraint {
static DEFAULT_SIZE: SizeConstraint = SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
};
&self.inner_base().size
}
pub fn type_str(&self) -> &'static str {
match self {
Self::Container(e) => &e.size,
Self::StaticText(e) => &e.size,
Self::Text(e) => &e.size,
Self::Line(e) => &e.size,
Self::RepeatingTable(e) => &e.size,
Self::Image(e) => &e.size,
Self::PageNumber(e) => &e.size,
Self::Barcode(e) => &e.size,
Self::PageBreak(_) => &DEFAULT_SIZE,
Self::CurrentDate(e) => &e.size,
Self::Shape(e) => &e.size,
Self::Checkbox(e) => &e.size,
Self::CalculatedText(e) => &e.size,
Self::RichText(e) => &e.size,
Self::Chart(e) => &e.size,
Self::Container(e) => e.type_str(),
Self::StaticText(e) => e.type_str(),
Self::Text(e) => e.type_str(),
Self::Line(e) => e.type_str(),
Self::RepeatingTable(e) => e.type_str(),
Self::Image(e) => e.type_str(),
Self::PageNumber(e) => e.type_str(),
Self::Barcode(e) => e.type_str(),
Self::PageBreak(e) => e.type_str(),
Self::CurrentDate(e) => e.type_str(),
Self::Shape(e) => e.type_str(),
Self::Checkbox(e) => e.type_str(),
Self::CalculatedText(e) => e.type_str(),
Self::RichText(e) => e.type_str(),
Self::Chart(e) => e.type_str(),
}
}
}
@@ -408,11 +525,8 @@ impl TemplateElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RichTextElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
#[serde(default)]
pub style: TextStyle, // varsayilan stil (span'lar override edebilir)
pub content: Vec<RichTextSpan>,
@@ -421,13 +535,8 @@ pub struct RichTextElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContainerElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
#[serde(default)]
pub position: PositionMode,
#[serde(default)]
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
#[serde(default = "default_column")]
pub direction: String,
#[serde(default)]
@@ -463,11 +572,8 @@ fn default_start() -> String {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StaticTextElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub content: String,
}
@@ -475,11 +581,8 @@ pub struct StaticTextElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub content: Option<String>,
pub binding: ScalarBinding,
@@ -488,22 +591,16 @@ pub struct TextElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LineElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: LineStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub src: Option<String>,
pub binding: Option<ScalarBinding>,
pub style: ImageStyle,
@@ -512,11 +609,8 @@ pub struct ImageElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageNumberElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub format: Option<String>,
}
@@ -524,11 +618,8 @@ pub struct PageNumberElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BarcodeElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub format: String, // qr, ean13, ean8, code128, code39
pub value: Option<String>,
pub binding: Option<ScalarBinding>,
@@ -538,11 +629,8 @@ pub struct BarcodeElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RepeatingTableElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub data_source: ArrayBinding,
pub columns: Vec<TableColumn>,
pub style: TableStyle,
@@ -557,19 +645,15 @@ fn default_true() -> Option<bool> {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageBreakElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
#[serde(flatten)]
pub base: ElementBase,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentDateElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub format: Option<String>,
}
@@ -577,11 +661,8 @@ pub struct CurrentDateElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShapeElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub shape_type: String, // rectangle, ellipse, rounded_rectangle
pub style: ContainerStyle,
}
@@ -598,11 +679,8 @@ pub struct CheckboxStyle {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckboxElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub checked: Option<bool>, // statik değer
pub binding: Option<ScalarBinding>, // dinamik boolean binding
pub style: CheckboxStyle,
@@ -611,11 +689,8 @@ pub struct CheckboxElement {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalculatedTextElement {
pub id: String,
#[serde(default)]
pub condition: Option<Condition>,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(flatten)]
pub base: ElementBase,
pub style: TextStyle,
pub expression: String,
pub format: Option<String>,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { ChartElement, ChartType } from '../../../core/types'
defineProps<{ chart: ChartElement }>()
const emit = defineEmits<{
update: [updates: Record<string, unknown>]
updateStyle: [key: string, value: unknown]
}>()
</script>
<template>
<!-- Chart type -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': chart.chartType === 'bar' }" data-tip="Cubuk" @click="emit('update', { chartType: 'bar' as ChartType })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="6" width="3" height="6" rx="0.5" fill="currentColor" /><rect x="5.5" y="3" width="3" height="9" rx="0.5" fill="currentColor" /><rect x="9" y="5" width="3" height="7" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': chart.chartType === 'line' }" data-tip="Cizgi" @click="emit('update', { chartType: 'line' as ChartType })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><polyline points="2,10 5,5 8,7 12,3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" /><circle cx="2" cy="10" r="1.2" fill="currentColor" /><circle cx="5" cy="5" r="1.2" fill="currentColor" /><circle cx="8" cy="7" r="1.2" fill="currentColor" /><circle cx="12" cy="3" r="1.2" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': chart.chartType === 'pie' }" data-tip="Pasta" @click="emit('update', { chartType: 'pie' as ChartType })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 2a5 5 0 1 1-3.54 1.46" stroke="currentColor" stroke-width="1.3" fill="none" /><path d="M7 7V2A5 5 0 0 0 3.46 3.46Z" fill="currentColor" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Show labels -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': chart.labels?.show !== false }" data-tip="Etiketler" @click="emit('update', { labels: { ...chart.labels, show: chart.labels?.show === false ? true : false } })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="8" width="3" height="4" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="5.5" y="5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="9" y="6" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.4" /><text x="3.5" y="7" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">3</text><text x="7" y="4" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">7</text><text x="10.5" y="5" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">5</text></svg>
</button>
</div>
<div class="et__sep" />
<!-- Show grid -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': chart.axis?.showGrid !== false }" data-tip="Izgara" @click="emit('update', { axis: { ...chart.axis, showGrid: chart.axis?.showGrid === false ? true : false } })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5" /><line x1="2" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5" /><line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Background color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Arka Plan">
<input type="color" class="et__color" :value="chart.style.backgroundColor ?? '#ffffff'" @input="(e) => emit('updateStyle', 'backgroundColor', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="10" rx="1.5" :fill="chart.style.backgroundColor ?? '#ffffff'" stroke="#94a3b8" stroke-width="0.8" /></svg>
</label>
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { ContainerElement } from '../../../core/types'
const props = defineProps<{ container: ContainerElement }>()
const emit = defineEmits<{
update: [updates: Record<string, unknown>]
}>()
</script>
<template>
<!-- Direction -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': container.direction === 'column' }" data-tip="Dikey" @click="emit('update', { direction: 'column' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="2" y="5.5" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="2" y="10" width="10" height="3" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.direction === 'row' }" data-tip="Yatay" @click="emit('update', { direction: 'row' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="5.5" y="2" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="10" y="2" width="3" height="10" rx="0.5" fill="currentColor" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Align -->
<div class="et__group">
<template v-if="container.direction === 'column'">
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'start' }" data-tip="Sol" @click="emit('update', { align: 'start' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="8" height="2.5" rx="0.5" fill="currentColor" /><rect x="3.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'center' }" data-tip="Orta" @click="emit('update', { align: 'center' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="6.25" y="1" width="1.5" height="12" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3" width="8" height="2.5" rx="0.5" fill="currentColor" /><rect x="4.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'end' }" data-tip="Sag" @click="emit('update', { align: 'end' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2.5" y="3" width="8" height="2.5" rx="0.5" fill="currentColor" /><rect x="5.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'stretch' }" data-tip="Esnet" @click="emit('update', { align: 'stretch' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="7" height="2.5" rx="0.5" fill="currentColor" /><rect x="3.5" y="8" width="7" height="2.5" rx="0.5" fill="currentColor" /></svg>
</button>
</template>
<template v-else>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'start' }" data-tip="Ust" @click="emit('update', { align: 'start' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="2.5" height="8" rx="0.5" fill="currentColor" /><rect x="8" y="3.5" width="2.5" height="5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'center' }" data-tip="Orta" @click="emit('update', { align: 'center' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="6.25" width="12" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="2" width="2.5" height="10" rx="0.5" fill="currentColor" /><rect x="8" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'end' }" data-tip="Alt" @click="emit('update', { align: 'end' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="2.5" width="2.5" height="8" rx="0.5" fill="currentColor" /><rect x="8" y="5.5" width="2.5" height="5" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'stretch' }" data-tip="Esnet" @click="emit('update', { align: 'stretch' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor" /><rect x="8" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor" /></svg>
</button>
</template>
</div>
<div class="et__sep" />
<!-- Justify -->
<div class="et__group">
<template v-if="container.direction === 'column'">
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'start' }" data-tip="Ust" @click="emit('update', { justify: 'start' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="6.5" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'center' }" data-tip="Orta" @click="emit('update', { justify: 'center' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="6.25" width="12" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="9" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'end' }" data-tip="Alt" @click="emit('update', { justify: 'end' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="5.5" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="8.5" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'space-between' }" data-tip="Esit Aralik" @click="emit('update', { justify: 'space-between' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="8.5" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
</button>
</template>
<template v-else>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'start' }" data-tip="Sol" @click="emit('update', { justify: 'start' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'center' }" data-tip="Orta" @click="emit('update', { justify: 'center' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="6.25" y="1" width="1.5" height="12" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="9" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'end' }" data-tip="Sag" @click="emit('update', { justify: 'end' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'space-between' }" data-tip="Esit Aralik" @click="emit('update', { justify: 'space-between' })">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
</button>
</template>
</div>
<div class="et__sep" />
<!-- Gap -->
<div class="et__group et__group--gap" data-tip="Bosluk (mm)">
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="1" y="1" width="3.5" height="10" rx="0.5" stroke="currentColor" stroke-width="1" fill="none" /><rect x="7.5" y="1" width="3.5" height="10" rx="0.5" stroke="currentColor" stroke-width="1" fill="none" /><line x1="6" y1="3" x2="6" y2="9" stroke="currentColor" stroke-width="1" stroke-dasharray="1.5 1" /></svg>
<input type="number" class="et__num" step="1" min="0" :value="container.gap" @input="(e) => emit('update', { gap: parseFloat((e.target as HTMLInputElement).value) || 0 })" />
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { TableStyle } from '../../../core/types'
defineProps<{ tableStyle: TableStyle }>()
const emit = defineEmits<{
updateStyle: [key: string, value: unknown]
}>()
</script>
<template>
<!-- Font size -->
<div class="et__group et__group--gap" data-tip="Yazi Boyutu">
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 10L6 2l4 8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /><line x1="3.5" y1="7" x2="8.5" y2="7" stroke="currentColor" stroke-width="1" stroke-linecap="round" /></svg>
<input type="number" class="et__num" step="1" min="6" :value="tableStyle.fontSize ?? 10" @input="(e) => emit('updateStyle', 'fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
</div>
<div class="et__sep" />
<!-- Header bg color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Header Rengi">
<input type="color" class="et__color" :value="tableStyle.headerBg ?? '#f0f0f0'" @input="(e) => emit('updateStyle', 'headerBg', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="4" rx="1" :fill="tableStyle.headerBg ?? '#f0f0f0'" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="7" width="10" height="2" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="10" width="10" height="2" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /></svg>
</label>
</div>
<!-- Zebra color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Zebra Rengi">
<input type="color" class="et__color" :value="tableStyle.zebraOdd ?? '#fafafa'" @input="(e) => emit('updateStyle', 'zebraOdd', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="2.5" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="5.5" width="10" height="2.5" rx="0.5" :fill="tableStyle.zebraOdd ?? '#fafafa'" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="9" width="10" height="2.5" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /></svg>
</label>
</div>
<div class="et__sep" />
<!-- Border color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Kenarlik Rengi">
<input type="color" class="et__color" :value="tableStyle.borderColor ?? '#cccccc'" @input="(e) => emit('updateStyle', 'borderColor', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="10" rx="1" fill="none" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="1.5" /><line x1="2" y1="6" x2="12" y2="6" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="0.8" /><line x1="7" y1="2" x2="7" y2="12" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="0.8" /></svg>
</label>
</div>
<!-- Border width -->
<div class="et__group et__group--gap" data-tip="Kenarlik (mm)">
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="1" y="1" width="10" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
<input type="number" class="et__num" step="0.1" min="0" :value="tableStyle.borderWidth ?? 0.5" @input="(e) => emit('updateStyle', 'borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { TextStyle, TemplateElement } from '../../../core/types'
const props = defineProps<{ element: TemplateElement }>()
const style = () => props.element.style as TextStyle
const emit = defineEmits<{
updateStyle: [key: string, value: unknown]
}>()
</script>
<template>
<!-- Bold -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': style().fontWeight === 'bold' }" data-tip="Kalin" @click="emit('updateStyle', 'fontWeight', style().fontWeight === 'bold' ? 'normal' : 'bold')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M4 2.5h3.5a2.5 2.5 0 0 1 0 5H4V2.5z" stroke="currentColor" stroke-width="1.5" fill="none" /><path d="M4 7.5h4a2.5 2.5 0 0 1 0 5H4V7.5z" stroke="currentColor" stroke-width="1.5" fill="none" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Align -->
<div class="et__group">
<button class="et__btn" :class="{ 'et__btn--active': (style().align ?? 'left') === 'left' }" data-tip="Sola Hizala" @click="emit('updateStyle', 'align', 'left')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="2" y1="7" x2="9" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="2" y1="11" x2="11" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': style().align === 'center' }" data-tip="Ortala" @click="emit('updateStyle', 'align', 'center')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="3.5" y1="7" x2="10.5" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="2.5" y1="11" x2="11.5" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
</button>
<button class="et__btn" :class="{ 'et__btn--active': style().align === 'right' }" data-tip="Saga Hizala" @click="emit('updateStyle', 'align', 'right')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="5" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="3" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
</button>
</div>
<div class="et__sep" />
<!-- Font size -->
<div class="et__group et__group--gap">
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 10L6 2l4 8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /><line x1="3.5" y1="7" x2="8.5" y2="7" stroke="currentColor" stroke-width="1" stroke-linecap="round" /></svg>
<input type="number" class="et__num" step="1" min="1" :value="style().fontSize ?? 11" @input="(e) => emit('updateStyle', 'fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" data-tip="Yazi Boyutu (pt)" />
</div>
<div class="et__sep" />
<!-- Color -->
<div class="et__group">
<label class="et__color-wrap" data-tip="Renk">
<input type="color" class="et__color" :value="style().color ?? '#000000'" @input="(e) => emit('updateStyle', 'color', (e.target as HTMLInputElement).value)" />
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11" width="10" height="2" rx="0.5" :fill="style().color ?? '#000000'" /><path d="M5 9L7 3l2 6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /><line x1="5.5" y1="7.5" x2="8.5" y2="7.5" stroke="currentColor" stroke-width="1" stroke-linecap="round" /></svg>
</label>
</div>
</template>

View File

@@ -32,6 +32,7 @@ import RichTextProperties from '../properties/RichTextProperties.vue'
import ContainerProperties from '../properties/ContainerProperties.vue'
import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
import ChartProperties from '../properties/ChartProperties.vue'
import PropCondition from '../properties/shared/PropCondition.vue'
import '../../styles/properties.css'
const templateStore = useTemplateStore()
@@ -233,6 +234,13 @@ function deleteSelected() {
</div>
</div>
<!-- Condition -->
<PropCondition
v-if="selectedElement.id !== 'root'"
:condition="selectedElement.condition"
@update:condition="(v) => templateStore.updateElement(selectedElement!.id, { condition: v } as any)"
/>
<!-- Delete -->
<div v-if="selectedElement.id !== 'root'" class="prop-section">
<button class="prop-delete-btn" @click="deleteElement">Sil</button>

View File

@@ -3,6 +3,7 @@ import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema'
import type {
TemplateElement,
TextElement,
RepeatingTableElement,
TableColumn,
ImageElement,
@@ -46,6 +47,18 @@ const tools: ToolItem[] = [
content: 'Yeni metin',
}),
},
{
label: 'Veri Metni',
icon: 'D',
create: (): TextElement => ({
id: nextId('dtxt'),
type: 'text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 11, color: '#000000' },
binding: { type: 'scalar', path: '' },
}),
},
{
label: 'Zengin Metin',
icon: 'R',

View File

@@ -1,25 +1,26 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import type { BarcodeElement, BarcodeFormat, TemplateElement } from '../../core/types'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropCheckbox from './shared/PropCheckbox.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import type { BarcodeElement, BarcodeFormat } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: BarcodeElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const formatOptions = [
{ value: 'qr', label: 'QR Kod' },
{ value: 'ean13', label: 'EAN-13' },
{ value: 'ean8', label: 'EAN-8' },
{ value: 'code128', label: 'Code 128' },
{ value: 'code39', label: 'Code 39' },
]
const barcodeDefaults: Record<BarcodeFormat, string> = {
qr: 'https://example.com',
@@ -73,7 +74,6 @@ watch(
function onBarcodeValueInput(e: Event) {
const val = (e.target as HTMLInputElement).value
barcodeInputValue.value = val
if (validateBarcode(props.element.format, val)) {
barcodeInputInvalid.value = false
update({ value: val } as any)
@@ -82,38 +82,29 @@ function onBarcodeValueInput(e: Event) {
}
}
function onBarcodeFormatChange(newFormat: BarcodeFormat) {
function onBarcodeFormatChange(newFormat: string) {
const fmt = newFormat as BarcodeFormat
const currentValue = props.element.value ?? ''
if (validateBarcode(newFormat, currentValue)) {
update({ format: newFormat } as any)
if (validateBarcode(fmt, currentValue)) {
update({ format: fmt } as any)
} else {
const defaultVal = barcodeDefaults[newFormat]
const defaultVal = barcodeDefaults[fmt]
barcodeInputValue.value = defaultVal
barcodeInputInvalid.value = false
update({ format: newFormat, value: defaultVal } as any)
update({ format: fmt, value: defaultVal } as any)
}
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Barkod Ayarlari</div>
<div class="prop-row" data-tip="Barkod formati">
<label class="prop-label">Format</label>
<select
class="prop-input prop-select"
:value="element.format"
@change="
(e) => onBarcodeFormatChange((e.target as HTMLSelectElement).value as BarcodeFormat)
"
>
<option value="qr">QR Kod</option>
<option value="ean13">EAN-13</option>
<option value="ean8">EAN-8</option>
<option value="code128">Code 128</option>
<option value="code39">Code 39</option>
</select>
</div>
<PropSection title="Barkod Ayarlari">
<PropSelect
label="Format"
:model-value="element.format"
:options="formatOptions"
data-tip="Barkod formati"
@update:model-value="onBarcodeFormatChange"
/>
<div class="prop-row" data-tip="Barkod icerigi formata uygun olmali">
<label class="prop-label">Deger</label>
<input
@@ -124,63 +115,36 @@ function onBarcodeFormatChange(newFormat: BarcodeFormat) {
@input="onBarcodeValueInput"
/>
</div>
<div class="prop-row" data-tip="Barkod cizgi/modül rengi">
<label class="prop-label">Renk</label>
<div class="prop-row-inline">
<input
class="prop-input prop-color"
type="color"
:value="element.style.color ?? '#000000'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)"
/>
<button
v-if="element.style.color"
class="prop-clear"
@click="updateStyle('color', undefined)"
>
x
</button>
</div>
</div>
<div
<PropColorInput
label="Renk"
:model-value="element.style.color ?? '#000000'"
:clearable="true"
data-tip="Barkod cizgi/modul rengi"
@update:model-value="(v) => updateStyle('color', v)"
/>
<PropCheckbox
v-if="element.format !== 'qr'"
class="prop-row"
label="Metin Goster"
:model-value="
element.style.includeText ?? (element.format === 'ean13' || element.format === 'ean8')
"
data-tip="Barkod altinda degeri metin olarak goster"
>
<label class="prop-label">Metin Goster</label>
<input
type="checkbox"
:checked="
element.style.includeText ?? (element.format === 'ean13' || element.format === 'ean8')
"
@change="(e) => updateStyle('includeText', (e.target as HTMLInputElement).checked)"
/>
</div>
<div
@update:model-value="(v) => updateStyle('includeText', v)"
/>
<PropFieldSelect
v-if="schemaStore.scalarFields.length > 0"
class="prop-row"
label="Veri Baglama"
:model-value="element.binding?.path ?? ''"
:fields="schemaStore.scalarFields"
:allow-empty="true"
empty-label="Yok (statik deger)"
data-tip="Schema'dan dinamik veri baglama"
>
<label class="prop-label">Veri Baglama</label>
<select
class="prop-input prop-select"
:value="element.binding?.path ?? ''"
@change="
(e) => {
const val = (e.target as HTMLSelectElement).value
if (val) {
update({ binding: { type: 'scalar', path: val } } as any)
} else {
update({ binding: undefined } as any)
}
}
"
>
<option value="">Yok (statik deger)</option>
<option v-for="field in schemaStore.scalarFields" :key="field.path" :value="field.path">
{{ field.title }} ({{ field.path }})
</option>
</select>
</div>
</div>
@update:model-value="
(v) => {
if (v) update({ binding: { type: 'scalar', path: v } } as any)
else update({ binding: undefined } as any)
}
"
/>
</PropSection>
</template>

View File

@@ -1,32 +1,26 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { CalculatedTextElement, TextStyle, TemplateElement } from '../../core/types'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import DexprEditor from '../common/DexprEditor.vue'
import type { CalculatedTextElement, TextStyle } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CalculatedTextElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const style = () => props.element.style as TextStyle
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
function onExpressionChange(value: string) {
update({ expression: value } as any)
}
const formatOptions = [
{ value: '', label: 'Yok' },
{ value: 'currency', label: 'Para Birimi' },
{ value: 'number', label: 'Sayi' },
{ value: 'percentage', label: 'Yuzde' },
]
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Hesaplanan Metin</div>
<PropSection title="Hesaplanan Metin">
<div
class="prop-row-stack"
data-tip="Hesaplama ifadesi (orn: toplamlar.kdv + toplamlar.araToplam)"
@@ -34,69 +28,28 @@ function onExpressionChange(value: string) {
<label class="prop-label">Ifade</label>
<DexprEditor
:model-value="element.expression"
@update:model-value="onExpressionChange"
@update:model-value="(v) => update({ expression: v } as any)"
placeholder="toplamlar.kdv + toplamlar.araToplam"
/>
</div>
<div class="prop-row" data-tip="Sonucun gosterim formati">
<label class="prop-label">Format</label>
<select
class="prop-input prop-select"
:value="element.format ?? ''"
@change="
(e) => update({ format: (e.target as HTMLSelectElement).value || undefined } as any)
"
>
<option value="">Yok</option>
<option value="currency">Para Birimi</option>
<option value="number">Sayi</option>
<option value="percentage">Yuzde</option>
</select>
</div>
<div class="prop-row" data-tip="Yazi tipi boyutu (point)">
<label class="prop-label">Boyut (pt)</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.style as TextStyle).fontSize ?? 11"
@input="
(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)
"
/>
</div>
<div class="prop-row" data-tip="Metin rengi">
<label class="prop-label">Renk</label>
<input
class="prop-input prop-color"
type="color"
:value="(element.style as TextStyle).color ?? '#000000'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)"
/>
</div>
<div class="prop-row" data-tip="Yazi tipi kalinligi">
<label class="prop-label">Kalinlik</label>
<select
class="prop-input prop-select"
:value="(element.style as TextStyle).fontWeight ?? 'normal'"
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)"
>
<option value="normal">Normal</option>
<option value="bold">Kalin</option>
</select>
</div>
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
<label class="prop-label">Hizalama</label>
<select
class="prop-input prop-select"
:value="(element.style as TextStyle).align ?? 'left'"
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)"
>
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
<PropSelect
label="Format"
:model-value="element.format ?? ''"
:options="formatOptions"
data-tip="Sonucun gosterim formati"
@update:model-value="(v) => update({ format: v || undefined } as any)"
/>
<PropTextStyleGroup
:font-size="style().fontSize ?? 11"
:font-weight="style().fontWeight ?? 'normal'"
:font-family="style().fontFamily"
:color="style().color ?? '#000000'"
:align="style().align ?? 'left'"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>
</PropSection>
</template>

View File

@@ -1,32 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import type { ChartElement, ChartType, GroupMode, TemplateElement } from '../../core/types'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropCheckbox from './shared/PropCheckbox.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import type { ChartElement, ChartType, GroupMode } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: ChartElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle, updateNested } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
function update(updates: Partial<ChartElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates as Partial<TemplateElement>)
}
const chartTypeOptions = [
{ value: 'bar', label: 'Bar' },
{ value: 'line', label: 'Line' },
{ value: 'pie', label: 'Pie' },
]
function updateStyle(key: string, value: unknown) {
const newStyle = { ...props.element.style, [key]: value }
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
update({ style: newStyle })
}
const groupModeOptions = [
{ value: 'grouped', label: 'Yan Yana' },
{ value: 'stacked', label: 'Yigin' },
]
// Schema'daki array alanlari
const arrayFields = computed(() => schemaStore.arrayFields)
const alignOptions = [
{ value: 'left', label: 'Sol' },
{ value: 'center', label: 'Orta' },
{ value: 'right', label: 'Sag' },
]
const legendPositionOptions = [
{ value: 'top', label: 'Ust' },
{ value: 'bottom', label: 'Alt' },
{ value: 'right', label: 'Sag' },
]
// Secili array'in item alanlari
const itemFields = computed(() => {
const path = props.element.dataSource?.path
if (!path) return []
@@ -38,6 +49,15 @@ const numberFields = computed(() =>
itemFields.value.filter((f) => f.type === 'number' || f.type === 'integer'),
)
const isPie = computed(() => props.element.chartType === 'pie')
const hasGroup = computed(() => !!props.element.groupField)
const colorList = computed(() => {
return (
props.element.style.colors ?? ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']
)
})
function updateDataSource(path: string) {
const fields = schemaStore.getArrayItemFields(path)
const strField = fields.find((f) => f.type === 'string')
@@ -47,39 +67,9 @@ function updateDataSource(path: string) {
categoryField: strField?.key ?? fields[0]?.key ?? '',
valueField: numField?.key ?? fields[1]?.key ?? '',
groupField: undefined,
})
} as any)
}
function updateTitle(key: string, value: unknown) {
const current = props.element.title ?? { text: '' }
update({ title: { ...current, [key]: value } })
}
function updateLegend(key: string, value: unknown) {
const current = props.element.legend ?? { show: false }
update({ legend: { ...current, [key]: value } })
}
function updateLabels(key: string, value: unknown) {
const current = props.element.labels ?? { show: false }
update({ labels: { ...current, [key]: value } })
}
function updateAxis(key: string, value: unknown) {
const current = props.element.axis ?? {}
update({ axis: { ...current, [key]: value } })
}
const isPie = computed(() => props.element.chartType === 'pie')
const hasGroup = computed(() => !!props.element.groupField)
// Renk paleti (default 6 renk)
const colorList = computed(() => {
return (
props.element.style.colors ?? ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']
)
})
function updateColor(index: number, value: string) {
const colors = [...colorList.value]
colors[index] = value
@@ -87,8 +77,7 @@ function updateColor(index: number, value: string) {
}
function addColor() {
const colors = [...colorList.value, '#6B7280']
updateStyle('colors', colors)
updateStyle('colors', [...colorList.value, '#6B7280'])
}
function removeColor(index: number) {
@@ -100,218 +89,140 @@ function removeColor(index: number) {
<template>
<div class="chart-properties">
<!-- Grafik Tipi -->
<div class="prop-section">
<div class="prop-section__title">Grafik Tipi</div>
<div class="prop-row">
<select
class="prop-input prop-select"
:value="element.chartType"
@change="update({ chartType: ($event.target as HTMLSelectElement).value as ChartType })"
>
<option value="bar">Bar</option>
<option value="line">Line</option>
<option value="pie">Pie</option>
</select>
</div>
</div>
<PropSection title="Grafik Tipi">
<PropSelect
label=""
:model-value="element.chartType"
:options="chartTypeOptions"
@update:model-value="(v) => update({ chartType: v as ChartType } as any)"
/>
</PropSection>
<!-- Veri Kaynagi -->
<div class="prop-section">
<div class="prop-section__title">Veri Kaynagi</div>
<div class="prop-row">
<label class="prop-label">Array</label>
<select
class="prop-input prop-select"
:value="element.dataSource?.path ?? ''"
@change="updateDataSource(($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>Sec...</option>
<option v-for="arr in arrayFields" :key="arr.path" :value="arr.path">
{{ arr.title || arr.path }}
</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Kategori</label>
<select
class="prop-input prop-select"
:value="element.categoryField"
@change="update({ categoryField: ($event.target as HTMLSelectElement).value })"
>
<option v-for="f in itemFields" :key="f.key" :value="f.key">
{{ f.title || f.key }}
</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Deger</label>
<select
class="prop-input prop-select"
:value="element.valueField"
@change="update({ valueField: ($event.target as HTMLSelectElement).value })"
>
<option v-for="f in numberFields" :key="f.key" :value="f.key">
{{ f.title || f.key }}
</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Gruplama</label>
<select
class="prop-input prop-select"
:value="element.groupField ?? ''"
@change="update({ groupField: ($event.target as HTMLSelectElement).value || undefined })"
>
<option value="">Yok</option>
<option v-for="f in stringFields" :key="f.key" :value="f.key">
{{ f.title || f.key }}
</option>
</select>
</div>
<div v-if="hasGroup && !isPie" class="prop-row">
<label class="prop-label">Grup Modu</label>
<select
class="prop-input prop-select"
:value="element.groupMode ?? 'grouped'"
@change="update({ groupMode: ($event.target as HTMLSelectElement).value as GroupMode })"
>
<option value="grouped">Yan Yana</option>
<option value="stacked">Yigin</option>
</select>
</div>
</div>
<PropSection title="Veri Kaynagi">
<PropFieldSelect
label="Array"
:model-value="element.dataSource?.path ?? ''"
:fields="schemaStore.arrayFields"
placeholder="Sec..."
@update:model-value="updateDataSource"
/>
<PropFieldSelect
label="Kategori"
:model-value="element.categoryField"
:fields="itemFields"
@update:model-value="(v) => update({ categoryField: v } as any)"
/>
<PropFieldSelect
label="Deger"
:model-value="element.valueField"
:fields="numberFields"
@update:model-value="(v) => update({ valueField: v } as any)"
/>
<PropFieldSelect
label="Gruplama"
:model-value="element.groupField ?? ''"
:fields="stringFields"
:allow-empty="true"
empty-label="Yok"
@update:model-value="(v) => update({ groupField: v || undefined } as any)"
/>
<PropSelect
v-if="hasGroup && !isPie"
label="Grup Modu"
:model-value="element.groupMode ?? 'grouped'"
:options="groupModeOptions"
@update:model-value="(v) => update({ groupMode: v as GroupMode } as any)"
/>
</PropSection>
<!-- Baslik -->
<div class="prop-section">
<div class="prop-section__title">Baslik</div>
<PropSection title="Baslik">
<div class="prop-row">
<label class="prop-label">Metin</label>
<input
class="prop-input"
type="text"
:value="element.title?.text ?? ''"
@change="updateTitle('text', ($event.target as HTMLInputElement).value)"
@change="(e) => updateNested('title', 'text', (e.target as HTMLInputElement).value, { text: '' })"
placeholder="Grafik basligi"
/>
</div>
<div class="prop-row" v-if="element.title?.text">
<label class="prop-label">Boyut</label>
<input
class="prop-input prop-input--sm"
type="number"
:value="element.title?.fontSize ?? 4"
step="0.5"
@change="updateTitle('fontSize', parseFloat(($event.target as HTMLInputElement).value))"
<template v-if="element.title?.text">
<PropNumberInput
label="Boyut"
:model-value="element.title?.fontSize ?? 4"
:step="0.5"
@update:model-value="(v) => updateNested('title', 'fontSize', v, { text: '' })"
/>
</div>
<div class="prop-row" v-if="element.title?.text">
<label class="prop-label">Renk</label>
<input
class="prop-color"
type="color"
:value="element.title?.color ?? '#333333'"
@input="updateTitle('color', ($event.target as HTMLInputElement).value)"
<PropColorInput
label="Renk"
:model-value="element.title?.color ?? '#333333'"
@update:model-value="(v) => updateNested('title', 'color', v, { text: '' })"
/>
</div>
<div class="prop-row" v-if="element.title?.text">
<label class="prop-label">Hiza</label>
<select
class="prop-input prop-select"
:value="element.title?.align ?? 'center'"
@change="updateTitle('align', ($event.target as HTMLSelectElement).value)"
>
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
<PropSelect
label="Hiza"
:model-value="element.title?.align ?? 'center'"
:options="alignOptions"
@update:model-value="(v) => updateNested('title', 'align', v, { text: '' })"
/>
</template>
</PropSection>
<!-- Gosterge (Legend) -->
<div class="prop-section">
<div class="prop-section__title">Gosterge</div>
<div class="prop-row">
<label class="prop-label">Goster</label>
<input
type="checkbox"
:checked="element.legend?.show ?? false"
@change="updateLegend('show', ($event.target as HTMLInputElement).checked)"
/>
</div>
<PropSection title="Gosterge">
<PropCheckbox
label="Goster"
:model-value="element.legend?.show ?? false"
@update:model-value="(v) => updateNested('legend', 'show', v, { show: false })"
/>
<template v-if="element.legend?.show">
<div class="prop-row">
<label class="prop-label">Konum</label>
<select
class="prop-input prop-select"
:value="element.legend?.position ?? 'bottom'"
@change="updateLegend('position', ($event.target as HTMLSelectElement).value)"
>
<option value="top">Ust</option>
<option value="bottom">Alt</option>
<option value="right">Sag</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Boyut</label>
<input
class="prop-input prop-input--sm"
type="number"
:value="element.legend?.fontSize ?? 2.8"
step="0.2"
@change="
updateLegend('fontSize', parseFloat(($event.target as HTMLInputElement).value))
"
/>
</div>
<PropSelect
label="Konum"
:model-value="element.legend?.position ?? 'bottom'"
:options="legendPositionOptions"
@update:model-value="(v) => updateNested('legend', 'position', v)"
/>
<PropNumberInput
label="Boyut"
:model-value="element.legend?.fontSize ?? 2.8"
:step="0.2"
@update:model-value="(v) => updateNested('legend', 'fontSize', v)"
/>
</template>
</div>
</PropSection>
<!-- Etiketler -->
<div class="prop-section">
<div class="prop-section__title">Etiketler</div>
<div class="prop-row">
<label class="prop-label">Goster</label>
<input
type="checkbox"
:checked="element.labels?.show ?? false"
@change="updateLabels('show', ($event.target as HTMLInputElement).checked)"
/>
</div>
<PropSection title="Etiketler">
<PropCheckbox
label="Goster"
:model-value="element.labels?.show ?? false"
@update:model-value="(v) => updateNested('labels', 'show', v, { show: false })"
/>
<template v-if="element.labels?.show">
<div class="prop-row">
<label class="prop-label">Boyut</label>
<input
class="prop-input prop-input--sm"
type="number"
:value="element.labels?.fontSize ?? 2.2"
step="0.2"
@change="
updateLabels('fontSize', parseFloat(($event.target as HTMLInputElement).value))
"
/>
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input
class="prop-color"
type="color"
:value="element.labels?.color ?? '#333333'"
@input="updateLabels('color', ($event.target as HTMLInputElement).value)"
/>
</div>
<PropNumberInput
label="Boyut"
:model-value="element.labels?.fontSize ?? 2.2"
:step="0.2"
@update:model-value="(v) => updateNested('labels', 'fontSize', v)"
/>
<PropColorInput
label="Renk"
:model-value="element.labels?.color ?? '#333333'"
@update:model-value="(v) => updateNested('labels', 'color', v)"
/>
</template>
</div>
</PropSection>
<!-- Eksenler (pie haric) -->
<div class="prop-section" v-if="!isPie">
<div class="prop-section__title">Eksenler</div>
<PropSection v-if="!isPie" title="Eksenler">
<div class="prop-row">
<label class="prop-label">X Etiketi</label>
<input
class="prop-input"
type="text"
:value="element.axis?.xLabel ?? ''"
@change="updateAxis('xLabel', ($event.target as HTMLInputElement).value || undefined)"
@change="(e) => updateNested('axis', 'xLabel', (e.target as HTMLInputElement).value || undefined, {})"
placeholder="X ekseni"
/>
</div>
@@ -321,116 +232,103 @@ function removeColor(index: number) {
class="prop-input"
type="text"
:value="element.axis?.yLabel ?? ''"
@change="updateAxis('yLabel', ($event.target as HTMLInputElement).value || undefined)"
@change="(e) => updateNested('axis', 'yLabel', (e.target as HTMLInputElement).value || undefined, {})"
placeholder="Y ekseni"
/>
</div>
<div class="prop-row">
<label class="prop-label">Izgara</label>
<input
type="checkbox"
:checked="element.axis?.showGrid ?? true"
@change="updateAxis('showGrid', ($event.target as HTMLInputElement).checked)"
<PropCheckbox
label="Izgara"
:model-value="element.axis?.showGrid ?? true"
@update:model-value="(v) => updateNested('axis', 'showGrid', v, {})"
/>
<PropColorInput
v-if="element.axis?.showGrid !== false"
label="Izgara Renk"
:model-value="element.axis?.gridColor ?? '#E5E7EB'"
@update:model-value="(v) => updateNested('axis', 'gridColor', v, {})"
/>
<template v-if="element.chartType === 'line'">
<PropCheckbox
label="Dikey Izgara"
:model-value="element.axis?.showVerticalGrid ?? true"
@update:model-value="(v) => updateNested('axis', 'showVerticalGrid', v, {})"
/>
</div>
<div class="prop-row" v-if="element.axis?.showGrid !== false">
<label class="prop-label">Izgara Renk</label>
<input
class="prop-color"
type="color"
:value="element.axis?.gridColor ?? '#E5E7EB'"
@input="updateAxis('gridColor', ($event.target as HTMLInputElement).value)"
<PropColorInput
v-if="element.axis?.showVerticalGrid !== false"
label="Dikey Izgara Renk"
:model-value="element.axis?.verticalGridColor ?? '#E5E7EB'"
@update:model-value="(v) => updateNested('axis', 'verticalGridColor', v, {})"
/>
</div>
</div>
</template>
</PropSection>
<!-- Stil -->
<div class="prop-section">
<div class="prop-section__title">Stil</div>
<div class="prop-row">
<label class="prop-label">Arka Plan</label>
<input
class="prop-color"
type="color"
:value="element.style.backgroundColor ?? '#FFFFFF'"
@input="updateStyle('backgroundColor', ($event.target as HTMLInputElement).value)"
/>
</div>
<PropSection title="Stil">
<PropColorInput
label="Arka Plan"
:model-value="element.style.backgroundColor ?? '#FFFFFF'"
@update:model-value="(v) => updateStyle('backgroundColor', v)"
/>
<!-- Renk Paleti -->
<div class="prop-section__subtitle">Renk Paleti</div>
<div v-for="(color, i) in colorList" :key="i" class="prop-row">
<input
class="prop-color"
type="color"
:value="color"
@input="updateColor(i, ($event.target as HTMLInputElement).value)"
@input="(e) => updateColor(i, (e.target as HTMLInputElement).value)"
/>
<button class="prop-btn-sm prop-btn-sm--danger" @click="removeColor(i)" title="Kaldir">
×
</button>
</div>
<button class="prop-btn-sm" @click="addColor">+ Renk Ekle</button>
</div>
</PropSection>
<!-- Tipe Ozel -->
<div class="prop-section" v-if="element.chartType === 'bar'">
<div class="prop-section__title">Bar Ayarlari</div>
<div class="prop-row">
<label class="prop-label">Bar Boslugu</label>
<input
class="prop-input prop-input--sm"
type="number"
:value="element.style.barGap ?? 0.2"
step="0.05"
min="0"
max="0.8"
@change="updateStyle('barGap', parseFloat(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
<PropSection v-if="element.chartType === 'bar'" title="Bar Ayarlari">
<PropNumberInput
label="Bar Boslugu"
:model-value="element.style.barGap ?? 0.2"
:step="0.05"
:min="0"
:max="0.8"
@update:model-value="(v) => updateStyle('barGap', v)"
/>
</PropSection>
<div class="prop-section" v-if="element.chartType === 'line'">
<div class="prop-section__title">Line Ayarlari</div>
<div class="prop-row">
<label class="prop-label">Cizgi Kalinligi</label>
<input
class="prop-input prop-input--sm"
type="number"
:value="element.style.lineWidth ?? 0.5"
step="0.1"
min="0.1"
@change="updateStyle('lineWidth', parseFloat(($event.target as HTMLInputElement).value))"
/>
</div>
<div class="prop-row">
<label class="prop-label">Noktalar</label>
<input
type="checkbox"
:checked="element.style.showPoints ?? true"
@change="updateStyle('showPoints', ($event.target as HTMLInputElement).checked)"
/>
</div>
</div>
<PropSection v-if="element.chartType === 'line'" title="Line Ayarlari">
<PropNumberInput
label="Cizgi Kalinligi"
:model-value="element.style.lineWidth ?? 0.5"
:step="0.1"
:min="0.1"
@update:model-value="(v) => updateStyle('lineWidth', v)"
/>
<PropSelect
label="Egri Tipi"
:model-value="element.style.curveType ?? 'linear'"
:options="[{ value: 'linear', label: 'Duz' }, { value: 'smooth', label: 'Yumusak' }]"
@update:model-value="(v) => updateStyle('curveType', v)"
/>
<PropCheckbox
label="Noktalar"
:model-value="element.style.showPoints ?? true"
@update:model-value="(v) => updateStyle('showPoints', v)"
/>
</PropSection>
<div class="prop-section" v-if="element.chartType === 'pie'">
<div class="prop-section__title">Pie Ayarlari</div>
<div class="prop-row">
<label class="prop-label">Ic Yaricap</label>
<input
class="prop-input prop-input--sm"
type="number"
:value="element.style.innerRadius ?? 0"
step="0.05"
min="0"
max="0.9"
@change="
updateStyle('innerRadius', parseFloat(($event.target as HTMLInputElement).value))
"
/>
</div>
<PropSection v-if="element.chartType === 'pie'" title="Pie Ayarlari">
<PropNumberInput
label="Ic Yaricap"
:model-value="element.style.innerRadius ?? 0"
:step="0.05"
:min="0"
:max="0.9"
@update:model-value="(v) => updateStyle('innerRadius', v)"
/>
<div class="prop-row" style="font-size: 11px; color: #94a3b8">0 = Pie, &gt;0 = Donut</div>
</div>
</PropSection>
</div>
</template>

View File

@@ -1,63 +1,75 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { CheckboxElement, TemplateElement } from '../../core/types'
import { computed } from 'vue'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import PropSection from './shared/PropSection.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropCheckbox from './shared/PropCheckbox.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import type { CheckboxElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CheckboxElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const booleanFields = computed(() =>
schemaStore.scalarFields.filter((f) => f.type === 'boolean' || f.type === 'string'),
)
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Onay Kutusu</div>
<div v-if="!element.binding" class="prop-row" data-tip="Onay kutusunun varsayilan durumu">
<label class="prop-label">Isaretli</label>
<input
type="checkbox"
:checked="element.checked ?? false"
@change="(e) => update({ checked: (e.target as HTMLInputElement).checked } as any)"
/>
</div>
<div class="prop-row" data-tip="Onay kutusu boyutu (mm)">
<label class="prop-label">Boyut (mm)</label>
<input
class="prop-input"
type="number"
step="0.5"
min="1"
:value="element.style.size ?? 4"
@input="(e) => updateStyle('size', parseFloat((e.target as HTMLInputElement).value) || 4)"
/>
</div>
<div class="prop-row" data-tip="Isaret (tik) rengi">
<label class="prop-label">Isaret Rengi</label>
<input
class="prop-input prop-color"
type="color"
:value="element.style.checkColor ?? '#000000'"
@input="(e) => updateStyle('checkColor', (e.target as HTMLInputElement).value)"
/>
</div>
<div class="prop-row" data-tip="Kutu kenarlik rengi">
<label class="prop-label">Kenar Rengi</label>
<input
class="prop-input prop-color"
type="color"
:value="element.style.borderColor ?? '#333333'"
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)"
/>
</div>
</div>
<PropSection title="Onay Kutusu">
<PropFieldSelect
label="Veri Alani"
:model-value="element.binding?.path ?? ''"
:fields="booleanFields"
:allow-empty="true"
empty-label="Yok (statik)"
data-tip="Onay durumunun gelecegi veri alani"
@update:model-value="
(v) =>
update({
binding: v ? { type: 'scalar', path: v } : undefined,
checked: v ? undefined : element.checked ?? false,
} as any)
"
/>
<PropCheckbox
v-if="!element.binding"
label="Isaretli"
:model-value="element.checked ?? false"
data-tip="Onay kutusunun varsayilan durumu"
@update:model-value="(v) => update({ checked: v } as any)"
/>
<PropNumberInput
label="Boyut (mm)"
:model-value="element.style.size ?? 4"
:step="0.5"
:min="1"
data-tip="Onay kutusu boyutu (mm)"
@update:model-value="(v) => updateStyle('size', v)"
/>
<PropColorInput
label="Isaret Rengi"
:model-value="element.style.checkColor ?? '#000000'"
data-tip="Isaret (tik) rengi"
@update:model-value="(v) => updateStyle('checkColor', v)"
/>
<PropColorInput
label="Kenar Rengi"
:model-value="element.style.borderColor ?? '#333333'"
data-tip="Kutu kenarlik rengi"
@update:model-value="(v) => updateStyle('borderColor', v)"
/>
<PropNumberInput
label="Kenar Kalinligi"
:model-value="element.style.borderWidth ?? 0.3"
:step="0.1"
:min="0"
data-tip="Kutu kenarlik kalinligi (mm)"
@update:model-value="(v) => updateStyle('borderWidth', v)"
/>
</PropSection>
</template>

View File

@@ -1,52 +1,51 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PaddingBox from './PaddingBox.vue'
import type { ContainerElement, TemplateElement } from '../../core/types'
import type { ContainerElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: ContainerElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
const directionOptions = [
{ value: 'column', label: 'Dikey' },
{ value: 'row', label: 'Yatay' },
]
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const breakOptions = [
{ value: 'auto', label: 'Izin Ver' },
{ value: 'avoid', label: 'Bolme' },
]
const borderStyleOptions = [
{ value: 'solid', label: 'Duz' },
{ value: 'dashed', label: 'Kesikli' },
{ value: 'dotted', label: 'Noktali' },
]
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Container Ayarlari</div>
<div class="prop-row" data-tip="Cocuk elemanlarin dizilim yonu">
<label class="prop-label">Yon</label>
<select
class="prop-input prop-select"
:value="element.direction"
@change="(e) => update({ direction: (e.target as HTMLSelectElement).value } as any)"
>
<option value="column">Dikey</option>
<option value="row">Yatay</option>
</select>
</div>
<div class="prop-row" data-tip="Cocuk elemanlar arasi bosluk (mm)">
<label class="prop-label">Bosluk (mm)</label>
<input
class="prop-input"
type="number"
step="1"
min="0"
:value="element.gap"
@input="
(e) => update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 } as any)
"
/>
</div>
<PropSection title="Container Ayarlari">
<PropSelect
label="Yon"
:model-value="element.direction"
:options="directionOptions"
data-tip="Cocuk elemanlarin dizilim yonu"
@update:model-value="(v) => update({ direction: v } as any)"
/>
<PropNumberInput
label="Bosluk (mm)"
:model-value="element.gap"
:step="1"
:min="0"
data-tip="Cocuk elemanlar arasi bosluk (mm)"
@update:model-value="(v) => update({ gap: v } as any)"
/>
<div class="prop-row" data-tip="Cocuklarin cross-axis hizalamasi">
<label class="prop-label">{{
element.direction === 'column' ? 'Yatay Hizalama' : 'Dikey Hizalama'
@@ -87,92 +86,53 @@ function updateStyle(key: string, value: unknown) {
@update="(side, value) => update({ padding: { ...element.padding, [side]: value } } as any)"
/>
<div class="prop-row" data-tip="Sayfa sonunda bolunmeyi kontrol eder">
<label class="prop-label">Sayfa Bolme</label>
<select
class="prop-input prop-select"
:value="element.breakInside ?? 'auto'"
@change="(e) => update({ breakInside: (e.target as HTMLSelectElement).value } as any)"
>
<option value="auto">Izin Ver</option>
<option value="avoid">Bolme</option>
</select>
</div>
<PropSelect
label="Sayfa Bolme"
:model-value="element.breakInside ?? 'auto'"
:options="breakOptions"
data-tip="Sayfa sonunda bolunmeyi kontrol eder"
@update:model-value="(v) => update({ breakInside: v } as any)"
/>
</PropSection>
<div class="prop-section__subtitle">Stil</div>
<div class="prop-row" data-tip="Container arka plan rengi">
<label class="prop-label">Arka plan</label>
<div class="prop-row-inline">
<input
class="prop-input prop-color"
type="color"
:value="element.style.backgroundColor ?? '#ffffff'"
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)"
/>
<button
v-if="element.style.backgroundColor"
class="prop-clear"
@click="updateStyle('backgroundColor', undefined)"
>
x
</button>
</div>
</div>
<div class="prop-row" data-tip="Kenarlik kalinligi (mm)">
<label class="prop-label">Kenarlik (mm)</label>
<input
class="prop-input"
type="number"
step="0.1"
min="0"
:value="element.style.borderWidth ?? 0"
@input="
(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)
"
/>
</div>
<div class="prop-row" data-tip="Kenarlik cizgisi rengi">
<label class="prop-label">Kenarlik rengi</label>
<div class="prop-row-inline">
<input
class="prop-input prop-color"
type="color"
:value="element.style.borderColor ?? '#000000'"
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)"
/>
<button
v-if="element.style.borderColor"
class="prop-clear"
@click="updateStyle('borderColor', undefined)"
>
x
</button>
</div>
</div>
<div class="prop-row" data-tip="Kenarlik cizgi stili">
<label class="prop-label">Kenarlik stili</label>
<select
class="prop-input prop-select"
:value="element.style.borderStyle ?? 'solid'"
@change="(e) => updateStyle('borderStyle', (e.target as HTMLSelectElement).value)"
>
<option value="solid">Duz</option>
<option value="dashed">Kesikli</option>
<option value="dotted">Noktali</option>
</select>
</div>
<div class="prop-row" data-tip="Kose yuvarlakligi (mm)">
<label class="prop-label">Radius (mm)</label>
<input
class="prop-input"
type="number"
step="0.5"
min="0"
:value="element.style.borderRadius ?? 0"
@input="
(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)
"
/>
</div>
</div>
<PropSection title="Stil">
<PropColorInput
label="Arka plan"
:model-value="element.style.backgroundColor"
default-color="#ffffff"
:clearable="true"
data-tip="Container arka plan rengi"
@update:model-value="(v) => updateStyle('backgroundColor', v)"
/>
<PropNumberInput
label="Kenarlik (mm)"
:model-value="element.style.borderWidth ?? 0"
:step="0.1"
:min="0"
data-tip="Kenarlik kalinligi (mm)"
@update:model-value="(v) => updateStyle('borderWidth', v)"
/>
<PropColorInput
label="Kenarlik rengi"
:model-value="element.style.borderColor"
:clearable="true"
data-tip="Kenarlik cizgisi rengi"
@update:model-value="(v) => updateStyle('borderColor', v)"
/>
<PropSelect
label="Kenarlik stili"
:model-value="element.style.borderStyle ?? 'solid'"
:options="borderStyleOptions"
data-tip="Kenarlik cizgi stili"
@update:model-value="(v) => updateStyle('borderStyle', v)"
/>
<PropNumberInput
label="Radius (mm)"
:model-value="element.style.borderRadius ?? 0"
:step="0.5"
:min="0"
data-tip="Kose yuvarlakligi (mm)"
@update:model-value="(v) => updateStyle('borderRadius', v)"
/>
</PropSection>
</template>

View File

@@ -1,73 +1,43 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { CurrentDateElement, TextStyle, TemplateElement } from '../../core/types'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { CurrentDateElement, TextStyle } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CurrentDateElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const style = () => props.element.style as TextStyle
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const formatOptions = [
{ value: 'DD.MM.YYYY', label: '30.03.2026' },
{ value: 'DD/MM/YYYY', label: '30/03/2026' },
{ value: 'YYYY-MM-DD', label: '2026-03-30' },
{ value: 'DD.MM.YYYY HH:mm', label: '30.03.2026 14:30' },
]
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Tarih</div>
<div class="prop-row" data-tip="Tarih gosterim formati">
<label class="prop-label">Format</label>
<select
class="prop-input prop-select"
:value="element.format ?? 'DD.MM.YYYY'"
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)"
>
<option value="DD.MM.YYYY">30.03.2026</option>
<option value="DD/MM/YYYY">30/03/2026</option>
<option value="YYYY-MM-DD">2026-03-30</option>
<option value="DD.MM.YYYY HH:mm">30.03.2026 14:30</option>
</select>
</div>
<div class="prop-row" data-tip="Yazi tipi boyutu (point)">
<label class="prop-label">Boyut (pt)</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.style as TextStyle).fontSize ?? 10"
@input="
(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)
"
/>
</div>
<div class="prop-row" data-tip="Metin rengi">
<label class="prop-label">Renk</label>
<input
class="prop-input prop-color"
type="color"
:value="(element.style as TextStyle).color ?? '#666666'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)"
/>
</div>
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
<label class="prop-label">Hizalama</label>
<select
class="prop-input prop-select"
:value="(element.style as TextStyle).align ?? 'left'"
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)"
>
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
<PropSection title="Tarih">
<PropSelect
label="Format"
:model-value="element.format ?? 'DD.MM.YYYY'"
:options="formatOptions"
data-tip="Tarih gosterim formati"
@update:model-value="(v) => update({ format: v } as any)"
/>
<PropTextStyleGroup
:font-size="style().fontSize ?? 10"
:font-weight="style().fontWeight ?? 'normal'"
:font-family="style().fontFamily"
:color="style().color ?? '#666666'"
:align="style().align ?? 'left'"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>
</PropSection>
</template>

View File

@@ -1,28 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import type { ImageElement, TemplateElement } from '../../core/types'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import type { ImageElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: ImageElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
/** Statik mi dinamik mi? */
const isDynamic = computed(() => !!props.element.binding)
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
const imageScalarFields = computed(() =>
schemaStore.scalarFields.filter((f) => f.format === 'image' || f.type === 'string'),
)
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const fitOptions = [
{ value: 'contain', label: 'Sigdir' },
{ value: 'cover', label: 'Kap' },
{ value: 'stretch', label: 'Esnet' },
]
function onImageFileSelect(e: Event) {
const input = e.target as HTMLInputElement
@@ -30,40 +30,24 @@ function onImageFileSelect(e: Event) {
if (!file) return
const reader = new FileReader()
reader.onload = () => {
update({ src: reader.result as string, binding: undefined } as Partial<TemplateElement>)
update({ src: reader.result as string, binding: undefined } as any)
}
reader.readAsDataURL(file)
}
function setMode(mode: 'static' | 'dynamic') {
if (mode === 'static') {
update({ binding: undefined } as Partial<TemplateElement>)
update({ binding: undefined } as any)
} else {
// Dinamik moda geç — ilk uygun alanı seç veya boş bırak
const imageFields = schemaStore.scalarFields.filter(
(f) => f.format === 'image' || f.type === 'string',
)
const path = imageFields.length > 0 ? imageFields[0].path : ''
update({ src: undefined, binding: { type: 'scalar', path } } as Partial<TemplateElement>)
const path = imageScalarFields.value.length > 0 ? imageScalarFields.value[0].path : ''
update({ src: undefined, binding: { type: 'scalar', path } } as any)
}
}
function setBindingPath(path: string) {
update({ binding: { type: 'scalar', path } } as Partial<TemplateElement>)
}
/** Schema'dan görsel olabilecek alanlar (format: image veya string) */
const imageScalarFields = computed(() => {
return schemaStore.scalarFields.filter((f) => f.format === 'image' || f.type === 'string')
})
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Gorsel</div>
<!-- Statik / Dinamik toggle -->
<div class="prop-row" data-tip="Gorsel kaynagi: dosya veya veri alanından">
<PropSection title="Gorsel">
<div class="prop-row" data-tip="Gorsel kaynagi: dosya veya veri alanindan">
<label class="prop-label">Mod</label>
<div class="prop-toggle-group">
<button
@@ -83,7 +67,6 @@ const imageScalarFields = computed(() => {
</div>
</div>
<!-- Statik: dosya seçimi -->
<template v-if="!isDynamic">
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
<label class="prop-label">Kaynak</label>
@@ -104,41 +87,28 @@ const imageScalarFields = computed(() => {
</div>
</template>
<!-- Dinamik: schema alan seçimi -->
<template v-else>
<div class="prop-row" data-tip="Gorsel URL'sinin gelecegi veri alani">
<label class="prop-label">Veri Alani</label>
<select
class="prop-input prop-select"
:value="element.binding?.path ?? ''"
@change="(e) => setBindingPath((e.target as HTMLSelectElement).value)"
>
<option value="" disabled>Secin...</option>
<option v-for="field in imageScalarFields" :key="field.path" :value="field.path">
{{ field.title }} ({{ field.path }})
</option>
</select>
</div>
<PropFieldSelect
label="Veri Alani"
:model-value="element.binding?.path ?? ''"
:fields="imageScalarFields"
data-tip="Gorsel URL'sinin gelecegi veri alani"
@update:model-value="(v) => update({ binding: { type: 'scalar', path: v } } as any)"
/>
<div v-if="element.binding?.path" class="prop-row">
<label class="prop-label">Path</label>
<span class="prop-info">{{ element.binding.path }}</span>
</div>
</template>
<!-- Sığdırma modu (ortak) -->
<div class="prop-row" data-tip="Gorselin alana sigdirma modu">
<label class="prop-label">Sigdirma</label>
<select
class="prop-input prop-select"
:value="element.style.objectFit ?? 'contain'"
@change="(e) => updateStyle('objectFit', (e.target as HTMLSelectElement).value)"
>
<option value="contain">Sigdir</option>
<option value="cover">Kap</option>
<option value="stretch">Esnet</option>
</select>
</div>
</div>
<PropSelect
label="Sigdirma"
:model-value="element.style.objectFit ?? 'contain'"
:options="fitOptions"
data-tip="Gorselin alana sigdirma modu"
@update:model-value="(v) => updateStyle('objectFit', v)"
/>
</PropSection>
</template>
<style scoped>

View File

@@ -1,46 +1,30 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { LineElement, TemplateElement } from '../../core/types'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import PropSection from './shared/PropSection.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import type { LineElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: LineElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function updateStyle(key: string, value: unknown) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, {
style: { ...props.element.style, [key]: value },
} as Partial<TemplateElement>)
}
const { updateStyle } = usePropertyUpdate(() => props.element)
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Cizgi Stili</div>
<div class="prop-row" data-tip="Cizgi kalinligi (mm)">
<label class="prop-label">Kalinlik (mm)</label>
<input
class="prop-input"
type="number"
step="0.1"
min="0.1"
:value="element.style.strokeWidth ?? 0.5"
@input="
(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)
"
/>
</div>
<div class="prop-row" data-tip="Cizgi rengi">
<label class="prop-label">Renk</label>
<input
class="prop-input prop-color"
type="color"
:value="element.style.strokeColor ?? '#000000'"
@input="(e) => updateStyle('strokeColor', (e.target as HTMLInputElement).value)"
/>
</div>
</div>
<PropSection title="Cizgi Stili">
<PropNumberInput
label="Kalinlik (mm)"
:model-value="element.style.strokeWidth ?? 0.5"
:step="0.1"
:min="0.1"
data-tip="Cizgi kalinligi (mm)"
@update:model-value="(v) => updateStyle('strokeWidth', v)"
/>
<PropColorInput
label="Renk"
:model-value="element.style.strokeColor ?? '#000000'"
data-tip="Cizgi rengi"
@update:model-value="(v) => updateStyle('strokeColor', v)"
/>
</PropSection>
</template>

View File

@@ -1,73 +1,43 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { PageNumberElement, TextStyle, TemplateElement } from '../../core/types'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { PageNumberElement, TextStyle } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: PageNumberElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const style = () => props.element.style as TextStyle
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const formatOptions = [
{ value: '{current} / {total}', label: '1 / 5' },
{ value: '{current}', label: '1' },
{ value: 'Sayfa {current}', label: 'Sayfa 1' },
{ value: 'Sayfa {current} / {total}', label: 'Sayfa 1 / 5' },
]
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Sayfa Numarasi</div>
<div class="prop-row" data-tip="Sayfa numarasi gosterim formati">
<label class="prop-label">Format</label>
<select
class="prop-input prop-select"
:value="element.format ?? '{current} / {total}'"
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)"
>
<option value="{current} / {total}">1 / 5</option>
<option value="{current}">1</option>
<option value="Sayfa {current}">Sayfa 1</option>
<option value="Sayfa {current} / {total}">Sayfa 1 / 5</option>
</select>
</div>
<div class="prop-row" data-tip="Yazi tipi boyutu (point)">
<label class="prop-label">Boyut (pt)</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.style as TextStyle).fontSize ?? 10"
@input="
(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)
"
/>
</div>
<div class="prop-row" data-tip="Metin rengi">
<label class="prop-label">Renk</label>
<input
class="prop-input prop-color"
type="color"
:value="(element.style as TextStyle).color ?? '#666666'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)"
/>
</div>
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
<label class="prop-label">Hizalama</label>
<select
class="prop-input prop-select"
:value="(element.style as TextStyle).align ?? 'center'"
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)"
>
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
<PropSection title="Sayfa Numarasi">
<PropSelect
label="Format"
:model-value="element.format ?? '{current} / {total}'"
:options="formatOptions"
data-tip="Sayfa numarasi gosterim formati"
@update:model-value="(v) => update({ format: v } as any)"
/>
<PropTextStyleGroup
:font-size="style().fontSize ?? 10"
:font-weight="style().fontWeight ?? 'normal'"
:font-family="style().fontFamily"
:color="style().color ?? '#666666'"
:align="style().align ?? 'center'"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>
</PropSection>
</template>

View File

@@ -1,13 +1,21 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import type { TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: TemplateElement }>()
const templateStore = useTemplateStore()
function togglePositioning() {
if (props.element.position.type === 'flow') {
const positionOptions = [
{ value: 'flow', label: 'Flow' },
{ value: 'absolute', label: 'Absolute' },
]
function togglePositioning(value: string) {
if (value === 'absolute') {
templateStore.updateElementPosition(props.element.id, { type: 'absolute', x: 0, y: 0 })
} else {
templateStore.updateElementPosition(props.element.id, { type: 'flow' })
@@ -16,54 +24,43 @@ function togglePositioning() {
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Pozisyon</div>
<div class="prop-row" data-tip="Flow: otomatik dizilim, Absolute: sabit konum">
<label class="prop-label">Mod</label>
<select
class="prop-input prop-select"
:value="element.position.type"
@change="togglePositioning"
>
<option value="flow">Flow</option>
<option value="absolute">Absolute</option>
</select>
</div>
<PropSection title="Pozisyon">
<PropSelect
label="Mod"
:model-value="element.position.type"
:options="positionOptions"
data-tip="Flow: otomatik dizilim, Absolute: sabit konum"
@update:model-value="togglePositioning"
/>
<template v-if="element.position.type === 'absolute'">
<div class="prop-row" data-tip="Yatay pozisyon parent sol kenardan uzaklik (mm)">
<label class="prop-label">X (mm)</label>
<input
class="prop-input"
type="number"
step="0.5"
:value="element.position.x"
@input="
(e) =>
templateStore.updateElementPosition(element.id, {
type: 'absolute',
x: parseFloat((e.target as HTMLInputElement).value) || 0,
y: (element.position as any).y ?? 0,
})
"
/>
</div>
<div class="prop-row" data-tip="Dikey pozisyon parent ust kenardan uzaklik (mm)">
<label class="prop-label">Y (mm)</label>
<input
class="prop-input"
type="number"
step="0.5"
:value="element.position.y"
@input="
(e) =>
templateStore.updateElementPosition(element.id, {
type: 'absolute',
x: (element.position as any).x ?? 0,
y: parseFloat((e.target as HTMLInputElement).value) || 0,
})
"
/>
</div>
<PropNumberInput
label="X (mm)"
:model-value="(element.position as any).x ?? 0"
:step="0.5"
data-tip="Yatay pozisyon parent sol kenardan uzaklik (mm)"
@update:model-value="
(v) =>
templateStore.updateElementPosition(element.id, {
type: 'absolute',
x: v,
y: (element.position as any).y ?? 0,
})
"
/>
<PropNumberInput
label="Y (mm)"
:model-value="(element.position as any).y ?? 0"
:step="0.5"
data-tip="Dikey pozisyon parent ust kenardan uzaklik (mm)"
@update:model-value="
(v) =>
templateStore.updateElementPosition(element.id, {
type: 'absolute',
x: (element.position as any).x ?? 0,
y: v,
})
"
/>
</template>
</div>
</PropSection>
</template>

View File

@@ -1,29 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import { sz } from '../../core/types'
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
import type {
RepeatingTableElement,
TableColumn,
FormatType,
TemplateElement,
} from '../../core/types'
import PropSection from './shared/PropSection.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import TableColumnEditor from './table/TableColumnEditor.vue'
import TableStyleEditor from './table/TableStyleEditor.vue'
import type { RepeatingTableElement, TableColumn, TableStyle } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: RepeatingTableElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
let colIdCounter = Date.now()
function nextColId() {
return `col_${(++colIdCounter).toString(36)}`
@@ -40,24 +31,21 @@ function updateTableDataSource(path: string) {
align: defaultAlignForSchema(field),
format: schemaFormatToFormatType(field.format),
}))
update({
dataSource: { type: 'array', path },
columns,
} as Partial<TemplateElement>)
update({ dataSource: { type: 'array', path }, columns } as any)
} else {
update({ dataSource: { type: 'array', path } } as Partial<TemplateElement>)
update({ dataSource: { type: 'array', path } } as any)
}
}
function updateTableStyle(key: string, value: unknown) {
const newStyle = { ...props.element.style, [key]: value }
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
update({ style: newStyle } as Partial<TemplateElement>)
update({ style: newStyle } as any)
}
function updateColumn(colId: string, updates: Partial<TableColumn>) {
const columns = props.element.columns.map((c) => (c.id === colId ? { ...c, ...updates } : c))
update({ columns } as Partial<TemplateElement>)
update({ columns } as any)
}
function addColumn() {
@@ -68,13 +56,11 @@ function addColumn() {
width: sz.auto(),
align: 'left',
}
update({ columns: [...props.element.columns, newCol] } as Partial<TemplateElement>)
update({ columns: [...props.element.columns, newCol] } as any)
}
function removeColumn(colId: string) {
update({
columns: props.element.columns.filter((c) => c.id !== colId),
} as Partial<TemplateElement>)
update({ columns: props.element.columns.filter((c) => c.id !== colId) } as any)
}
function moveColumn(colId: string, direction: -1 | 1) {
@@ -83,7 +69,7 @@ function moveColumn(colId: string, direction: -1 | 1) {
const newIdx = idx + direction
if (newIdx < 0 || newIdx >= cols.length) return
;[cols[idx], cols[newIdx]] = [cols[newIdx], cols[idx]]
update({ columns: cols } as Partial<TemplateElement>)
update({ columns: cols } as any)
}
const tableItemFields = computed(() => {
@@ -93,864 +79,39 @@ const tableItemFields = computed(() => {
<template>
<!-- Data source -->
<div class="prop-section">
<div class="prop-section__title">Veri Kaynagi</div>
<div class="prop-row" data-tip="Tablonun baglanacagi array veri kaynagi">
<label class="prop-label">Kaynak</label>
<select
class="prop-input prop-select"
:value="element.dataSource.path"
@change="(e) => updateTableDataSource((e.target as HTMLSelectElement).value)"
>
<option value="" disabled>Secin...</option>
<option v-for="arr in schemaStore.arrayFields" :key="arr.path" :value="arr.path">
{{ arr.title }} ({{ arr.path }})
</option>
</select>
</div>
</div>
<PropSection title="Veri Kaynagi">
<PropFieldSelect
label="Kaynak"
:model-value="element.dataSource.path"
:fields="schemaStore.arrayFields"
data-tip="Tablonun baglanacagi array veri kaynagi"
@update:model-value="updateTableDataSource"
/>
</PropSection>
<!-- Columns -->
<div class="prop-section">
<div class="prop-section__title">
Sutunlar
<PropSection title="Sutunlar">
<template #actions>
<button class="prop-add-btn" @click="addColumn">+</button>
</div>
<div v-for="col in element.columns" :key="col.id" class="tbl-col">
<!-- Row 1: title + actions -->
<div class="tbl-col__head">
<input
class="tbl-col__title"
type="text"
:value="col.title"
@change="(e) => updateColumn(col.id, { title: (e.target as HTMLInputElement).value })"
:placeholder="col.field"
data-tip="Sutun basligi"
/>
<div class="tbl-col__actions">
<button class="tbl-col__act" @click="moveColumn(col.id, -1)" data-tip="Yukari tasi">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M5 2L2 6h6L5 2z" fill="currentColor" />
</svg>
</button>
<button class="tbl-col__act" @click="moveColumn(col.id, 1)" data-tip="Asagi tasi">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M5 8L2 4h6L5 8z" fill="currentColor" />
</svg>
</button>
<button
class="tbl-col__act tbl-col__act--del"
@click="removeColumn(col.id)"
data-tip="Sutunu sil"
>
<svg width="10" height="10" viewBox="0 0 10 10">
<path
d="M2 2l6 6M8 2l-6 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<!-- Row 2: field + align + format + width compact -->
<div class="tbl-col__controls">
<!-- Field -->
<select
v-if="tableItemFields.length > 0"
class="tbl-col__field"
:value="col.field"
data-tip="Veri alani"
@change="
(e) => {
const field = (e.target as HTMLSelectElement).value
const node = tableItemFields.find((f) => f.key === field)
if (node) {
updateColumn(col.id, {
field,
title: node.title,
align: defaultAlignForSchema(node),
format: schemaFormatToFormatType(node.format),
})
} else {
updateColumn(col.id, { field })
}
}
"
>
<option v-for="f in tableItemFields" :key="f.key" :value="f.key">{{ f.key }}</option>
</select>
<input
v-else
class="tbl-col__field"
type="text"
:value="col.field"
@change="(e) => updateColumn(col.id, { field: (e.target as HTMLInputElement).value })"
data-tip="Veri alani"
/>
<!-- Alignment icons -->
<div class="tbl-col__align">
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': col.align === 'left' }"
@click="updateColumn(col.id, { align: 'left' })"
data-tip="Sola hizala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line
x1="1"
y1="3"
x2="11"
y2="3"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="1"
y1="6"
x2="8"
y2="6"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="1"
y1="9"
x2="10"
y2="9"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
</svg>
</button>
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': col.align === 'center' }"
@click="updateColumn(col.id, { align: 'center' })"
data-tip="Ortala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line
x1="1"
y1="3"
x2="11"
y2="3"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="2.5"
y1="6"
x2="9.5"
y2="6"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="1.5"
y1="9"
x2="10.5"
y2="9"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
</svg>
</button>
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': col.align === 'right' }"
@click="updateColumn(col.id, { align: 'right' })"
data-tip="Saga hizala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line
x1="1"
y1="3"
x2="11"
y2="3"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="4"
y1="6"
x2="11"
y2="6"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<line
x1="2"
y1="9"
x2="11"
y2="9"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<!-- Row 3: format + width -->
<div class="tbl-col__extra" data-tip="Veri gosterim formati">
<label class="tbl-col__elabel">Format</label>
<select
class="tbl-col__fmt"
:value="col.format ?? ''"
@change="
(e) =>
updateColumn(col.id, {
format: ((e.target as HTMLSelectElement).value || undefined) as
| FormatType
| undefined,
})
"
>
<option value="">Yok</option>
<option value="currency">Para birimi</option>
<option value="number">Sayi</option>
<option value="date">Tarih</option>
<option value="percentage">Yuzde</option>
</select>
</div>
<div class="tbl-col__extra" data-tip="Sutun genislik modu">
<label class="tbl-col__elabel">Genislik</label>
<select
class="tbl-col__wtype"
:value="col.width.type"
@change="
(e) => {
const t = (e.target as HTMLSelectElement).value
if (t === 'auto') updateColumn(col.id, { width: { type: 'auto' } })
else if (t === 'fr') updateColumn(col.id, { width: { type: 'fr', value: 1 } })
else updateColumn(col.id, { width: { type: 'fixed', value: 30 } })
}
"
>
<option value="auto">Otomatik</option>
<option value="fixed">Sabit (mm)</option>
<option value="fr">Oran (fr)</option>
</select>
<span
v-if="col.width.type === 'fixed' || col.width.type === 'fr'"
class="ts-tip-wrap"
:data-tip="col.width.type === 'fixed' ? 'Sabit genislik (mm)' : 'Oran degeri (fr)'"
>
<input
class="tbl-col__wval"
type="number"
step="1"
:min="col.width.type === 'fixed' ? 5 : 1"
:value="(col.width as any).value"
@change="
(e) =>
updateColumn(col.id, {
width: {
type: col.width.type,
value:
parseFloat((e.target as HTMLInputElement).value) ||
(col.width.type === 'fixed' ? 30 : 1),
} as any,
})
"
/>
</span>
</div>
</div>
</div>
</template>
<TableColumnEditor
v-for="col in element.columns"
:key="col.id"
:column="col"
:item-fields="tableItemFields"
@update="updateColumn"
@remove="removeColumn"
@move="moveColumn"
/>
</PropSection>
<!-- Table style -->
<div class="prop-section">
<div class="prop-section__title">Tablo Stili</div>
<div class="ts-form">
<!-- Font sizes -->
<label class="ts-lbl" data-tip="Icerik ve header yazi boyutu (pt)">Yazi boyutu</label>
<div class="ts-val ts-val--pair">
<span class="ts-sep">Icerik</span>
<span class="ts-tip-wrap" data-tip="Icerik yazi boyutu (pt)">
<input
class="ts-num"
type="number"
step="1"
min="6"
max="99"
:value="element.style.fontSize ?? 10"
@input="
(e) =>
updateTableStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)
"
/>
</span>
<span class="ts-sep">Header</span>
<span class="ts-tip-wrap" data-tip="Header yazi boyutu (pt)">
<input
class="ts-num"
type="number"
step="1"
min="6"
max="99"
:value="element.style.headerFontSize ?? element.style.fontSize ?? 10"
@input="
(e) =>
updateTableStyle(
'headerFontSize',
parseFloat((e.target as HTMLInputElement).value) || 10,
)
"
/>
</span>
</div>
<!-- Colors -->
<label class="ts-lbl" data-tip="Header, metin ve zebra satirlari renkleri">Renkler</label>
<div class="ts-val ts-val--colors">
<div class="ts-color-item" data-tip="Header arkaplan rengi">
<input
class="ts-swatch"
type="color"
:value="element.style.headerBg ?? '#f0f0f0'"
@input="(e) => updateTableStyle('headerBg', (e.target as HTMLInputElement).value)"
/>
<span class="ts-clbl">Arkaplan</span>
</div>
<div class="ts-color-item" data-tip="Header metin rengi">
<input
class="ts-swatch"
type="color"
:value="element.style.headerColor ?? '#000000'"
@input="(e) => updateTableStyle('headerColor', (e.target as HTMLInputElement).value)"
/>
<span class="ts-clbl">Metin</span>
</div>
<div class="ts-color-item" data-tip="Zebra satir rengi tek satirlar">
<div class="ts-swatch-wrap">
<input
class="ts-swatch"
type="color"
:value="element.style.zebraOdd ?? '#fafafa'"
@input="(e) => updateTableStyle('zebraOdd', (e.target as HTMLInputElement).value)"
/>
<button
v-if="element.style.zebraOdd"
class="ts-swatch-clr"
@click="updateTableStyle('zebraOdd', undefined)"
>
&times;
</button>
</div>
<span class="ts-clbl">Zebra</span>
</div>
</div>
<!-- Border -->
<label class="ts-lbl" data-tip="Tablo kenarlik rengi ve kalinligi">Kenarlik</label>
<div class="ts-val ts-val--pair">
<div class="ts-swatch-wrap" data-tip="Kenarlik rengi">
<input
class="ts-swatch"
type="color"
:value="element.style.borderColor ?? '#cccccc'"
@input="(e) => updateTableStyle('borderColor', (e.target as HTMLInputElement).value)"
/>
<button
v-if="element.style.borderColor"
class="ts-swatch-clr"
@click="updateTableStyle('borderColor', undefined)"
>
&times;
</button>
</div>
<span class="ts-tip-wrap" data-tip="Kenarlik kalinligi (mm)">
<input
class="ts-num"
type="number"
step="0.1"
min="0"
max="99"
:value="element.style.borderWidth ?? 0.5"
@input="
(e) =>
updateTableStyle(
'borderWidth',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
<span class="ts-unit">mm</span>
</div>
<!-- Cell padding -->
<label class="ts-lbl" data-tip="Hucre ic bosluklari yatay ve dikey (mm)">Ic bosluk</label>
<div class="ts-val ts-val--pair">
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">&#8596;</span>
<span class="ts-tip-wrap" data-tip="Yatay ic bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="element.style.cellPaddingH ?? 2"
@input="
(e) =>
updateTableStyle(
'cellPaddingH',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">&#8597;</span>
<span class="ts-tip-wrap" data-tip="Dikey ic bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="element.style.cellPaddingV ?? 1"
@input="
(e) =>
updateTableStyle(
'cellPaddingV',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
</div>
<!-- Header padding -->
<label class="ts-lbl" data-tip="Header hucre bosluklari yatay ve dikey (mm)"
>Header bosluk</label
>
<div class="ts-val ts-val--pair">
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">&#8596;</span>
<span class="ts-tip-wrap" data-tip="Header yatay bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="element.style.headerPaddingH ?? element.style.cellPaddingH ?? 2"
@input="
(e) =>
updateTableStyle(
'headerPaddingH',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">&#8597;</span>
<span class="ts-tip-wrap" data-tip="Header dikey bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="element.style.headerPaddingV ?? element.style.cellPaddingV ?? 1"
@input="
(e) =>
updateTableStyle(
'headerPaddingV',
parseFloat((e.target as HTMLInputElement).value) || 0,
)
"
/>
</span>
</div>
<!-- Repeat header -->
<label class="ts-lbl" data-tip="Cok sayfali tablolarda header'i her sayfada tekrarla"
>Header tekrarla</label
>
<div class="ts-val">
<label class="ts-toggle">
<input
type="checkbox"
:checked="element.repeatHeader !== false"
@change="(e) => update({ repeatHeader: (e.target as HTMLInputElement).checked } as any)"
/>
<span class="ts-toggle__track"></span>
</label>
</div>
</div>
</div>
<PropSection title="Tablo Stili">
<TableStyleEditor
:style="element.style as TableStyle"
:repeat-header="element.repeatHeader !== false"
@update:style="updateTableStyle"
@update:repeat-header="(v) => update({ repeatHeader: v } as any)"
/>
</PropSection>
</template>
<style scoped>
/* Column card - compact */
.tbl-col {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 5px;
padding: 5px 6px;
margin-bottom: 5px;
}
.tbl-col__head {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.tbl-col__title {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 12px;
font-weight: 500;
color: #334155;
padding: 1px 0;
outline: none;
}
.tbl-col__title:focus {
border-bottom: 1px solid #93c5fd;
}
.tbl-col__actions {
display: flex;
gap: 1px;
flex-shrink: 0;
}
.tbl-col__act {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
border-radius: 3px;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.tbl-col__act:hover {
background: #e2e8f0;
color: #475569;
}
.tbl-col__act--del:hover {
background: #fef2f2;
color: #dc2626;
}
.tbl-col__controls {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.tbl-col__field {
flex: 1;
min-width: 0;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
}
.tbl-col__field:focus {
outline: none;
border-color: #93c5fd;
}
.tbl-col__align {
display: flex;
gap: 0;
flex-shrink: 0;
}
.tbl-col__align-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid #e2e8f0;
background: white;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.tbl-col__align-btn:first-child {
border-radius: 3px 0 0 3px;
}
.tbl-col__align-btn:last-child {
border-radius: 0 3px 3px 0;
}
.tbl-col__align-btn:not(:first-child) {
border-left: none;
}
.tbl-col__align-btn--on {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.tbl-col__extra {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.tbl-col__elabel {
font-size: 11px;
color: #64748b;
flex-shrink: 0;
}
.tbl-col__fmt {
flex: 1;
min-width: 0;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
cursor: pointer;
}
.tbl-col__wtype {
width: 80px;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
cursor: pointer;
}
.tbl-col__wval {
width: 36px;
padding: 2px 3px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
text-align: center;
-moz-appearance: textfield;
}
.tbl-col__wval::-webkit-inner-spin-button,
.tbl-col__wval::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.tbl-col__wval:focus {
outline: none;
border-color: #93c5fd;
}
/* Table style — aligned 2-column form */
.ts-form {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 8px;
align-items: center;
}
.ts-lbl {
font-size: 11px;
color: #64748b;
white-space: nowrap;
}
.ts-val {
display: flex;
align-items: center;
justify-content: flex-end;
}
.ts-val--pair {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.ts-val--colors {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 6px;
}
.ts-sep {
font-size: 10px;
color: #94a3b8;
}
.ts-num {
width: 32px;
padding: 2px 3px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
text-align: center;
-moz-appearance: textfield;
}
.ts-num::-webkit-inner-spin-button,
.ts-num::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.ts-num:focus {
outline: none;
border-color: #93c5fd;
}
.ts-unit {
font-size: 10px;
color: #94a3b8;
}
/* Color swatches */
.ts-color-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.ts-clbl {
font-size: 9px;
color: #94a3b8;
white-space: nowrap;
}
.ts-swatch {
width: 22px;
height: 22px;
padding: 0;
cursor: pointer;
border: 1px solid #e2e8f0;
border-radius: 3px;
}
.ts-swatch-wrap {
position: relative;
display: inline-flex;
}
.ts-swatch-clr {
position: absolute;
top: -4px;
right: -4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 9px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #94a3b8;
padding: 0;
}
.ts-swatch-clr:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
.ts-pad-icon {
font-size: 11px;
color: #94a3b8;
line-height: 1;
}
.ts-tip-wrap {
position: relative;
display: inline-flex;
}
/* Toggle switch */
.ts-toggle {
position: relative;
display: inline-block;
cursor: pointer;
}
.ts-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.ts-toggle__track {
display: block;
width: 28px;
height: 16px;
background: #e2e8f0;
border-radius: 8px;
transition: background 0.15s;
position: relative;
}
.ts-toggle__track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
transition: transform 0.15s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.ts-toggle input:checked + .ts-toggle__track {
background: #3b82f6;
}
.ts-toggle input:checked + .ts-toggle__track::after {
transform: translateX(12px);
}
</style>

View File

@@ -1,27 +1,22 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import PropSection from './shared/PropSection.vue'
import PropColorInput from './shared/PropColorInput.vue'
import PropSelect from './shared/PropSelect.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { RichTextElement, RichTextSpan, TextStyle } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: RichTextElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<RichTextElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates as any)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<RichTextElement>)
}
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
function updateSpan(index: number, updates: Partial<RichTextSpan>) {
const content = [...props.element.content]
content[index] = { ...content[index], ...updates }
update({ content })
update({ content } as any)
}
function updateSpanStyle(index: number, key: string, value: unknown) {
@@ -31,60 +26,42 @@ function updateSpanStyle(index: number, key: string, value: unknown) {
function addSpan() {
const content = [...props.element.content, { text: 'yeni', style: {} }]
update({ content })
update({ content } as any)
}
function removeSpan(index: number) {
if (props.element.content.length <= 1) return
const content = props.element.content.filter((_, i) => i !== index)
update({ content })
update({ content } as any)
}
const weightOptions = [
{ value: '', label: 'Varsayilan' },
{ value: 'normal', label: 'Normal' },
{ value: 'bold', label: 'Kalin' },
]
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Varsayilan Stil</div>
<div class="prop-row" data-tip="Varsayilan yazi tipi boyutu (point)">
<label class="prop-label">Boyut (pt)</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="element.style.fontSize ?? 11"
@input="
(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)
"
/>
</div>
<div class="prop-row" data-tip="Varsayilan metin rengi">
<label class="prop-label">Renk</label>
<input
class="prop-input prop-color"
type="color"
:value="element.style.color ?? '#000000'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)"
/>
</div>
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
<label class="prop-label">Hizalama</label>
<select
class="prop-input prop-select"
:value="element.style.align ?? 'left'"
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)"
>
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
<PropSection title="Varsayilan Stil">
<PropTextStyleGroup
:font-size="element.style.fontSize ?? 11"
:font-weight="element.style.fontWeight ?? 'normal'"
:font-family="element.style.fontFamily"
:color="element.style.color ?? '#000000'"
:align="element.style.align ?? 'left'"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>
</PropSection>
<div class="prop-section">
<div class="prop-section__title">
Span'lar
<PropSection title="Span'lar">
<template #actions>
<button class="prop-add-btn" @click="addSpan" title="Span ekle">+</button>
</div>
</template>
<div v-for="(span, idx) in element.content" :key="idx" class="prop-span-card">
<div class="prop-span-card__header">
@@ -108,6 +85,15 @@ function removeSpan(index: number) {
@input="(e) => updateSpan(idx, { text: (e.target as HTMLInputElement).value })"
/>
</div>
<PropFieldSelect
label="Binding"
:model-value="span.binding?.path ?? ''"
:fields="schemaStore.scalarFields"
:allow-empty="true"
empty-label="Yok (statik)"
data-tip="Span'in baglanacagi veri alani"
@update:model-value="(v) => updateSpan(idx, { binding: v ? { type: 'scalar', path: v } : undefined })"
/>
<div class="prop-row" data-tip="Span yazi boyutu bos birakilirsa varsayilan kullanilir">
<label class="prop-label">Boyut</label>
<input
@@ -125,57 +111,31 @@ function removeSpan(index: number) {
"
/>
</div>
<div class="prop-row" data-tip="Span yazi kalinligi">
<label class="prop-label">Kalinlik</label>
<select
class="prop-input prop-select"
:value="(span.style as TextStyle).fontWeight ?? ''"
@change="
(e) => {
const v = (e.target as HTMLSelectElement).value
updateSpanStyle(idx, 'fontWeight', v || undefined)
}
"
>
<option value="">Varsayilan</option>
<option value="normal">Normal</option>
<option value="bold">Kalin</option>
</select>
</div>
<div class="prop-row" data-tip="Span metin rengi">
<label class="prop-label">Renk</label>
<input
class="prop-input prop-color"
type="color"
:value="(span.style as TextStyle).color ?? element.style.color ?? '#000000'"
@input="(e) => updateSpanStyle(idx, 'color', (e.target as HTMLInputElement).value)"
/>
</div>
<PropSelect
label="Kalinlik"
:model-value="(span.style as TextStyle).fontWeight ?? ''"
:options="weightOptions"
data-tip="Span yazi kalinligi"
@update:model-value="(v) => updateSpanStyle(idx, 'fontWeight', v || undefined)"
/>
<PropColorInput
label="Renk"
:model-value="(span.style as TextStyle).color ?? element.style.color ?? '#000000'"
data-tip="Span metin rengi"
@update:model-value="(v) => updateSpanStyle(idx, 'color', v)"
/>
<PropSelect
label="Hizalama"
:model-value="(span.style as TextStyle).align ?? ''"
:options="[{ value: '', label: 'Varsayilan' }, { value: 'left', label: 'Sol' }, { value: 'center', label: 'Orta' }, { value: 'right', label: 'Sag' }]"
data-tip="Span hizalamasi"
@update:model-value="(v) => updateSpanStyle(idx, 'align', v || undefined)"
/>
</div>
</div>
</PropSection>
</template>
<style scoped>
.prop-add-btn {
float: right;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
width: 22px;
height: 22px;
font-size: 14px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.prop-add-btn:hover {
background: #2563eb;
}
.prop-span-card {
background: #f8fafc;
border: 1px solid #e2e8f0;

View File

@@ -1,86 +1,72 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { ShapeElement, TemplateElement } from '../../core/types'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import PropSection from './shared/PropSection.vue'
import PropSelect from './shared/PropSelect.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import PropColorInput from './shared/PropColorInput.vue'
import type { ShapeElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: ShapeElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
const shapeOptions = [
{ value: 'rectangle', label: 'Dikdortgen' },
{ value: 'rounded_rectangle', label: 'Yuvarlak Dikdortgen' },
{ value: 'ellipse', label: 'Elips' },
]
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const borderStyleOptions = [
{ value: 'solid', label: 'Duz' },
{ value: 'dashed', label: 'Kesikli' },
{ value: 'dotted', label: 'Noktali' },
]
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Sekil</div>
<div class="prop-row" data-tip="Sekil tipi">
<label class="prop-label">Tip</label>
<select
class="prop-input prop-select"
:value="element.shapeType"
@change="(e) => update({ shapeType: (e.target as HTMLSelectElement).value } as any)"
>
<option value="rectangle">Dikdortgen</option>
<option value="rounded_rectangle">Yuvarlak Dikdortgen</option>
<option value="ellipse">Elips</option>
</select>
</div>
<div class="prop-row" data-tip="Sekil arka plan rengi">
<label class="prop-label">Arka Plan</label>
<input
class="prop-input prop-color"
type="color"
:value="element.style.backgroundColor ?? '#f0f0f0'"
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)"
/>
</div>
<div class="prop-row" data-tip="Kenarlik cizgisi rengi">
<label class="prop-label">Kenar Rengi</label>
<input
class="prop-input prop-color"
type="color"
:value="element.style.borderColor ?? '#333333'"
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)"
/>
</div>
<div class="prop-row" data-tip="Kenarlik cizgi kalinligi (mm)">
<label class="prop-label">Kenar Kalinligi</label>
<input
class="prop-input"
type="number"
step="0.25"
min="0"
:value="element.style.borderWidth ?? 0.5"
@input="
(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)
"
/>
</div>
<div
<PropSection title="Sekil">
<PropSelect
label="Tip"
:model-value="element.shapeType"
:options="shapeOptions"
data-tip="Sekil tipi"
@update:model-value="(v) => update({ shapeType: v } as any)"
/>
<PropColorInput
label="Arka Plan"
:model-value="element.style.backgroundColor ?? '#f0f0f0'"
data-tip="Sekil arka plan rengi"
@update:model-value="(v) => updateStyle('backgroundColor', v)"
/>
<PropColorInput
label="Kenar Rengi"
:model-value="element.style.borderColor ?? '#333333'"
data-tip="Kenarlik cizgisi rengi"
@update:model-value="(v) => updateStyle('borderColor', v)"
/>
<PropNumberInput
label="Kenar Kalinligi"
:model-value="element.style.borderWidth ?? 0.5"
:step="0.25"
:min="0"
data-tip="Kenarlik cizgi kalinligi (mm)"
@update:model-value="(v) => updateStyle('borderWidth', v)"
/>
<PropSelect
label="Kenar Stili"
:model-value="element.style.borderStyle ?? 'solid'"
:options="borderStyleOptions"
data-tip="Kenarlik cizgi stili"
@update:model-value="(v) => updateStyle('borderStyle', v)"
/>
<PropNumberInput
v-if="element.shapeType === 'rounded_rectangle'"
class="prop-row"
label="Kose Yuvarlakligi"
:model-value="element.style.borderRadius ?? 2"
:step="0.5"
:min="0"
data-tip="Kose yuvarlakligi (mm)"
>
<label class="prop-label">Kose Yuvarlakligi</label>
<input
class="prop-input"
type="number"
step="0.5"
min="0"
:value="element.style.borderRadius ?? 2"
@input="
(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)
"
/>
</div>
</div>
@update:model-value="(v) => updateStyle('borderRadius', v)"
/>
</PropSection>
</template>

View File

@@ -1,120 +1,131 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import PropSection from './shared/PropSection.vue'
import PropNumberInput from './shared/PropNumberInput.vue'
import type { TemplateElement, SizeValue } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: TemplateElement }>()
const templateStore = useTemplateStore()
const sizeOptions = [
{ value: 'auto', label: 'Otomatik' },
{ value: 'fixed', label: 'Sabit (mm)' },
{ value: 'fr', label: 'Oran (fr)' },
]
function updateSize(axis: 'width' | 'height', sv: SizeValue) {
templateStore.updateElementSize(props.element.id, { [axis]: sv })
}
function updateSizeConstraint(key: string, value: number | undefined) {
templateStore.updateElementSize(props.element.id, { [key]: value })
}
function onTypeChange(axis: 'width' | 'height', type: string) {
if (type === 'auto') updateSize(axis, { type: 'auto' })
else if (type === 'fr') updateSize(axis, { type: 'fr', value: 1 })
else updateSize(axis, { type: 'fixed', value: axis === 'width' ? 50 : 20 })
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Boyut</div>
<PropSection title="Boyut">
<div class="prop-row" data-tip="Genislik boyutlandirma modu">
<label class="prop-label">Genislik</label>
<select
class="prop-input prop-select"
:value="element.size.width.type"
@change="
(e) => {
const t = (e.target as HTMLSelectElement).value
if (t === 'auto') updateSize('width', { type: 'auto' })
else if (t === 'fr') updateSize('width', { type: 'fr', value: 1 })
else updateSize('width', { type: 'fixed', value: 50 })
}
"
@change="(e) => onTypeChange('width', (e.target as HTMLSelectElement).value)"
>
<option value="auto">Otomatik</option>
<option value="fixed">Sabit (mm)</option>
<option value="fr">Oran (fr)</option>
<option v-for="opt in sizeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div
<PropNumberInput
v-if="element.size.width.type === 'fixed'"
class="prop-row"
label="mm"
:model-value="(element.size.width as any).value"
:step="1"
:min="1"
data-tip="Sabit genislik degeri (mm)"
>
<label class="prop-label">mm</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.size.width as any).value"
@input="
(e) =>
updateSize('width', {
type: 'fixed',
value: parseFloat((e.target as HTMLInputElement).value) || 10,
})
"
/>
</div>
<div
@update:model-value="(v) => updateSize('width', { type: 'fixed', value: v })"
/>
<PropNumberInput
v-if="element.size.width.type === 'fr'"
class="prop-row"
label="fr"
:model-value="(element.size.width as any).value"
:step="1"
:min="1"
data-tip="Kalan alani oransal doldurma degeri"
>
<label class="prop-label">fr</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.size.width as any).value"
@input="
(e) =>
updateSize('width', {
type: 'fr',
value: parseFloat((e.target as HTMLInputElement).value) || 1,
})
"
/>
</div>
@update:model-value="(v) => updateSize('width', { type: 'fr', value: v })"
/>
<div class="prop-row" data-tip="Yukseklik boyutlandirma modu">
<label class="prop-label">Yukseklik</label>
<select
class="prop-input prop-select"
:value="element.size.height.type"
@change="
(e) => {
const t = (e.target as HTMLSelectElement).value
if (t === 'auto') updateSize('height', { type: 'auto' })
else if (t === 'fr') updateSize('height', { type: 'fr', value: 1 })
else updateSize('height', { type: 'fixed', value: 20 })
}
"
@change="(e) => onTypeChange('height', (e.target as HTMLSelectElement).value)"
>
<option value="auto">Otomatik</option>
<option value="fixed">Sabit (mm)</option>
<option value="fr">Oran (fr)</option>
<option v-for="opt in sizeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div
<PropNumberInput
v-if="element.size.height.type === 'fixed'"
class="prop-row"
label="mm"
:model-value="(element.size.height as any).value"
:step="1"
:min="1"
data-tip="Sabit yukseklik degeri (mm)"
>
<label class="prop-label">mm</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.size.height as any).value"
@input="
(e) =>
updateSize('height', {
type: 'fixed',
value: parseFloat((e.target as HTMLInputElement).value) || 10,
})
"
/>
</div>
</div>
@update:model-value="(v) => updateSize('height', { type: 'fixed', value: v })"
/>
<PropNumberInput
v-if="element.size.height.type === 'fr'"
label="fr"
:model-value="(element.size.height as any).value"
:step="1"
:min="1"
data-tip="Kalan alani oransal doldurma degeri"
@update:model-value="(v) => updateSize('height', { type: 'fr', value: v })"
/>
</PropSection>
<PropSection title="Min / Max">
<PropNumberInput
label="Min Gen."
:model-value="element.size.minWidth ?? 0"
:step="1"
:min="0"
data-tip="Minimum genislik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('minWidth', v > 0 ? v : undefined)"
/>
<PropNumberInput
label="Max Gen."
:model-value="element.size.maxWidth ?? 0"
:step="1"
:min="0"
data-tip="Maksimum genislik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('maxWidth', v > 0 ? v : undefined)"
/>
<PropNumberInput
label="Min Yuk."
:model-value="element.size.minHeight ?? 0"
:step="1"
:min="0"
data-tip="Minimum yukseklik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('minHeight', v > 0 ? v : undefined)"
/>
<PropNumberInput
label="Max Yuk."
:model-value="element.size.maxHeight ?? 0"
:step="1"
:min="0"
data-tip="Maksimum yukseklik (mm) 0 = sinir yok"
@update:model-value="(v) => updateSizeConstraint('maxHeight', v > 0 ? v : undefined)"
/>
</PropSection>
</template>

View File

@@ -1,28 +1,23 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { StaticTextElement, TextStyle, TemplateElement } from '../../core/types'
import { computed } from 'vue'
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
import { useSchemaStore } from '../../stores/schema'
import PropSection from './shared/PropSection.vue'
import PropFieldSelect from './shared/PropFieldSelect.vue'
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
import type { StaticTextElement, TextElement, TextStyle, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: TemplateElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { update, updateStyle } = usePropertyUpdate(() => props.element)
const schemaStore = useSchemaStore()
const style = () => props.element.style as TextStyle
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
const isText = computed(() => props.element.type === 'text')
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Metin Stili</div>
<PropSection title="Metin">
<div v-if="element.type === 'static_text'" class="prop-row" data-tip="Sabit metin icerigi">
<label class="prop-label">Metin</label>
<input
@@ -33,50 +28,39 @@ function updateStyle(key: string, value: unknown) {
/>
</div>
<div class="prop-row" data-tip="Yazi tipi boyutu (point)">
<label class="prop-label">Boyut (pt)</label>
<input
class="prop-input"
type="number"
step="1"
min="1"
:value="(element.style as TextStyle).fontSize ?? 11"
@input="
(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)
"
<template v-if="isText">
<PropFieldSelect
label="Veri Alani"
:model-value="(element as TextElement).binding?.path ?? ''"
:fields="schemaStore.scalarFields"
data-tip="Metnin baglanacagi veri alani"
@update:model-value="(v) => update({ binding: { type: 'scalar', path: v } } as any)"
/>
</div>
<div class="prop-row" data-tip="Yazi tipi kalinligi">
<label class="prop-label">Kalinlik</label>
<select
class="prop-input prop-select"
:value="(element.style as TextStyle).fontWeight ?? 'normal'"
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)"
>
<option value="normal">Normal</option>
<option value="bold">Kalin</option>
</select>
</div>
<div class="prop-row" data-tip="Metin rengi">
<label class="prop-label">Renk</label>
<input
class="prop-input prop-color"
type="color"
:value="(element.style as TextStyle).color ?? '#000000'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)"
/>
</div>
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
<label class="prop-label">Hizalama</label>
<select
class="prop-input prop-select"
:value="(element.style as TextStyle).align ?? 'left'"
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)"
>
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
<div class="prop-row" data-tip="Veri alaninin onune eklenecek sabit metin">
<label class="prop-label">Ön Ek</label>
<input
class="prop-input"
type="text"
:value="(element as TextElement).content ?? ''"
placeholder="ör: Fatura No: "
@input="(e) => update({ content: (e.target as HTMLInputElement).value || undefined } as any)"
/>
</div>
</template>
</PropSection>
<PropSection title="Metin Stili">
<PropTextStyleGroup
:font-size="style().fontSize ?? 11"
:font-weight="style().fontWeight ?? 'normal'"
:font-family="style().fontFamily"
:color="style().color ?? '#000000'"
:align="style().align ?? 'left'"
@update:font-size="(v) => updateStyle('fontSize', v)"
@update:font-weight="(v) => updateStyle('fontWeight', v)"
@update:font-family="(v) => updateStyle('fontFamily', v)"
@update:color="(v) => updateStyle('color', v)"
@update:align="(v) => updateStyle('align', v)"
/>
</PropSection>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
defineProps<{
label: string
modelValue: boolean
dataTip?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<input
type="checkbox"
:checked="modelValue"
@change="(e) => emit('update:modelValue', (e.target as HTMLInputElement).checked)"
/>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
modelValue: string | undefined
defaultColor?: string
clearable?: boolean
dataTip?: string
}>(),
{ defaultColor: '#000000', clearable: false },
)
const emit = defineEmits<{ 'update:modelValue': [value: string | undefined] }>()
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<div v-if="clearable" class="prop-row-inline">
<input
class="prop-input prop-color"
type="color"
:value="modelValue ?? defaultColor"
@input="(e) => emit('update:modelValue', (e.target as HTMLInputElement).value)"
/>
<button v-if="modelValue" class="prop-clear" @click="emit('update:modelValue', undefined)">
x
</button>
</div>
<input
v-else
class="prop-input prop-color"
type="color"
:value="modelValue ?? defaultColor"
@input="(e) => emit('update:modelValue', (e.target as HTMLInputElement).value)"
/>
</div>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSchemaStore } from '../../../stores/schema'
import PropFieldSelect from './PropFieldSelect.vue'
import PropSelect from './PropSelect.vue'
import PropSection from './PropSection.vue'
import type { Condition } from '../../../core/types'
import '../../../styles/properties.css'
const props = defineProps<{
condition?: Condition
}>()
const emit = defineEmits<{
'update:condition': [value: Condition | undefined]
}>()
const schemaStore = useSchemaStore()
const enabled = computed(() => !!props.condition)
const operatorOptions = [
{ value: 'eq', label: '= Esit' },
{ value: 'neq', label: '≠ Esit Degil' },
{ value: 'gt', label: '> Buyuk' },
{ value: 'gte', label: '>= Buyuk Esit' },
{ value: 'lt', label: '< Kucuk' },
{ value: 'lte', label: '<= Kucuk Esit' },
{ value: 'truthy', label: 'Dolu (truthy)' },
{ value: 'falsy', label: 'Bos (falsy)' },
]
const needsValue = computed(() => {
const op = props.condition?.operator
return op && op !== 'truthy' && op !== 'falsy'
})
function toggle(on: boolean) {
if (on) {
emit('update:condition', { path: '', operator: 'truthy' })
} else {
emit('update:condition', undefined)
}
}
function updateField(key: keyof Condition, value: unknown) {
emit('update:condition', { ...props.condition!, [key]: value })
}
</script>
<template>
<PropSection title="Kosullu Gosterim">
<div class="prop-row" data-tip="Elemani belirli bir kosulla goster/gizle">
<label class="prop-label">Aktif</label>
<input type="checkbox" :checked="enabled" @change="toggle(($event.target as HTMLInputElement).checked)" />
</div>
<template v-if="enabled">
<PropFieldSelect
label="Alan"
:model-value="condition!.path"
:fields="schemaStore.scalarFields"
data-tip="Kosulun degerlendirilecegi veri alani"
@update:model-value="(v) => updateField('path', v)"
/>
<PropSelect
label="Operator"
:model-value="condition!.operator"
:options="operatorOptions"
data-tip="Karsilastirma operatoru"
@update:model-value="(v) => updateField('operator', v)"
/>
<div v-if="needsValue" class="prop-row" data-tip="Karsilastirilacak deger">
<label class="prop-label">Deger</label>
<input
class="prop-input"
type="text"
:value="condition!.value ?? ''"
@input="(e) => updateField('value', (e.target as HTMLInputElement).value)"
/>
</div>
</template>
</PropSection>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
modelValue: string
fields: Array<{ path?: string; key?: string; title?: string; type?: string }>
placeholder?: string
allowEmpty?: boolean
emptyLabel?: string
dataTip?: string
}>(),
{ placeholder: 'Secin...', allowEmpty: false, emptyLabel: 'Yok' },
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<select
class="prop-input prop-select"
:value="modelValue"
@change="(e) => emit('update:modelValue', (e.target as HTMLSelectElement).value)"
>
<option v-if="allowEmpty" value="">{{ emptyLabel }}</option>
<option v-else value="" disabled>{{ placeholder }}</option>
<option
v-for="field in fields"
:key="field.path ?? field.key"
:value="field.path ?? field.key"
>
{{ field.title ?? field.path ?? field.key }}
<template v-if="field.path">({{ field.path }})</template>
</option>
</select>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
modelValue: number
step?: number
min?: number
max?: number
dataTip?: string
}>(),
{ step: 1, min: 0 },
)
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
function onInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (!isNaN(val)) emit('update:modelValue', val)
}
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<input
class="prop-input"
type="number"
:step="step"
:min="min"
:max="max"
:value="modelValue"
@input="onInput"
/>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = withDefaults(defineProps<{ title: string; defaultOpen?: boolean }>(), {
defaultOpen: true,
})
const open = ref(props.defaultOpen)
</script>
<template>
<div class="prop-section">
<div class="prop-section__title prop-section__title--collapsible" @click="open = !open">
<span class="prop-section__chevron" :class="{ 'prop-section__chevron--closed': !open }"
>&#9662;</span
>
{{ title }}
<span class="prop-section__actions" @click.stop><slot name="actions" /></span>
</div>
<template v-if="open"><slot /></template>
</div>
</template>
<style scoped>
.prop-section__title--collapsible {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 4px;
}
.prop-section__chevron {
font-size: 8px;
transition: transform 0.15s;
display: inline-block;
}
.prop-section__chevron--closed {
transform: rotate(-90deg);
}
.prop-section__actions {
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
defineProps<{
label: string
modelValue: string
options: Array<{ value: string; label: string }>
dataTip?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div class="prop-row" :data-tip="dataTip">
<label class="prop-label">{{ label }}</label>
<select
class="prop-input prop-select"
:value="modelValue"
@change="(e) => emit('update:modelValue', (e.target as HTMLSelectElement).value)"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../../stores/template'
import PropNumberInput from './PropNumberInput.vue'
import PropColorInput from './PropColorInput.vue'
import PropSelect from './PropSelect.vue'
const props = withDefaults(
defineProps<{
fontSize: number
fontWeight?: string
fontFamily?: string
color: string
align: string
showWeight?: boolean
}>(),
{ fontWeight: 'normal', fontFamily: undefined, showWeight: true },
)
defineEmits<{
'update:fontSize': [value: number]
'update:fontWeight': [value: string]
'update:fontFamily': [value: string | undefined]
'update:color': [value: string]
'update:align': [value: string]
}>()
const templateStore = useTemplateStore()
const fontOptions = computed(() =>
templateStore.template.fonts.map((f) => ({ value: f, label: f })),
)
const weightOptions = [
{ value: 'normal', label: 'Normal' },
{ value: 'bold', label: 'Kalin' },
]
const alignOptions = [
{ value: 'left', label: 'Sol' },
{ value: 'center', label: 'Orta' },
{ value: 'right', label: 'Sag' },
]
</script>
<template>
<PropSelect
v-if="fontOptions.length > 1"
label="Font"
:model-value="fontFamily ?? fontOptions[0]?.value ?? ''"
:options="fontOptions"
data-tip="Yazi tipi ailesi"
@update:model-value="$emit('update:fontFamily', $event)"
/>
<PropNumberInput
label="Boyut (pt)"
:model-value="fontSize"
:step="1"
:min="1"
data-tip="Yazi tipi boyutu (point)"
@update:model-value="$emit('update:fontSize', $event)"
/>
<PropSelect
v-if="showWeight"
label="Kalinlik"
:model-value="fontWeight!"
:options="weightOptions"
data-tip="Yazi tipi kalinligi"
@update:model-value="$emit('update:fontWeight', $event)"
/>
<PropColorInput
label="Renk"
:model-value="color"
data-tip="Metin rengi"
@update:model-value="$emit('update:color', $event!)"
/>
<PropSelect
label="Hizalama"
:model-value="align"
:options="alignOptions"
data-tip="Metnin yatay hizalamasi"
@update:model-value="$emit('update:align', $event)"
/>
</template>

View File

@@ -0,0 +1,387 @@
<script setup lang="ts">
import { defaultAlignForSchema, schemaFormatToFormatType } from '../../../core/schema-parser'
import type { TableColumn, FormatType } from '../../../core/types'
type ItemField = { key: string; title: string; type?: string; format?: string }
import '../../../styles/properties.css'
defineProps<{
column: TableColumn
itemFields: ItemField[]
}>()
const emit = defineEmits<{
update: [colId: string, updates: Partial<TableColumn>]
remove: [colId: string]
move: [colId: string, direction: -1 | 1]
}>()
</script>
<template>
<div class="tbl-col">
<!-- Row 1: title + actions -->
<div class="tbl-col__head">
<input
class="tbl-col__title"
type="text"
:value="column.title"
@change="(e) => emit('update', column.id, { title: (e.target as HTMLInputElement).value })"
:placeholder="column.field"
data-tip="Sutun basligi"
/>
<div class="tbl-col__actions">
<button class="tbl-col__act" @click="emit('move', column.id, -1)" data-tip="Yukari tasi">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M5 2L2 6h6L5 2z" fill="currentColor" />
</svg>
</button>
<button class="tbl-col__act" @click="emit('move', column.id, 1)" data-tip="Asagi tasi">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M5 8L2 4h6L5 8z" fill="currentColor" />
</svg>
</button>
<button
class="tbl-col__act tbl-col__act--del"
@click="emit('remove', column.id)"
data-tip="Sutunu sil"
>
<svg width="10" height="10" viewBox="0 0 10 10">
<path
d="M2 2l6 6M8 2l-6 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<!-- Row 2: field + align -->
<div class="tbl-col__controls">
<select
v-if="itemFields.length > 0"
class="tbl-col__field"
:value="column.field"
data-tip="Veri alani"
@change="
(e) => {
const field = (e.target as HTMLSelectElement).value
const node = itemFields.find((f) => f.key === field)
if (node) {
emit('update', column.id, {
field,
title: node.title,
align: defaultAlignForSchema(node as any),
format: schemaFormatToFormatType(node.format),
})
} else {
emit('update', column.id, { field })
}
}
"
>
<option v-for="f in itemFields" :key="f.key" :value="f.key">{{ f.key }}</option>
</select>
<input
v-else
class="tbl-col__field"
type="text"
:value="column.field"
@change="(e) => emit('update', column.id, { field: (e.target as HTMLInputElement).value })"
data-tip="Veri alani"
/>
<!-- Alignment icons -->
<div class="tbl-col__align">
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': column.align === 'left' }"
@click="emit('update', column.id, { align: 'left' })"
data-tip="Sola hizala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="1" y1="6" x2="8" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="1" y1="9" x2="10" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': column.align === 'center' }"
@click="emit('update', column.id, { align: 'center' })"
data-tip="Ortala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="2.5" y1="6" x2="9.5" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="1.5" y1="9" x2="10.5" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<button
class="tbl-col__align-btn"
:class="{ 'tbl-col__align-btn--on': column.align === 'right' }"
@click="emit('update', column.id, { align: 'right' })"
data-tip="Saga hizala"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="4" y1="6" x2="11" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="2" y1="9" x2="11" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
<!-- Row 3: format + width -->
<div class="tbl-col__extra" data-tip="Veri gosterim formati">
<label class="tbl-col__elabel">Format</label>
<select
class="tbl-col__fmt"
:value="column.format ?? ''"
@change="
(e) =>
emit('update', column.id, {
format: ((e.target as HTMLSelectElement).value || undefined) as FormatType | undefined,
})
"
>
<option value="">Yok</option>
<option value="currency">Para birimi</option>
<option value="number">Sayi</option>
<option value="date">Tarih</option>
<option value="percentage">Yuzde</option>
</select>
</div>
<div class="tbl-col__extra" data-tip="Sutun genislik modu">
<label class="tbl-col__elabel">Genislik</label>
<select
class="tbl-col__wtype"
:value="column.width.type"
@change="
(e) => {
const t = (e.target as HTMLSelectElement).value
if (t === 'auto') emit('update', column.id, { width: { type: 'auto' } })
else if (t === 'fr') emit('update', column.id, { width: { type: 'fr', value: 1 } })
else emit('update', column.id, { width: { type: 'fixed', value: 30 } })
}
"
>
<option value="auto">Otomatik</option>
<option value="fixed">Sabit (mm)</option>
<option value="fr">Oran (fr)</option>
</select>
<span
v-if="column.width.type === 'fixed' || column.width.type === 'fr'"
class="ts-tip-wrap"
:data-tip="column.width.type === 'fixed' ? 'Sabit genislik (mm)' : 'Oran degeri (fr)'"
>
<input
class="tbl-col__wval"
type="number"
step="1"
:min="column.width.type === 'fixed' ? 5 : 1"
:value="(column.width as any).value"
@change="
(e) =>
emit('update', column.id, {
width: {
type: column.width.type,
value:
parseFloat((e.target as HTMLInputElement).value) ||
(column.width.type === 'fixed' ? 30 : 1),
} as any,
})
"
/>
</span>
</div>
</div>
</template>
<style scoped>
.tbl-col {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 5px;
padding: 5px 6px;
margin-bottom: 5px;
}
.tbl-col__head {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.tbl-col__title {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 12px;
font-weight: 500;
color: #334155;
padding: 1px 0;
outline: none;
}
.tbl-col__title:focus {
border-bottom: 1px solid #93c5fd;
}
.tbl-col__actions {
display: flex;
gap: 1px;
flex-shrink: 0;
}
.tbl-col__act {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
border-radius: 3px;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.tbl-col__act:hover {
background: #e2e8f0;
color: #475569;
}
.tbl-col__act--del:hover {
background: #fef2f2;
color: #dc2626;
}
.tbl-col__controls {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.tbl-col__field {
flex: 1;
min-width: 0;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
}
.tbl-col__field:focus {
outline: none;
border-color: #93c5fd;
}
.tbl-col__align {
display: flex;
gap: 0;
flex-shrink: 0;
}
.tbl-col__align-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid #e2e8f0;
background: white;
color: #94a3b8;
cursor: pointer;
padding: 0;
}
.tbl-col__align-btn:first-child {
border-radius: 3px 0 0 3px;
}
.tbl-col__align-btn:last-child {
border-radius: 0 3px 3px 0;
}
.tbl-col__align-btn:not(:first-child) {
border-left: none;
}
.tbl-col__align-btn--on {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.tbl-col__extra {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.tbl-col__elabel {
font-size: 11px;
color: #64748b;
flex-shrink: 0;
}
.tbl-col__fmt {
flex: 1;
min-width: 0;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
cursor: pointer;
}
.tbl-col__wtype {
width: 80px;
padding: 2px 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
cursor: pointer;
}
.tbl-col__wval {
width: 36px;
padding: 2px 3px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
text-align: center;
-moz-appearance: textfield;
}
.tbl-col__wval::-webkit-inner-spin-button,
.tbl-col__wval::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.tbl-col__wval:focus {
outline: none;
border-color: #93c5fd;
}
.ts-tip-wrap {
position: relative;
display: inline-flex;
}
</style>

View File

@@ -0,0 +1,384 @@
<script setup lang="ts">
import type { TableStyle } from '../../../core/types'
import '../../../styles/properties.css'
const props = defineProps<{
style: TableStyle
repeatHeader: boolean
}>()
const emit = defineEmits<{
'update:style': [key: string, value: unknown]
'update:repeatHeader': [value: boolean]
}>()
</script>
<template>
<div class="ts-form">
<!-- Font sizes -->
<label class="ts-lbl" data-tip="Icerik ve header yazi boyutu (pt)">Yazi boyutu</label>
<div class="ts-val ts-val--pair">
<span class="ts-sep">Icerik</span>
<span class="ts-tip-wrap" data-tip="Icerik yazi boyutu (pt)">
<input
class="ts-num"
type="number"
step="1"
min="6"
max="99"
:value="style.fontSize ?? 10"
@input="(e) => emit('update:style', 'fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)"
/>
</span>
<span class="ts-sep">Header</span>
<span class="ts-tip-wrap" data-tip="Header yazi boyutu (pt)">
<input
class="ts-num"
type="number"
step="1"
min="6"
max="99"
:value="style.headerFontSize ?? style.fontSize ?? 10"
@input="(e) => emit('update:style', 'headerFontSize', parseFloat((e.target as HTMLInputElement).value) || 10)"
/>
</span>
</div>
<!-- Colors -->
<label class="ts-lbl" data-tip="Header, metin ve zebra satirlari renkleri">Renkler</label>
<div class="ts-val ts-val--colors">
<div class="ts-color-item" data-tip="Header arkaplan rengi">
<input
class="ts-swatch"
type="color"
:value="style.headerBg ?? '#f0f0f0'"
@input="(e) => emit('update:style', 'headerBg', (e.target as HTMLInputElement).value)"
/>
<span class="ts-clbl">Arkaplan</span>
</div>
<div class="ts-color-item" data-tip="Header metin rengi">
<input
class="ts-swatch"
type="color"
:value="style.headerColor ?? '#000000'"
@input="(e) => emit('update:style', 'headerColor', (e.target as HTMLInputElement).value)"
/>
<span class="ts-clbl">Metin</span>
</div>
<div class="ts-color-item" data-tip="Zebra satir rengi tek satirlar">
<div class="ts-swatch-wrap">
<input
class="ts-swatch"
type="color"
:value="style.zebraOdd ?? '#fafafa'"
@input="(e) => emit('update:style', 'zebraOdd', (e.target as HTMLInputElement).value)"
/>
<button
v-if="style.zebraOdd"
class="ts-swatch-clr"
@click="emit('update:style', 'zebraOdd', undefined)"
>
&times;
</button>
</div>
<span class="ts-clbl">Tek</span>
</div>
<div class="ts-color-item" data-tip="Zebra satir rengi cift satirlar">
<div class="ts-swatch-wrap">
<input
class="ts-swatch"
type="color"
:value="style.zebraEven ?? '#ffffff'"
@input="(e) => emit('update:style', 'zebraEven', (e.target as HTMLInputElement).value)"
/>
<button
v-if="style.zebraEven"
class="ts-swatch-clr"
@click="emit('update:style', 'zebraEven', undefined)"
>
&times;
</button>
</div>
<span class="ts-clbl">Cift</span>
</div>
</div>
<!-- Border -->
<label class="ts-lbl" data-tip="Tablo kenarlik rengi ve kalinligi">Kenarlik</label>
<div class="ts-val ts-val--pair">
<div class="ts-swatch-wrap" data-tip="Kenarlik rengi">
<input
class="ts-swatch"
type="color"
:value="style.borderColor ?? '#cccccc'"
@input="(e) => emit('update:style', 'borderColor', (e.target as HTMLInputElement).value)"
/>
<button
v-if="style.borderColor"
class="ts-swatch-clr"
@click="emit('update:style', 'borderColor', undefined)"
>
&times;
</button>
</div>
<span class="ts-tip-wrap" data-tip="Kenarlik kalinligi (mm)">
<input
class="ts-num"
type="number"
step="0.1"
min="0"
max="99"
:value="style.borderWidth ?? 0.5"
@input="(e) => emit('update:style', 'borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
<span class="ts-unit">mm</span>
</div>
<!-- Cell padding -->
<label class="ts-lbl" data-tip="Hucre ic bosluklari yatay ve dikey (mm)">Ic bosluk</label>
<div class="ts-val ts-val--pair">
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">&#8596;</span>
<span class="ts-tip-wrap" data-tip="Yatay ic bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="style.cellPaddingH ?? 2"
@input="(e) => emit('update:style', 'cellPaddingH', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">&#8597;</span>
<span class="ts-tip-wrap" data-tip="Dikey ic bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="style.cellPaddingV ?? 1"
@input="(e) => emit('update:style', 'cellPaddingV', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
</div>
<!-- Header padding -->
<label class="ts-lbl" data-tip="Header hucre bosluklari yatay ve dikey (mm)">Header bosluk</label>
<div class="ts-val ts-val--pair">
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">&#8596;</span>
<span class="ts-tip-wrap" data-tip="Header yatay bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="style.headerPaddingH ?? style.cellPaddingH ?? 2"
@input="(e) => emit('update:style', 'headerPaddingH', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">&#8597;</span>
<span class="ts-tip-wrap" data-tip="Header dikey bosluk (mm)">
<input
class="ts-num"
type="number"
step="0.5"
min="0"
max="99"
:value="style.headerPaddingV ?? style.cellPaddingV ?? 1"
@input="(e) => emit('update:style', 'headerPaddingV', parseFloat((e.target as HTMLInputElement).value) || 0)"
/>
</span>
</div>
<!-- Repeat header -->
<label class="ts-lbl" data-tip="Cok sayfali tablolarda header'i her sayfada tekrarla">Header tekrarla</label>
<div class="ts-val">
<label class="ts-toggle">
<input
type="checkbox"
:checked="repeatHeader"
@change="(e) => emit('update:repeatHeader', (e.target as HTMLInputElement).checked)"
/>
<span class="ts-toggle__track"></span>
</label>
</div>
</div>
</template>
<style scoped>
.ts-form {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 8px;
align-items: center;
}
.ts-lbl {
font-size: 11px;
color: #64748b;
white-space: nowrap;
}
.ts-val {
display: flex;
align-items: center;
justify-content: flex-end;
}
.ts-val--pair {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.ts-val--colors {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 6px;
}
.ts-sep {
font-size: 10px;
color: #94a3b8;
}
.ts-num {
width: 32px;
padding: 2px 3px;
border: 1px solid #e2e8f0;
border-radius: 3px;
font-size: 11px;
background: white;
color: #334155;
text-align: center;
-moz-appearance: textfield;
}
.ts-num::-webkit-inner-spin-button,
.ts-num::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.ts-num:focus {
outline: none;
border-color: #93c5fd;
}
.ts-unit {
font-size: 10px;
color: #94a3b8;
}
.ts-color-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.ts-clbl {
font-size: 9px;
color: #94a3b8;
white-space: nowrap;
}
.ts-swatch {
width: 22px;
height: 22px;
padding: 0;
cursor: pointer;
border: 1px solid #e2e8f0;
border-radius: 3px;
}
.ts-swatch-wrap {
position: relative;
display: inline-flex;
}
.ts-swatch-clr {
position: absolute;
top: -4px;
right: -4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 9px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #94a3b8;
padding: 0;
}
.ts-swatch-clr:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
.ts-pad-icon {
font-size: 11px;
color: #94a3b8;
line-height: 1;
}
.ts-tip-wrap {
position: relative;
display: inline-flex;
}
.ts-toggle {
position: relative;
display: inline-block;
cursor: pointer;
}
.ts-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.ts-toggle__track {
display: block;
width: 28px;
height: 16px;
background: #e2e8f0;
border-radius: 8px;
transition: background 0.15s;
position: relative;
}
.ts-toggle__track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
transition: transform 0.15s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.ts-toggle input:checked + .ts-toggle__track {
background: #3b82f6;
}
.ts-toggle input:checked + .ts-toggle__track::after {
transform: translateX(12px);
}
</style>

View File

@@ -0,0 +1,30 @@
import { useTemplateStore } from '../stores/template'
import { useEditorStore } from '../stores/editor'
import type { TemplateElement } from '../core/types'
export function usePropertyUpdate(elementRef: () => TemplateElement) {
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...elementRef().style, [key]: value } } as Partial<TemplateElement>)
}
function updateNested(
field: string,
key: string,
value: unknown,
defaults: Record<string, unknown> = {},
) {
const current = (elementRef() as any)[field] ?? defaults
update({ [field]: { ...current, [key]: value } } as any)
}
return { update, updateStyle, updateNested }
}

View File

@@ -116,10 +116,19 @@ export interface BarcodeStyle {
includeText?: boolean // barkod altına değer yazılsın mı (QR hariç)
}
// --- Condition (koşullu gösterim) ---
export interface Condition {
path: string
operator: string
value?: unknown
}
// --- Element tipleri ---
interface BaseElement {
id: string
condition?: Condition
position: PositionMode
size: SizeConstraint
}
@@ -241,11 +250,22 @@ export interface ChartLabels {
color?: string
}
export interface ChartReferenceLine {
categoryIndex: number
color?: string
width?: number
label?: string
dash?: boolean
}
export interface ChartAxis {
xLabel?: string
yLabel?: string
showGrid?: boolean
gridColor?: string
showVerticalGrid?: boolean
verticalGridColor?: string
referenceLines?: ChartReferenceLine[]
}
export interface ChartStyle {

View File

@@ -0,0 +1,119 @@
.et {
display: flex;
align-items: center;
gap: 2px;
background: #1e293b;
border-radius: 6px;
padding: 3px 4px;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06);
pointer-events: auto;
white-space: nowrap;
}
.et__group {
display: flex;
align-items: center;
gap: 1px;
}
.et__sep {
width: 1px;
height: 16px;
background: #334155;
margin: 0 2px;
flex-shrink: 0;
}
.et__btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 0;
transition:
background 0.1s,
color 0.1s;
}
.et__btn:hover {
background: #334155;
color: #e2e8f0;
}
.et__btn--active {
background: #3b82f6;
color: white;
}
.et__btn--active:hover {
background: #2563eb;
}
.et__group--gap {
gap: 3px;
}
.et__gap-icon {
color: #64748b;
flex-shrink: 0;
}
.et__num {
width: 32px;
height: 22px;
border: 1px solid #334155;
border-radius: 4px;
background: #0f172a;
color: #e2e8f0;
text-align: center;
font-size: 11px;
font-family: inherit;
padding: 0;
outline: none;
-moz-appearance: textfield;
}
.et__num::-webkit-inner-spin-button,
.et__num::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.et__num:focus {
border-color: #3b82f6;
}
.et__color-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
position: relative;
color: #94a3b8;
transition: background 0.1s;
}
.et__color-wrap:hover {
background: #334155;
color: #e2e8f0;
}
.et__color {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -55,6 +55,10 @@ test-visual-editor:
test-visual: visual-refs
cd frontend && bun run test:visual
# Visual snapshot'lari guncelle (UI degisikliklerinden sonra)
update-snapshots: visual-refs
cd frontend && bun run test:visual -- --update-snapshots
# Tum testler (Rust + frontend unit + visual)
test-all: test-rust test-front test-visual

View File

@@ -129,10 +129,23 @@ pub struct LineChartLayout {
pub show_labels: bool,
pub label_font: f64,
pub label_color: String,
pub smooth: bool,
/// X axis line endpoints
pub x_axis_y: f64,
pub x_axis_x1: f64,
pub x_axis_x2: f64,
/// Vertical reference lines
pub ref_lines: Vec<RefLineLayout>,
}
pub struct RefLineLayout {
pub x: f64,
pub y1: f64,
pub y2: f64,
pub color: String,
pub width: f64,
pub dash: bool,
pub label: Option<String>,
}
pub struct PieSlice {
@@ -223,6 +236,10 @@ pub trait ChartDataSource {
fn inner_radius(&self) -> Option<f64>;
fn show_points(&self) -> Option<bool>;
fn line_width(&self) -> Option<f64>;
fn curve_type(&self) -> Option<&str>;
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine];
fn show_vertical_grid(&self) -> bool;
fn vertical_grid_color(&self) -> Option<&str>;
}
// ---------------------------------------------------------------------------
@@ -314,6 +331,18 @@ impl ChartDataSource for crate::data_resolve::ResolvedChartData {
fn line_width(&self) -> Option<f64> {
self.style.line_width
}
fn curve_type(&self) -> Option<&str> {
self.style.curve_type.as_deref()
}
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine] {
self.axis.as_ref().map_or(&[], |a| &a.reference_lines)
}
fn show_vertical_grid(&self) -> bool {
self.axis.as_ref().and_then(|a| a.show_vertical_grid).unwrap_or(true)
}
fn vertical_grid_color(&self) -> Option<&str> {
self.axis.as_ref().and_then(|a| a.vertical_grid_color.as_deref())
}
}
// ---------------------------------------------------------------------------
@@ -403,6 +432,18 @@ impl ChartDataSource for crate::ChartRenderData {
fn line_width(&self) -> Option<f64> {
self.line_width
}
fn curve_type(&self) -> Option<&str> {
self.curve_type.as_deref()
}
fn reference_lines(&self) -> &[dreport_core::models::ChartReferenceLine] {
&self.reference_lines
}
fn show_vertical_grid(&self) -> bool {
self.show_vertical_grid
}
fn vertical_grid_color(&self) -> Option<&str> {
self.vertical_grid_color.as_deref()
}
}
// ---------------------------------------------------------------------------
@@ -693,22 +734,14 @@ pub fn compute_x_labels_line(
rotate_angle: 0.0,
};
}
let spacing = if n_cats == 1 {
pw
} else {
pw / (n_cats - 1) as f64
};
let step = pw / n_cats as f64;
let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
let rotate_angle = compute_label_rotation(max_label_len, spacing);
let rotate_angle = compute_label_rotation(max_label_len, step);
let labels = categories
.iter()
.enumerate()
.map(|(ci, cat)| {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let x = px + step / 2.0 + ci as f64 * step;
XLabel {
text: cat.clone(),
x,
@@ -833,6 +866,11 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
let show_labels = data.show_labels();
let label_font = data.label_font_size().unwrap_or(2.2);
let label_color = data.label_color().unwrap_or("#333").to_string();
let smooth = data.curve_type() == Some("smooth");
// Slot-based positioning: each category gets a slot, point centered in slot
// This adds padding on left/right so first/last points don't touch axes
let step = if n_cats > 0 { pw / n_cats as f64 } else { pw };
let series = (0..data.series_count())
.map(|si| {
@@ -841,11 +879,7 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
.iter()
.enumerate()
.map(|(ci, val)| {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let x = px + step / 2.0 + ci as f64 * step;
let y = py + ph - ((val - min_val) / range) * ph;
LinePoint { x, y, value: *val }
})
@@ -859,6 +893,42 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
let x_labels = compute_x_labels_line(data.categories(), px, py + ph, pw);
// Vertical grid lines at each category
let vgrid_color = data.vertical_grid_color().unwrap_or("#E5E7EB").to_string();
let mut ref_lines: Vec<RefLineLayout> = if data.show_vertical_grid() {
(0..n_cats).map(|ci| {
let x = px + step / 2.0 + ci as f64 * step;
RefLineLayout {
x,
y1: py,
y2: py + ph,
color: vgrid_color.clone(),
width: 0.15,
dash: false,
label: None,
}
}).collect()
} else {
vec![]
};
// Explicit reference lines (overlay on top of grid)
for rl in data.reference_lines() {
if rl.category_index >= n_cats {
continue;
}
let x = px + step / 2.0 + rl.category_index as f64 * step;
ref_lines.push(RefLineLayout {
x,
y1: py,
y2: py + ph,
color: rl.color.clone().unwrap_or_else(|| "#9CA3AF".to_string()),
width: rl.width.unwrap_or(0.3),
dash: rl.dash.unwrap_or(true),
label: rl.label.clone(),
});
}
LineChartLayout {
min_val,
max_val,
@@ -870,9 +940,11 @@ pub fn compute_line_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> Line
show_labels,
label_font,
label_color,
smooth,
x_axis_y: py + ph,
x_axis_x1: px,
x_axis_x2: px + pw,
ref_lines,
}
}

View File

@@ -29,14 +29,14 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
// Title
if let Some(ref title) = cl.title {
let anchor = match title.align.as_str() {
"left" => "start",
"right" => "end",
_ => "middle",
"left" => SvgAnchor::Start,
"right" => SvgAnchor::End,
_ => SvgAnchor::Middle,
};
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="{}" font-weight="bold">{}</text>"##,
title.x, title.y, title.font_size, title.color, anchor, escape_xml(&title.text)
title.x, title.y, title.font_size, title.color, anchor.as_str(), escape_xml(&title.text)
)
.unwrap();
}
@@ -56,14 +56,7 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
let has_axis = !matches!(data.chart_type, dreport_core::models::ChartType::Pie);
if has_axis && let Some(ref axis) = data.axis {
if let Some(ref x_label) = axis.x_label {
let x = cl.plot_x + cl.plot_w / 2.0;
let y = height_mm - 2.0;
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.8" fill="#666" text-anchor="middle">{}</text>"##,
x, y, escape_xml(x_label)
)
.unwrap();
svg_text(&mut svg, cl.plot_x + cl.plot_w / 2.0, height_mm - 2.0, 2.8, "#666", SvgAnchor::Middle, x_label);
}
if let Some(ref y_label) = axis.y_label {
let x = 3.0;
@@ -101,24 +94,8 @@ fn render_bar(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
)
.unwrap();
if bl.show_labels {
if bl.stacked {
if bar.value > 0.0 {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
bar.label_x, bar.label_y, bl.label_font, bl.label_color, format_value(bar.value)
)
.unwrap();
}
} else {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
bar.label_x, bar.label_y, bl.label_font, bl.label_color, format_value(bar.value)
)
.unwrap();
}
if bl.show_labels && (!bl.stacked || bar.value > 0.0) {
svg_text(svg, bar.label_x, bar.label_y, bl.label_font, &bl.label_color, SvgAnchor::Middle, &format_value(bar.value));
}
}
@@ -144,14 +121,13 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
// Y axis
render_y_axis_svg(svg, &ll.y_axis);
let mut label_texts = String::new();
for series_layout in &ll.series {
let color = color_at(&cl.palette, series_layout.color_idx);
let mut points = String::new();
let mut point_circles = String::new();
for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
if ll.show_points {
write!(
point_circles,
@@ -162,24 +138,75 @@ fn render_line(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
}
if ll.show_labels {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{}</text>"##,
pt.x, pt.y - 1.5, ll.label_font, ll.label_color, format_value(pt.value)
)
.unwrap();
svg_text(&mut label_texts, pt.x, pt.y - 1.5, ll.label_font, &ll.label_color, SvgAnchor::Middle, &format_value(pt.value));
}
}
write!(
svg,
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
points.trim(), color, ll.line_width
)
.unwrap();
if ll.smooth && series_layout.points.len() >= 2 {
// Catmull-Rom → cubic bezier smooth curve
let pts = &series_layout.points;
let mut d = format!("M{:.2},{:.2}", pts[0].x, pts[0].y);
for i in 0..pts.len() - 1 {
let p0 = if i > 0 { &pts[i - 1] } else { &pts[i] };
let p1 = &pts[i];
let p2 = &pts[i + 1];
let p3 = if i + 2 < pts.len() { &pts[i + 2] } else { &pts[i + 1] };
let cp1x = p1.x + (p2.x - p0.x) / 6.0;
let cp1y = p1.y + (p2.y - p0.y) / 6.0;
let cp2x = p2.x - (p3.x - p1.x) / 6.0;
let cp2y = p2.y - (p3.y - p1.y) / 6.0;
write!(d, " C{:.2},{:.2} {:.2},{:.2} {:.2},{:.2}",
cp1x, cp1y, cp2x, cp2y, p2.x, p2.y
).unwrap();
}
write!(
svg,
r##"<path d="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
d, color, ll.line_width
)
.unwrap();
} else {
let mut points = String::new();
for pt in &series_layout.points {
write!(points, "{:.2},{:.2} ", pt.x, pt.y).unwrap();
}
write!(
svg,
r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linejoin="round" stroke-linecap="round"/>"##,
points.trim(), color, ll.line_width
)
.unwrap();
}
svg.push_str(&point_circles);
}
// Data labels (rendered after lines/points so they appear on top)
svg.push_str(&label_texts);
// Reference lines (vertical)
for rl in &ll.ref_lines {
if rl.dash {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{:.2}" stroke-dasharray="1.5,1"/>"##,
rl.x, rl.y1, rl.x, rl.y2, rl.color, rl.width
)
.unwrap();
} else {
write!(
svg,
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{:.2}"/>"##,
rl.x, rl.y1, rl.x, rl.y2, rl.color, rl.width
)
.unwrap();
}
if let Some(ref label) = rl.label {
svg_text(svg, rl.x, rl.y1 - 1.0, 2.0, &rl.color, SvgAnchor::Middle, label);
}
}
// X axis labels
render_x_labels_svg(svg, &ll.x_labels);
@@ -239,18 +266,11 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
.unwrap();
}
// Percentage label inside slice
if pl.show_labels {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle" dominant-baseline="central">{}%</text>"##,
slice.label_x, slice.label_y, pl.label_font, pl.label_color,
(slice.fraction * 100.0).round()
)
.unwrap();
let pct = format!("{}%", (slice.fraction * 100.0).round());
svg_text_central(svg, slice.label_x, slice.label_y, pl.label_font, &pl.label_color, SvgAnchor::Middle, &pct);
}
// Category name label outside slice with leader line
if pl.show_cat_labels && !slice.cat_label_text.is_empty() {
write!(
svg,
@@ -259,18 +279,8 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
slice.leader_end_x, slice.leader_end_y
)
.unwrap();
let anchor = if slice.cat_label_anchor_end {
"end"
} else {
"start"
};
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#555" text-anchor="{}" dominant-baseline="central">{}</text>"##,
slice.cat_label_x, slice.cat_label_y, anchor, escape_xml(&slice.cat_label_text)
)
.unwrap();
let anchor = if slice.cat_label_anchor_end { SvgAnchor::End } else { SvgAnchor::Start };
svg_text_central(svg, slice.cat_label_x, slice.cat_label_y, 2.5, "#555", anchor, &slice.cat_label_text);
}
}
}
@@ -292,15 +302,7 @@ fn render_legend(
item.swatch_x, item.swatch_y, color
)
.unwrap();
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="#666">{}</text>"##,
item.text_x,
item.text_y,
legend.font_size,
escape_xml(&item.name)
)
.unwrap();
svg_text(svg, item.text_x, item.text_y, legend.font_size, "#666", SvgAnchor::Start, &item.name);
}
}
@@ -310,12 +312,7 @@ fn render_legend(
fn render_y_axis_svg(svg: &mut String, y_axis: &chart_layout::YAxisLayout) {
for tick in &y_axis.ticks {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.3" fill="#666" text-anchor="end">{}</text>"##,
y_axis.axis_x - 1.5, tick.y + 0.8, tick.label
)
.unwrap();
svg_text(svg, y_axis.axis_x - 1.5, tick.y + 0.8, 2.3, "#666", SvgAnchor::End, &tick.label);
if y_axis.show_grid {
write!(
@@ -340,6 +337,7 @@ fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout)
let angle = x_labels.rotate_angle;
for label in &x_labels.labels {
if angle > 0.0 {
// Döndürülmüş etiket — transform gerektiğinden helper kullanamıyoruz
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-{:.1},{:.2},{:.2})">{}</text>"##,
@@ -347,12 +345,7 @@ fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout)
)
.unwrap();
} else {
write!(
svg,
r##"<text x="{:.2}" y="{:.2}" font-size="2.5" fill="#666" text-anchor="middle">{}</text>"##,
label.x, label.y, escape_xml(&label.text)
)
.unwrap();
svg_text(svg, label.x, label.y, 2.5, "#666", SvgAnchor::Middle, &label.text);
}
}
}
@@ -364,6 +357,61 @@ fn escape_xml(s: &str) -> String {
.replace('"', "&quot;")
}
/// SVG text hizalama modu
enum SvgAnchor {
Start,
Middle,
End,
}
impl SvgAnchor {
fn as_str(&self) -> &str {
match self {
SvgAnchor::Start => "start",
SvgAnchor::Middle => "middle",
SvgAnchor::End => "end",
}
}
}
/// Tekrarlayan SVG text element yazımını soyutlar.
fn svg_text(
svg: &mut String,
x: f64,
y: f64,
font_size: f64,
fill: &str,
anchor: SvgAnchor,
text: &str,
) {
write!(
svg,
r##"<text x="{x:.2}" y="{y:.2}" font-size="{font_size:.1}" fill="{fill}" text-anchor="{anchor}">{text}</text>"##,
anchor = anchor.as_str(),
text = escape_xml(text),
)
.unwrap();
}
/// SVG text with dominant-baseline="central" (pie labels vb.)
fn svg_text_central(
svg: &mut String,
x: f64,
y: f64,
font_size: f64,
fill: &str,
anchor: SvgAnchor,
text: &str,
) {
write!(
svg,
r##"<text x="{x:.2}" y="{y:.2}" font-size="{font_size:.1}" fill="{fill}" text-anchor="{anchor}" dominant-baseline="central">{text}</text>"##,
anchor = anchor.as_str(),
text = escape_xml(text),
)
.unwrap();
}
#[cfg(test)]
mod tests {
use super::*;
@@ -567,6 +615,9 @@ mod tests {
y_label: Some("Revenue".to_string()),
show_grid: None,
grid_color: None,
show_vertical_grid: None,
vertical_grid_color: None,
reference_lines: vec![],
});
let svg = render_svg(&data, 100.0, 60.0);
@@ -582,6 +633,9 @@ mod tests {
y_label: Some("Y Label".to_string()),
show_grid: None,
grid_color: None,
show_vertical_grid: None,
vertical_grid_color: None,
reference_lines: vec![],
});
let svg = render_svg(&data, 80.0, 80.0);

View File

@@ -2,6 +2,9 @@ use dreport_core::models::*;
use serde_json::Value;
use std::collections::HashMap;
// Re-export HasOptionalBinding for convenience
pub use dreport_core::models::HasOptionalBinding;
/// Şu anki tarihi verilen format string'ine göre formatla.
/// Desteklenen tokenlar: YYYY, MM, DD, HH, mm, ss
/// WASM'da js_sys::Date, native'de SystemTime kullanır.
@@ -226,6 +229,15 @@ fn json_values_eq(a: &Value, b: &Value) -> bool {
}
}
/// Çözümle optional binding: binding varsa data'dan, yoksa static value'dan
fn resolve_optional_binding(el: &impl HasOptionalBinding, data: &Value) -> String {
if let Some(binding) = el.binding() {
value_to_string(resolve_path(data, &binding.path))
} else {
el.static_value().unwrap_or_default().to_string()
}
}
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData, format_config: &dreport_core::models::FormatConfig) {
// Koşul kontrolü: condition varsa ve sağlanmıyorsa, hidden olarak işaretle ve çık
if let Some(condition) = el.condition() && !evaluate_condition(condition, data) {
@@ -235,7 +247,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
match el {
TemplateElement::StaticText(e) => {
resolved.texts.insert(e.id.clone(), e.content.clone());
resolved.texts.insert(e.base.id.clone(), e.content.clone());
}
TemplateElement::Text(e) => {
let bound_value = value_to_string(resolve_path(data, &e.binding.path));
@@ -243,7 +255,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
Some(prefix) if !prefix.is_empty() => format!("{}{}", prefix, bound_value),
_ => bound_value,
};
resolved.texts.insert(e.id.clone(), text);
resolved.texts.insert(e.base.id.clone(), text);
}
TemplateElement::PageNumber(e) => {
// Format string'i sakla — sayfa bölme sonrası gerçek değerlerle çözülecek
@@ -254,28 +266,18 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
.to_string();
resolved
.page_number_formats
.insert(e.id.clone(), fmt.clone());
.insert(e.base.id.clone(), fmt.clone());
// Placeholder koy (tek sayfalık fallback)
resolved.texts.insert(
e.id.clone(),
e.base.id.clone(),
fmt.replace("{current}", "1").replace("{total}", "1"),
);
}
TemplateElement::Barcode(e) => {
let value = if let Some(binding) = &e.binding {
value_to_string(resolve_path(data, &binding.path))
} else {
e.value.clone().unwrap_or_default()
};
resolved.barcodes.insert(e.id.clone(), value);
resolved.barcodes.insert(e.base.id.clone(), resolve_optional_binding(e, data));
}
TemplateElement::Image(e) => {
let src = if let Some(binding) = &e.binding {
value_to_string(resolve_path(data, &binding.path))
} else {
e.src.clone().unwrap_or_default()
};
resolved.images.insert(e.id.clone(), src);
resolved.images.insert(e.base.id.clone(), resolve_optional_binding(e, data));
}
TemplateElement::RepeatingTable(e) => {
let array = resolve_path(data, &e.data_source.path);
@@ -302,7 +304,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
}
_ => vec![],
};
resolved.tables.insert(e.id.clone(), ResolvedTable { rows });
resolved.tables.insert(e.base.id.clone(), ResolvedTable { rows });
}
TemplateElement::Container(e) => {
for child in &e.children {
@@ -312,7 +314,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
TemplateElement::CurrentDate(e) => {
let fmt = e.format.as_deref().unwrap_or("DD.MM.YYYY");
let text = format_current_date(fmt);
resolved.texts.insert(e.id.clone(), text);
resolved.texts.insert(e.base.id.clone(), text);
}
TemplateElement::Checkbox(e) => {
let checked = if let Some(binding) = &e.binding {
@@ -327,7 +329,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
e.checked.unwrap_or(false)
};
// Store as "true"/"false" string in texts map
resolved.texts.insert(e.id.clone(), checked.to_string());
resolved.texts.insert(e.base.id.clone(), checked.to_string());
}
TemplateElement::CalculatedText(e) => {
let result = crate::expr_eval::evaluate_expression(&e.expression, data);
@@ -338,7 +340,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
} else {
formatted
};
resolved.texts.insert(e.id.clone(), text);
resolved.texts.insert(e.base.id.clone(), text);
}
TemplateElement::RichText(e) => {
let spans: Vec<ResolvedRichSpan> = e
@@ -371,7 +373,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
}
})
.collect();
resolved.rich_texts.insert(e.id.clone(), spans);
resolved.rich_texts.insert(e.base.id.clone(), spans);
}
TemplateElement::Chart(e) => {
let array = resolve_path(data, &e.data_source.path);
@@ -389,7 +391,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
group_mode: e.group_mode.clone(),
},
};
resolved.charts.insert(e.id.clone(), chart_data);
resolved.charts.insert(e.base.id.clone(), chart_data);
}
TemplateElement::Line(_) => {}
TemplateElement::Shape(_) => {}
@@ -542,10 +544,7 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -554,10 +553,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "el_name".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("el_name".to_string(), SizeConstraint::default()),
style: TextStyle::default(),
content: None,
binding: ScalarBinding {
@@ -593,10 +589,7 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -605,10 +598,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "el_no".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("el_no".to_string(), SizeConstraint::default()),
style: TextStyle::default(),
content: Some("Fatura No: ".to_string()),
binding: ScalarBinding {
@@ -641,10 +631,7 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -653,10 +640,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("title".to_string(), SizeConstraint::default()),
style: TextStyle::default(),
content: "FATURA".to_string(),
})],
@@ -682,10 +666,7 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -694,10 +675,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("tbl".to_string(), SizeConstraint::default()),
data_source: ArrayBinding {
path: "kalemler".to_string(),
},
@@ -754,10 +732,7 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -766,10 +741,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("tbl".to_string(), SizeConstraint::default()),
data_source: ArrayBinding {
path: "items".to_string(),
},
@@ -808,10 +780,7 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -820,10 +789,7 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "el_missing".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("el_missing".to_string(), SizeConstraint::default()),
style: TextStyle::default(),
content: None,
binding: ScalarBinding {

View File

@@ -161,8 +161,21 @@ pub struct ChartRenderData {
// Title align
#[serde(default)]
pub title_align: Option<String>,
// Curve type for line charts
#[serde(default)]
pub curve_type: Option<String>,
// Vertical reference lines
#[serde(default)]
pub reference_lines: Vec<dreport_core::models::ChartReferenceLine>,
// Vertical grid
#[serde(default = "default_true")]
pub show_vertical_grid: bool,
#[serde(default)]
pub vertical_grid_color: Option<String>,
}
fn default_true() -> bool { true }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartSeriesData {
pub name: String,
@@ -223,6 +236,131 @@ pub struct ResolvedStyle {
pub barcode_include_text: Option<bool>,
}
// --- From<&XStyle> for ResolvedStyle ---
impl From<&dreport_core::models::TextStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::TextStyle) -> Self {
Self {
font_size: s.font_size,
font_weight: s.font_weight.clone(),
font_style: s.font_style.clone(),
font_family: s.font_family.clone(),
color: s.color.clone(),
text_align: s.align.clone(),
..Default::default()
}
}
}
impl From<&dreport_core::models::ContainerStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::ContainerStyle) -> Self {
Self {
background_color: s.background_color.clone(),
border_color: s.border_color.clone(),
border_width: s.border_width,
border_radius: s.border_radius,
border_style: s.border_style.clone(),
..Default::default()
}
}
}
impl From<&dreport_core::models::LineStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::LineStyle) -> Self {
Self {
stroke_color: s.stroke_color.clone(),
stroke_width: s.stroke_width,
..Default::default()
}
}
}
impl From<&dreport_core::models::ImageStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::ImageStyle) -> Self {
Self {
object_fit: s.object_fit.clone(),
..Default::default()
}
}
}
impl From<&dreport_core::models::BarcodeStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::BarcodeStyle) -> Self {
Self {
barcode_color: s.color.clone(),
barcode_include_text: s.include_text,
..Default::default()
}
}
}
impl From<&dreport_core::models::CheckboxStyle> for ResolvedStyle {
fn from(s: &dreport_core::models::CheckboxStyle) -> Self {
Self {
color: s.check_color.clone(),
border_color: s.border_color.clone(),
border_width: s.border_width,
..Default::default()
}
}
}
impl From<&data_resolve::ResolvedChartData> for ChartRenderData {
fn from(cd: &data_resolve::ResolvedChartData) -> Self {
let n_colors = cd.categories.len().max(cd.series.len()).max(1);
let colors: Vec<String> = (0..n_colors)
.map(|i| {
cd.style
.colors
.as_ref()
.and_then(|c| c.get(i).cloned())
.unwrap_or_else(|| {
chart_layout::DEFAULT_COLORS[i % chart_layout::DEFAULT_COLORS.len()]
.to_string()
})
})
.collect();
Self {
chart_type: cd.chart_type.clone(),
categories: cd.categories.clone(),
series: cd
.series
.iter()
.map(|s| ChartSeriesData {
name: s.name.clone(),
values: s.values.clone(),
})
.collect(),
title_text: cd.title.as_ref().map(|t| t.text.clone()),
title_font_size: cd.title.as_ref().and_then(|t| t.font_size),
title_color: cd.title.as_ref().and_then(|t| t.color.clone()),
title_align: cd.title.as_ref().and_then(|t| t.align.clone()),
colors,
show_labels: cd.labels.as_ref().is_some_and(|l| l.show),
label_font_size: cd.labels.as_ref().and_then(|l| l.font_size),
label_color: cd.labels.as_ref().and_then(|l| l.color.clone()),
show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true),
grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()),
bar_gap: cd.style.bar_gap,
stacked: matches!(cd.group_mode, Some(dreport_core::models::GroupMode::Stacked)),
inner_radius: cd.style.inner_radius,
show_points: cd.style.show_points,
line_width: cd.style.line_width,
background_color: cd.style.background_color.clone(),
legend_show: cd.legend.as_ref().is_some_and(|l| l.show),
legend_position: cd.legend.as_ref().and_then(|l| l.position.clone()),
legend_font_size: cd.legend.as_ref().and_then(|l| l.font_size),
x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()),
y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()),
curve_type: cd.style.curve_type.clone(),
reference_lines: cd.axis.as_ref().map_or_else(Vec::new, |a| a.reference_lines.clone()),
show_vertical_grid: cd.axis.as_ref().and_then(|a| a.show_vertical_grid).unwrap_or(true),
vertical_grid_color: cd.axis.as_ref().and_then(|a| a.vertical_grid_color.clone()),
}
}
}
/// Ana layout hesaplama fonksiyonu.
/// Template + data + font verileri alır, her element için pozisyon döner.
pub fn compute_layout(

View File

@@ -21,11 +21,6 @@ fn mm(v: f64) -> f32 {
v as f32 * MM_TO_PT
}
/// f64 mm degerini f32 pt'ye cevir (chart render icin)
fn pt(mm_val: f64) -> f32 {
mm_val as f32 * MM_TO_PT
}
/// Hex renk (#RRGGBB veya #RGB) → rgb::Color
fn parse_color(hex: &str) -> rgb::Color {
let hex = hex.trim_start_matches('#');
@@ -46,6 +41,18 @@ fn parse_color(hex: &str) -> rgb::Color {
rgb::Color::new(r, g, b)
}
fn fill_from_color(color: rgb::Color) -> Fill {
Fill {
paint: color.into(),
opacity: NormalizedF32::ONE,
rule: Default::default(),
}
}
// ---------------------------------------------------------------------------
// Path builders
// ---------------------------------------------------------------------------
/// Rounded rectangle path oluştur. radius 0 ise düz dikdörtgen.
fn build_rect_path(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<krilla::geom::Path> {
let mut pb = PathBuilder::new();
@@ -92,11 +99,132 @@ fn build_ellipse_path(x: f32, y: f32, w: f32, h: f32) -> Option<krilla::geom::Pa
pb.finish()
}
fn fill_from_color(color: rgb::Color) -> Fill {
Fill {
paint: color.into(),
opacity: NormalizedF32::ONE,
rule: Default::default(),
/// Merkez + radius'tan daire path'i oluştur (build_ellipse_path'in kısa hali)
fn build_circle_path(cx: f32, cy: f32, r: f32) -> Option<krilla::geom::Path> {
build_ellipse_path(cx - r, cy - r, r * 2.0, r * 2.0)
}
// ---------------------------------------------------------------------------
// SurfaceExt — krilla surface üzerinde tekrar eden draw kalıplarını soyutlar
// ---------------------------------------------------------------------------
trait SurfaceExt {
fn draw_filled(&mut self, path: &krilla::geom::Path, color: rgb::Color);
fn draw_stroked(&mut self, path: &krilla::geom::Path, color: rgb::Color, width: f32);
fn draw_filled_stroked(
&mut self,
path: &krilla::geom::Path,
fill: Option<rgb::Color>,
stroke_color: rgb::Color,
stroke_width: f32,
);
}
impl SurfaceExt for krilla::surface::Surface<'_> {
fn draw_filled(&mut self, path: &krilla::geom::Path, color: rgb::Color) {
self.set_fill(Some(fill_from_color(color)));
self.set_stroke(None);
self.draw_path(path);
self.set_fill(None);
}
fn draw_stroked(&mut self, path: &krilla::geom::Path, color: rgb::Color, width: f32) {
self.set_fill(None);
self.set_stroke(Some(Stroke {
paint: color.into(),
width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
self.draw_path(path);
self.set_stroke(None);
}
fn draw_filled_stroked(
&mut self,
path: &krilla::geom::Path,
fill: Option<rgb::Color>,
stroke_color: rgb::Color,
stroke_width: f32,
) {
self.set_fill(fill.map(fill_from_color));
self.set_stroke(Some(Stroke {
paint: stroke_color.into(),
width: stroke_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
self.draw_path(path);
self.set_fill(None);
self.set_stroke(None);
}
}
// ---------------------------------------------------------------------------
// draw_box — container ve shape'in ortak fill+border çizim mantığı
// ---------------------------------------------------------------------------
/// Kutu şekli: dikdörtgen, yuvarlatılmış dikdörtgen veya elips.
enum BoxShape {
Rect { radius: f32 },
Ellipse,
}
/// Arka plan + border'ı tek seferde çizer (CSS border-box modeli).
/// Container ve shape render'larının ortak kodu.
fn draw_box(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
bg_color: Option<&str>,
border_color: Option<&str>,
border_width: Option<f64>,
shape: BoxShape,
) {
let has_bg = bg_color.is_some();
let has_border = border_color.is_some() && border_width.unwrap_or(0.0) > 0.0;
if !has_bg && !has_border {
return;
}
let build_path = |bx: f32, by: f32, bw: f32, bh: f32, shape: &BoxShape| -> Option<krilla::geom::Path> {
match shape {
BoxShape::Ellipse => build_ellipse_path(bx, by, bw, bh),
BoxShape::Rect { radius } => build_rect_path(bx, by, bw, bh, *radius),
}
};
if has_border {
let bw = mm(border_width.unwrap_or(0.5));
let bc = parse_color(border_color.unwrap_or("#000000"));
let inset = bw / 2.0;
// Border durumunda radius'u inset kadar küçült
let inset_shape = match shape {
BoxShape::Ellipse => BoxShape::Ellipse,
BoxShape::Rect { radius } => BoxShape::Rect {
radius: (radius - inset).max(0.0),
},
};
let path = build_path(x + inset, y + inset, w - bw, h - bw, &inset_shape);
if let Some(p) = path {
surface.draw_filled_stroked(
&p,
bg_color.map(parse_color),
bc,
bw,
);
}
} else {
let fill = parse_color(bg_color.unwrap_or("#ffffff"));
let path = build_path(x, y, w, h, &shape);
if let Some(p) = path {
surface.draw_filled(&p, fill);
}
}
}
@@ -329,80 +457,29 @@ fn render_shape(
style: &ResolvedStyle,
content: &Option<ResolvedContent>,
) {
let has_bg = style.background_color.is_some();
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
if !has_bg && !has_border {
return;
}
let shape_type = match content {
Some(ResolvedContent::Shape { shape_type }) => shape_type.as_str(),
_ => "rectangle",
};
let rect_radius = |s: &ResolvedStyle| -> f32 {
if shape_type == "rounded_rectangle" {
s.border_radius.map(mm).unwrap_or(mm(3.0))
} else {
s.border_radius.map(mm).unwrap_or(0.0)
}
let shape = match shape_type {
"ellipse" => BoxShape::Ellipse,
"rounded_rectangle" => BoxShape::Rect {
radius: style.border_radius.map(mm).unwrap_or(mm(3.0)),
},
_ => BoxShape::Rect {
radius: style.border_radius.map(mm).unwrap_or(0.0),
},
};
if has_border {
let border_width = mm(style.border_width.unwrap_or(0.5));
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
let inset = border_width / 2.0;
// Fill + stroke tek path ile — anti-aliasing uyumu
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
let path = match shape_type {
"ellipse" => {
build_ellipse_path(x + inset, y + inset, w - border_width, h - border_width)
}
_ => {
let radius = rect_radius(style);
build_rect_path(
x + inset,
y + inset,
w - border_width,
h - border_width,
(radius - inset).max(0.0),
)
}
};
if let Some(p) = path {
surface.draw_path(&p);
}
} else {
// Sadece fill, border yok
surface.set_fill(Some(fill_from_color(parse_color(
style.background_color.as_deref().unwrap_or("#ffffff"),
))));
surface.set_stroke(None);
let path = match shape_type {
"ellipse" => build_ellipse_path(x, y, w, h),
_ => build_rect_path(x, y, w, h, rect_radius(style)),
};
if let Some(p) = path {
surface.draw_path(&p);
}
}
surface.set_fill(None);
surface.set_stroke(None);
draw_box(
surface,
x, y, w, h,
style.background_color.as_deref(),
style.border_color.as_deref(),
style.border_width,
shape,
);
}
fn render_checkbox(
@@ -418,54 +495,24 @@ fn render_checkbox(
let border_width = mm(style.border_width.unwrap_or(0.3));
let inset = border_width / 2.0;
// Draw box outline (inset for CSS border-box match)
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
if let Some(p) = build_rect_path(
x + inset,
y + inset,
w - border_width,
h - border_width,
0.0,
) {
surface.draw_path(&p);
if let Some(p) = build_rect_path(x + inset, y + inset, w - border_width, h - border_width, 0.0) {
surface.draw_stroked(&p, border_color, border_width);
}
// Draw checkmark if checked
if checked {
let check_color = parse_color(style.color.as_deref().unwrap_or("#000000"));
let stroke_w = w.min(h) * 0.12;
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: check_color.into(),
width: stroke_w,
opacity: NormalizedF32::ONE,
..Default::default()
}));
// Checkmark: two lines forming a "✓"
let check_path = {
let mut pb = PathBuilder::new();
let mx = w * 0.2;
let my = h * 0.5;
pb.move_to(x + mx, y + my);
pb.move_to(x + w * 0.2, y + h * 0.5);
pb.line_to(x + w * 0.4, y + h * 0.75);
pb.line_to(x + w * 0.8, y + h * 0.25);
pb.finish()
};
if let Some(p) = check_path {
surface.draw_path(&p);
surface.draw_stroked(&p, check_color, stroke_w);
}
}
surface.set_fill(None);
surface.set_stroke(None);
}
fn render_container_bg(
@@ -476,55 +523,16 @@ fn render_container_bg(
h: f32,
style: &ResolvedStyle,
) {
let has_bg = style.background_color.is_some();
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
if !has_bg && !has_border {
return;
}
let radius = style.border_radius.map(mm).unwrap_or(0.0);
if has_border {
let border_width = mm(style.border_width.unwrap_or(0.5));
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
let inset = border_width / 2.0;
// CSS border-box: stroke path'i border_width/2 içeri çek.
// Tek draw_path ile hem fill hem stroke çizerek anti-aliasing uyumunu sağla.
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
if let Some(path) = build_rect_path(
x + inset,
y + inset,
w - border_width,
h - border_width,
(radius - inset).max(0.0),
) {
surface.draw_path(&path);
}
} else {
// Sadece background, border yok
surface.set_fill(Some(fill_from_color(parse_color(
style.background_color.as_deref().unwrap_or("#ffffff"),
))));
surface.set_stroke(None);
if let Some(path) = build_rect_path(x, y, w, h, radius) {
surface.draw_path(&path);
}
}
surface.set_fill(None);
surface.set_stroke(None);
draw_box(
surface,
x, y, w, h,
style.background_color.as_deref(),
style.border_color.as_deref(),
style.border_width,
BoxShape::Rect {
radius: style.border_radius.map(mm).unwrap_or(0.0),
},
);
}
#[allow(clippy::too_many_arguments)]
@@ -707,32 +715,16 @@ fn render_line(
h: f32,
style: &ResolvedStyle,
) {
let stroke_color = style
let color = style
.stroke_color
.as_deref()
.map(parse_color)
.unwrap_or(rgb::Color::new(0, 0, 0));
// Çizgiyi filled rectangle olarak çiz — CSS borderTop ile aynı davranış.
// Stroke kullanmak sub-pixel anti-aliasing farkları yaratır.
surface.set_fill(Some(fill_from_color(stroke_color)));
surface.set_stroke(None);
let rect_path = {
let mut pb = PathBuilder::new();
// Eleman yüksekliği layout engine tarafından stroke_width olarak hesaplandı.
// Tüm eleman alanını dolduran ince dikdörtgen çiz.
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
pb.push_rect(rect);
}
pb.finish()
};
if let Some(p) = rect_path {
surface.draw_path(&p);
if let Some(path) = build_rect_path(x, y, w, h, 0.0) {
surface.draw_filled(&path, color);
}
surface.set_fill(None);
}
#[derive(Debug, PartialEq)]
@@ -935,14 +927,14 @@ fn render_chart(
if let Some(f) = font {
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
let fs_pt = pt(title.font_size);
let fs_pt = mm(title.font_size);
let (tw, _) = measurer.measure(&title.text, None, fs_pt, Some("bold"), None);
let tx = match title.align.as_str() {
"left" => pt(title.x),
"right" => pt(title.x) - tw,
_ => pt(title.x) - tw / 2.0,
"left" => mm(title.x),
"right" => mm(title.x) - tw,
_ => mm(title.x) - tw / 2.0,
};
let ty = pt(title.y);
let ty = mm(title.y);
surface.draw_text(
Point::from_xy(tx, ty),
f.clone(),
@@ -966,26 +958,28 @@ fn render_chart(
if bl.stacked {
if bar.value > 0.0 {
let label = format_value(bar.value);
chart_text_centered(
chart_text(
surface,
bar.label_x,
bar.label_y,
&label,
bl.label_font,
&bl.label_color,
ChartTextAlign::Center,
fonts,
measurer,
);
}
} else {
let label = format_value(bar.value);
chart_text_centered(
chart_text(
surface,
bar.label_x,
bar.label_y,
&label,
bl.label_font,
&bl.label_color,
ChartTextAlign::Center,
fonts,
measurer,
);
@@ -1015,17 +1009,39 @@ fn render_chart(
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: color.into(),
width: pt(ll.line_width),
width: mm(ll.line_width),
opacity: NormalizedF32::ONE,
..Default::default()
}));
let path = {
let mut pb = PathBuilder::new();
for (i, (lx, ly)) in points.iter().enumerate() {
if i == 0 {
pb.move_to(pt(*lx), pt(*ly));
} else {
pb.line_to(pt(*lx), pt(*ly));
if ll.smooth && points.len() >= 2 {
let pts = &series_layout.points;
pb.move_to(mm(pts[0].x), mm(pts[0].y));
for i in 0..pts.len() - 1 {
let p0 = if i > 0 { &pts[i - 1] } else { &pts[i] };
let p1 = &pts[i];
let p2 = &pts[i + 1];
let p3 = if i + 2 < pts.len() { &pts[i + 2] } else { &pts[i + 1] };
let cp1x = p1.x + (p2.x - p0.x) / 6.0;
let cp1y = p1.y + (p2.y - p0.y) / 6.0;
let cp2x = p2.x - (p3.x - p1.x) / 6.0;
let cp2y = p2.y - (p3.y - p1.y) / 6.0;
pb.cubic_to(
mm(cp1x), mm(cp1y),
mm(cp2x), mm(cp2y),
mm(p2.x), mm(p2.y),
);
}
} else {
for (i, (lx, ly)) in points.iter().enumerate() {
if i == 0 {
pb.move_to(mm(*lx), mm(*ly));
} else {
pb.line_to(mm(*lx), mm(*ly));
}
}
}
pb.finish()
@@ -1037,24 +1053,8 @@ fn render_chart(
// Points
if ll.show_points {
for (lx, ly) in &points {
let r = pt(0.8);
let cx = pt(*lx);
let cy = pt(*ly);
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
let circle = {
let mut pb = PathBuilder::new();
let k = r * 0.5522848;
pb.move_to(cx, cy - r);
pb.cubic_to(cx + k, cy - r, cx + r, cy - k, cx + r, cy);
pb.cubic_to(cx + r, cy + k, cx + k, cy + r, cx, cy + r);
pb.cubic_to(cx - k, cy + r, cx - r, cy + k, cx - r, cy);
pb.cubic_to(cx - r, cy - k, cx - k, cy - r, cx, cy - r);
pb.close();
pb.finish()
};
if let Some(p) = circle {
surface.draw_path(&p);
if let Some(circle) = build_circle_path(mm(*lx), mm(*ly), mm(0.8)) {
surface.draw_filled(&circle, color);
}
}
}
@@ -1063,19 +1063,46 @@ fn render_chart(
if ll.show_labels {
for lp in &series_layout.points {
let label = format_value(lp.value);
chart_text_centered(
chart_text(
surface,
lp.x,
lp.y - 1.5,
&label,
ll.label_font,
&ll.label_color,
ChartTextAlign::Center,
fonts,
measurer,
);
}
}
}
// Reference lines (vertical)
for rl in &ll.ref_lines {
let rl_color = parse_color(&rl.color);
chart_line_seg(
surface,
rl.x,
rl.y1,
rl.x,
rl.y2,
rl_color,
(rl.width * 2.5) as f32,
);
if let Some(ref label) = rl.label {
chart_text(
surface,
rl.x,
rl.y1 - 1.0,
label,
2.0,
&rl.color,
ChartTextAlign::Center,
fonts,
measurer,
);
}
}
render_chart_x_labels(surface, &ll.x_labels, fonts, measurer);
let ac = parse_color("#9CA3AF");
chart_line_seg(
@@ -1114,13 +1141,14 @@ fn render_chart(
if pl.show_labels {
let pct = (slice.fraction * 100.0).round();
let label = format!("{}%", pct);
chart_text_centered(
chart_text(
surface,
slice.label_x,
slice.label_y,
&label,
pl.label_font,
&pl.label_color,
ChartTextAlign::Center,
fonts,
measurer,
);
@@ -1136,29 +1164,22 @@ fn render_chart(
parse_color("#999999"),
0.5,
);
if slice.cat_label_anchor_end {
chart_text_end(
surface,
slice.cat_label_x,
slice.cat_label_y,
&slice.cat_label_text,
2.5,
"#555555",
fonts,
measurer,
);
let align = if slice.cat_label_anchor_end {
ChartTextAlign::End
} else {
chart_text_start(
surface,
slice.cat_label_x,
slice.cat_label_y,
&slice.cat_label_text,
2.5,
"#555555",
fonts,
measurer,
);
}
ChartTextAlign::Start
};
chart_text(
surface,
slice.cat_label_x,
slice.cat_label_y,
&slice.cat_label_text,
2.5,
"#555555",
align,
fonts,
measurer,
);
}
}
}
@@ -1177,13 +1198,14 @@ fn render_chart(
legend.swatch_size,
color,
);
chart_text_start(
chart_text(
surface,
item.text_x,
item.text_y,
&item.name,
legend.font_size,
"#666666",
ChartTextAlign::Start,
fonts,
measurer,
);
@@ -1196,14 +1218,14 @@ fn render_chart(
if let Some(ref x_label) = data.x_label {
let lx = cl.plot_x + cl.plot_w / 2.0;
let ly = base_y_mm + h_mm - 2.0;
chart_text_centered(surface, lx, ly, x_label, 2.8, "#666666", fonts, measurer);
chart_text(surface, lx, ly, x_label, 2.8, "#666666", ChartTextAlign::Center, fonts, measurer);
}
if let Some(ref y_label) = data.y_label {
let lx = base_x_mm + 3.0;
let ly = cl.plot_y + cl.plot_h / 2.0;
surface.push_transform(&Transform::from_translate(pt(lx), pt(ly)));
surface.push_transform(&Transform::from_translate(mm(lx), mm(ly)));
surface.push_transform(&Transform::from_row(0.0, -1.0, 1.0, 0.0, 0.0, 0.0));
chart_text_centered(surface, 0.0, 0.0, y_label, 2.8, "#666666", fonts, measurer);
chart_text(surface, 0.0, 0.0, y_label, 2.8, "#666666", ChartTextAlign::Center, fonts, measurer);
surface.pop();
surface.pop();
}
@@ -1219,7 +1241,7 @@ fn chart_rect(
rh: f64,
color: rgb::Color,
) {
let (rx, ry, rw, rh) = (pt(rx), pt(ry), pt(rw), pt(rh));
let (rx, ry, rw, rh) = (mm(rx), mm(ry), mm(rw), mm(rh));
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
let path = {
@@ -1243,7 +1265,7 @@ fn chart_line_seg(
color: rgb::Color,
width: f32,
) {
let (x1, y1, x2, y2) = (pt(x1), pt(y1), pt(x2), pt(y2));
let (x1, y1, x2, y2) = (mm(x1), mm(y1), mm(x2), mm(y2));
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: color.into(),
@@ -1262,93 +1284,52 @@ fn chart_line_seg(
}
}
/// Chart icin metin ciz — tek satirlik, centered
/// font_size_mm: SVG viewBox'taki mm cinsinden boyut, pt'ye cevrilir
#[allow(clippy::too_many_arguments)]
fn chart_text_centered(
surface: &mut krilla::surface::Surface<'_>,
cx_mm: f64,
cy_mm: f64,
text: &str,
font_size_mm: f64,
color_hex: &str,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) {
let font = fonts.get(None, None, None);
let Some(f) = font else {
return;
};
let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm);
let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
surface.draw_text(
Point::from_xy(pt(cx_mm) - tw / 2.0, pt(cy_mm)),
f.clone(),
fs_pt,
text,
false,
TextDirection::Auto,
);
/// Chart metin hizalama modu
enum ChartTextAlign {
Start,
Center,
End,
}
/// Chart icin metin ciz — end-aligned (sag hizali)
/// Chart için tek satır metin çiz (mm cinsinden koordinatlar, pt'ye çevrilir)
#[allow(clippy::too_many_arguments)]
fn chart_text_end(
surface: &mut krilla::surface::Surface<'_>,
right_x_mm: f64,
cy_mm: f64,
text: &str,
font_size_mm: f64,
color_hex: &str,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) {
let font = fonts.get(None, None, None);
let Some(f) = font else {
return;
};
let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm);
let (tw, _) = measurer.measure(text, None, fs_pt, None, None);
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
surface.draw_text(
Point::from_xy(pt(right_x_mm) - tw, pt(cy_mm)),
f.clone(),
fs_pt,
text,
false,
TextDirection::Auto,
);
}
/// Chart icin metin ciz — start-aligned (sol hizali)
#[allow(clippy::too_many_arguments)]
fn chart_text_start(
fn chart_text(
surface: &mut krilla::surface::Surface<'_>,
x_mm: f64,
cy_mm: f64,
y_mm: f64,
text: &str,
font_size_mm: f64,
color_hex: &str,
align: ChartTextAlign,
fonts: &FontCollection,
_measurer: &mut TextMeasurer,
measurer: &mut TextMeasurer,
) {
let font = fonts.get(None, None, None);
let Some(f) = font else {
let Some(font) = fonts.get(None, None, None) else {
return;
};
let color = parse_color(color_hex);
let fs_pt = pt(font_size_mm);
let fs = mm(font_size_mm);
let px = mm(x_mm);
let py = mm(y_mm);
let draw_x = match align {
ChartTextAlign::Start => px,
ChartTextAlign::Center => {
let (tw, _) = measurer.measure(text, None, fs, None, None);
px - tw / 2.0
}
ChartTextAlign::End => {
let (tw, _) = measurer.measure(text, None, fs, None, None);
px - tw
}
};
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
surface.draw_text(
Point::from_xy(pt(x_mm), pt(cy_mm)),
f.clone(),
fs_pt,
Point::from_xy(draw_x, py),
font.clone(),
fs,
text,
false,
TextDirection::Auto,
@@ -1363,13 +1344,14 @@ fn render_chart_y_axis(
measurer: &mut TextMeasurer,
) {
for tick in &y_axis.ticks {
chart_text_end(
chart_text(
surface,
y_axis.axis_x - 1.5,
tick.y + 0.8,
&tick.label,
2.3,
"#666666",
ChartTextAlign::End,
fonts,
measurer,
);
@@ -1409,31 +1391,33 @@ fn render_chart_x_labels(
let angle = x_labels.rotate_angle;
for label in &x_labels.labels {
if angle > 0.0 {
surface.push_transform(&Transform::from_translate(pt(label.x), pt(label.y)));
surface.push_transform(&Transform::from_translate(mm(label.x), mm(label.y)));
let angle_rad = (angle as f32).to_radians();
let c = angle_rad.cos();
let s = angle_rad.sin();
surface.push_transform(&Transform::from_row(c, -s, s, c, 0.0, 0.0));
chart_text_end(
chart_text(
surface,
0.0,
0.0,
&label.text,
2.2,
"#666666",
ChartTextAlign::End,
fonts,
measurer,
);
surface.pop();
surface.pop();
} else {
chart_text_centered(
chart_text(
surface,
label.x,
label.y,
&label.text,
2.5,
"#666666",
ChartTextAlign::Center,
fonts,
measurer,
);
@@ -1452,19 +1436,19 @@ fn build_arc_path(
) -> Option<krilla::geom::Path> {
let mut pb = PathBuilder::new();
let sx = pt(cx + radius * start.cos());
let sy = pt(cy + radius * start.sin());
let sx = mm(cx + radius * start.cos());
let sy = mm(cy + radius * start.sin());
if inner_r > 0.0 {
pb.move_to(sx, sy);
approximate_arc(&mut pb, cx, cy, radius, start, end);
let ix = pt(cx + inner_r * end.cos());
let iy = pt(cy + inner_r * end.sin());
let ix = mm(cx + inner_r * end.cos());
let iy = mm(cy + inner_r * end.sin());
pb.line_to(ix, iy);
approximate_arc(&mut pb, cx, cy, inner_r, end, start);
pb.close();
} else {
pb.move_to(pt(cx), pt(cy));
pb.move_to(mm(cx), mm(cy));
pb.line_to(sx, sy);
approximate_arc(&mut pb, cx, cy, radius, start, end);
pb.close();
@@ -1496,7 +1480,7 @@ fn approximate_arc(pb: &mut PathBuilder, cx: f64, cy: f64, r: f64, start: f64, e
let c2x = p2x + k * r * a2.sin();
let c2y = p2y - k * r * a2.cos();
pb.cubic_to(pt(c1x), pt(c1y), pt(c2x), pt(c2y), pt(p2x), pt(p2y));
pb.cubic_to(mm(c1x), mm(c1y), mm(c2x), mm(c2y), mm(p2x), mm(p2y));
}
}
@@ -1603,17 +1587,14 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("root".to_string(), SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
@@ -1628,17 +1609,14 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("title".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
style: TextStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
@@ -1647,34 +1625,28 @@ mod tests {
content: "FATURA".to_string(),
}),
TemplateElement::Line(LineElement {
id: "line1".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("line1".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
style: LineStyle {
stroke_color: Some("#000000".to_string()),
stroke_width: Some(0.5),
},
}),
TemplateElement::Text(TextElement {
id: "firma".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("firma".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
style: TextStyle {
font_size: Some(11.0),
..Default::default()
@@ -1804,7 +1776,7 @@ mod tests {
#[test]
fn test_pt_conversion() {
let result = pt(25.4);
let result = mm(25.4);
assert!((result - 72.0).abs() < 0.01);
}

View File

@@ -138,7 +138,7 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
};
// Pozisyon moduna göre
match &el.position {
match &el.base.position {
PositionMode::Absolute { x, y } => {
style.position = Position::Absolute;
style.inset = Rect {
@@ -152,7 +152,7 @@ pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>)
}
// Boyut
apply_size_to_style(&mut style, &el.size, parent_direction);
apply_size_to_style(&mut style, &el.base.size, parent_direction);
// Container border
if let Some(bw) = el.style.border_width {
@@ -197,7 +197,7 @@ pub fn leaf_style(
#[cfg(test)]
mod tests {
use super::*;
use dreport_core::models::{ContainerStyle, Padding};
use dreport_core::models::{ContainerStyle, ElementBase, Padding};
#[test]
fn test_mm_to_pt_conversion() {
@@ -327,10 +327,7 @@ mod tests {
#[test]
fn test_container_to_style_direction() {
let el = ContainerElement {
id: "test".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("test".to_string(), SizeConstraint::default()),
direction: "row".to_string(),
gap: 5.0,
padding: Padding {
@@ -354,10 +351,12 @@ mod tests {
#[test]
fn test_container_to_style_absolute() {
let el = ContainerElement {
id: "test".to_string(),
condition: None,
position: PositionMode::Absolute { x: 20.0, y: 30.0 },
size: SizeConstraint::default(),
base: ElementBase {
id: "test".to_string(),
condition: None,
position: PositionMode::Absolute { x: 20.0, y: 30.0 },
size: SizeConstraint::default(),
},
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),

View File

@@ -186,7 +186,7 @@ pub fn expand_table_cached(
) -> ContainerElement {
let rows = resolved
.tables
.get(&table.id)
.get(&table.base.id)
.map(|t| t.rows.as_slice())
.unwrap_or(&[]);
let key = table_cache_key(table, rows, available_width_mm);
@@ -211,7 +211,7 @@ pub fn expand_table(
measurer: &mut TextMeasurer,
available_width_mm: f64,
) -> ContainerElement {
let resolved_table = resolved.tables.get(&table.id);
let resolved_table = resolved.tables.get(&table.base.id);
let rows = resolved_table.map(|t| t.rows.as_slice()).unwrap_or(&[]);
// Auto sütunlar için içerik bazlı genişlik hesapla
@@ -232,17 +232,14 @@ pub fn expand_table(
.enumerate()
.map(|(i, col)| {
let text = TemplateElement::StaticText(StaticTextElement {
id: format!("{}_hdr_{}", table.id, i),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
base: ElementBase::flow(
format!("{}_hdr_{}", table.base.id, i),
SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
),
style: TextStyle {
font_size: table.style.header_font_size.or(table.style.font_size),
font_weight: Some("bold".to_string()),
@@ -254,25 +251,13 @@ pub fn expand_table(
content: col.title.clone(),
});
TemplateElement::Container(ContainerElement {
id: format!("{}_hdr_{}_wrap", table.id, i),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: effective_widths[i].clone(),
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
base: ElementBase::flow(
format!("{}_hdr_{}_wrap", table.base.id, i),
SizeConstraint { width: effective_widths[i].clone(), ..Default::default() },
),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
top: header_pad_v,
right: header_pad_h,
bottom: header_pad_v,
left: header_pad_h,
},
padding: Padding { top: header_pad_v, right: header_pad_h, bottom: header_pad_v, left: header_pad_h },
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
@@ -283,31 +268,16 @@ pub fn expand_table(
.collect();
children.push(TemplateElement::Container(ContainerElement {
id: format!("{}_header", table.id),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
base: ElementBase::flow(
format!("{}_header", table.base.id),
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
),
direction: "row".to_string(),
gap: 0.0,
padding: Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
},
padding: Padding::default(),
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
background_color: table.style.header_bg.clone(),
..Default::default()
},
style: ContainerStyle { background_color: table.style.header_bg.clone(), ..Default::default() },
children: header_cells,
break_inside: "auto".to_string(),
}));
@@ -315,17 +285,10 @@ pub fn expand_table(
// Header altına ayırıcı çizgi
if table.style.border_color.is_some() {
children.push(TemplateElement::Line(LineElement {
id: format!("{}_header_line", table.id),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
base: ElementBase::flow(
format!("{}_header_line", table.base.id),
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
),
style: LineStyle {
stroke_color: table.style.border_color.clone(),
stroke_width: table.style.border_width,
@@ -343,17 +306,10 @@ pub fn expand_table(
let text_content = row_data.get(col_idx).cloned().unwrap_or_default();
let text = TemplateElement::StaticText(StaticTextElement {
id: format!("{}_r{}c{}", table.id, row_idx, col_idx),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
base: ElementBase::flow(
format!("{}_r{}c{}", table.base.id, row_idx, col_idx),
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
),
style: TextStyle {
font_size: table.style.font_size,
font_weight: None,
@@ -365,25 +321,13 @@ pub fn expand_table(
content: text_content,
});
TemplateElement::Container(ContainerElement {
id: format!("{}_r{}c{}_wrap", table.id, row_idx, col_idx),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: effective_widths[col_idx].clone(),
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
base: ElementBase::flow(
format!("{}_r{}c{}_wrap", table.base.id, row_idx, col_idx),
SizeConstraint { width: effective_widths[col_idx].clone(), ..Default::default() },
),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
top: cell_pad_v,
right: cell_pad_h,
bottom: cell_pad_v,
left: cell_pad_h,
},
padding: Padding { top: cell_pad_v, right: cell_pad_h, bottom: cell_pad_v, left: cell_pad_h },
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
@@ -401,31 +345,16 @@ pub fn expand_table(
};
children.push(TemplateElement::Container(ContainerElement {
id: format!("{}_row_{}", table.id, row_idx),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
base: ElementBase::flow(
format!("{}_row_{}", table.base.id, row_idx),
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
),
direction: "row".to_string(),
gap: 0.0,
padding: Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
},
padding: Padding::default(),
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
background_color: bg,
..Default::default()
},
style: ContainerStyle { background_color: bg, ..Default::default() },
children: cells,
break_inside: "auto".to_string(),
}));
@@ -433,18 +362,15 @@ pub fn expand_table(
// Wrapper container (column direction, tüm tablo)
ContainerElement {
id: table.id.clone(),
condition: None,
position: table.position.clone(),
size: table.size.clone(),
base: ElementBase {
id: table.base.id.clone(),
condition: None,
position: table.base.position.clone(),
size: table.base.size.clone(),
},
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
},
padding: Padding::default(),
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
@@ -478,14 +404,10 @@ mod tests {
.collect();
RepeatingTableElement {
id: "tbl".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
base: ElementBase::flow(
"tbl".to_string(),
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
),
data_source: ArrayBinding {
path: "items".to_string(),
},
@@ -554,7 +476,7 @@ mod tests {
let container = expand_table(&table, &resolved, &mut measurer, 180.0);
// Wrapper container properties
assert_eq!(container.id, "tbl");
assert_eq!(container.base.id, "tbl");
assert_eq!(container.direction, "column");
// Children: header row + 2 data rows (no border_color so no separator line)
@@ -563,7 +485,7 @@ mod tests {
// First child is header row container
match &container.children[0] {
TemplateElement::Container(c) => {
assert_eq!(c.id, "tbl_header");
assert_eq!(c.base.id, "tbl_header");
assert_eq!(c.direction, "row");
assert_eq!(c.children.len(), 2); // 2 columns
// Check header cell text (inside wrapper container)
@@ -577,7 +499,7 @@ mod tests {
for (row_idx, child) in container.children[1..].iter().enumerate() {
match child {
TemplateElement::Container(c) => {
assert_eq!(c.id, format!("tbl_row_{}", row_idx));
assert_eq!(c.base.id, format!("tbl_row_{}", row_idx));
assert_eq!(c.direction, "row");
assert_eq!(c.children.len(), 2);
}
@@ -666,7 +588,7 @@ mod tests {
// Second child should be a Line
match &container.children[1] {
TemplateElement::Line(l) => {
assert_eq!(l.id, "tbl_header_line");
assert_eq!(l.base.id, "tbl_header_line");
}
_ => panic!("Expected Line separator after header"),
}
@@ -738,14 +660,10 @@ mod tests {
];
let table = RepeatingTableElement {
id: "tbl".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
base: ElementBase::flow(
"tbl".to_string(),
SizeConstraint { width: SizeValue::Fr { value: 1.0 }, ..Default::default() },
),
data_source: ArrayBinding {
path: "items".to_string(),
},
@@ -769,14 +687,14 @@ mod tests {
match &container.children[0] {
TemplateElement::Container(c) => {
let w0 = match &c.children[0] {
TemplateElement::Container(wrap) => match &wrap.size.width {
TemplateElement::Container(wrap) => match &wrap.base.size.width {
SizeValue::Fixed { value } => *value,
_ => panic!("Expected Fixed width for auto column wrapper"),
},
_ => panic!("Expected Container wrapper"),
};
let w1 = match &c.children[1] {
TemplateElement::Container(wrap) => match &wrap.size.width {
TemplateElement::Container(wrap) => match &wrap.base.size.width {
SizeValue::Fixed { value } => *value,
_ => panic!("Expected Fixed width for auto column wrapper"),
},
@@ -811,7 +729,7 @@ mod tests {
// Second call — same inputs — cache hit
let result2 = expand_table_cached(&table, &resolved, &mut measurer, 180.0, &mut cache);
assert_eq!(cache.len(), 1); // no new entry
assert_eq!(result1.id, result2.id);
assert_eq!(result1.base.id, result2.base.id);
assert_eq!(result1.children.len(), result2.children.len());
}

View File

@@ -185,7 +185,7 @@ fn collect_break_modes(root: &ContainerElement) -> HashMap<String, String> {
fn collect_break_modes_recursive(el: &TemplateElement, modes: &mut HashMap<String, String>) {
if let TemplateElement::Container(c) = el {
modes.insert(c.id.clone(), c.break_inside.clone());
modes.insert(c.base.id.clone(), c.break_inside.clone());
for child in &c.children {
collect_break_modes_recursive(child, modes);
}
@@ -208,7 +208,7 @@ fn collect_no_repeat_recursive(el: &TemplateElement, set: &mut std::collections:
}
TemplateElement::RepeatingTable(t) => {
if t.repeat_header == Some(false) {
set.insert(t.id.clone());
set.insert(t.base.id.clone());
}
}
_ => {}
@@ -233,7 +233,7 @@ fn build_container(
// Child'lar için kullanılabilir genişliği hesapla
// Container'ın kendi padding ve border'ını çıkar
let border_w = el.style.border_width.unwrap_or(0.0);
let container_own_width = match &el.size.width {
let container_own_width = match &el.base.size.width {
SizeValue::Fixed { value } => *value,
_ => page_width_mm, // Fr veya Auto ise parent'ın genişliğini kullan
};
@@ -268,17 +268,10 @@ fn build_container(
node_map.insert(
node,
NodeInfo {
element_id: el.id.clone(),
element_type: "container".to_string(),
element_id: el.base.id.clone(),
element_type: el.type_str().to_string(),
content: None,
style: ResolvedStyle {
background_color: el.style.background_color.clone(),
border_color: el.style.border_color.clone(),
border_width: el.style.border_width,
border_radius: el.style.border_radius,
border_style: el.style.border_style.clone(),
..Default::default()
},
style: (&el.style).into(),
children_ids,
},
);
@@ -286,6 +279,30 @@ fn build_container(
Ok(node)
}
/// Leaf node oluştur ve node_map'e kaydet (tekrarlayan boilerplate'i ortadan kaldırır).
fn register_leaf(
taffy: &mut TaffyTree<MeasureContext>,
node_map: &mut HashMap<NodeId, NodeInfo>,
style: Style,
id: &str,
element_type: &str,
content: Option<ResolvedContent>,
resolved_style: ResolvedStyle,
) -> Result<NodeId, LayoutError> {
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: id.to_string(),
element_type: element_type.to_string(),
content,
style: resolved_style,
children_ids: vec![],
},
);
Ok(node)
}
/// Herhangi bir element tipini taffy node'a çevir
#[allow(clippy::too_many_arguments)]
fn build_element(
@@ -309,162 +326,97 @@ fn build_element(
page_width_mm,
table_cache,
),
TemplateElement::StaticText(e) => build_text_leaf(
TemplateElement::StaticText(e) => build_resolved_text_leaf(
&e.base,
e.type_str(),
&e.style,
taffy,
node_map,
&e.id,
"static_text",
resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or(&e.content),
&e.style,
&e.size,
&e.position,
resolved,
parent_direction,
&e.content,
),
TemplateElement::Text(e) => build_resolved_text_leaf(
&e.base,
e.type_str(),
&e.style,
taffy,
node_map,
resolved,
parent_direction,
"",
),
TemplateElement::PageNumber(e) => build_resolved_text_leaf(
&e.base,
e.type_str(),
&e.style,
taffy,
node_map,
resolved,
parent_direction,
"1 / 1",
),
TemplateElement::CurrentDate(e) => build_resolved_text_leaf(
&e.base,
e.type_str(),
&e.style,
taffy,
node_map,
resolved,
parent_direction,
"",
),
TemplateElement::CalculatedText(e) => build_resolved_text_leaf(
&e.base,
e.type_str(),
&e.style,
taffy,
node_map,
resolved,
parent_direction,
"",
),
TemplateElement::Text(e) => {
let text = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"text",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::PageNumber(e) => {
let text = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("1 / 1");
build_text_leaf(
taffy,
node_map,
&e.id,
"page_number",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::CurrentDate(e) => {
let text = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"current_date",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::CalculatedText(e) => {
let text = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"calculated_text",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::Line(e) => {
let stroke_w = e.style.stroke_width.unwrap_or(0.5);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
// Line: genişlik parent'tan, yükseklik stroke width
let mut leaf_style = style;
if matches!(e.size.height, SizeValue::Auto) {
leaf_style.size.height = Dimension::length(mm_to_pt(stroke_w));
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
if matches!(e.base.size.height, SizeValue::Auto) {
style.size.height = Dimension::length(mm_to_pt(stroke_w));
}
let node = taffy.new_leaf(leaf_style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "line".to_string(),
content: Some(ResolvedContent::Line),
style: ResolvedStyle {
stroke_color: e.style.stroke_color.clone(),
stroke_width: Some(stroke_w),
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
let mut rs: ResolvedStyle = (&e.style).into();
rs.stroke_width = Some(stroke_w);
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Line),
rs,
)
}
TemplateElement::Image(e) => {
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let src = resolved.images.get(&e.id).cloned().unwrap_or_default();
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "image".to_string(),
content: Some(ResolvedContent::Image { src }),
style: ResolvedStyle {
object_fit: e.style.object_fit.clone(),
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
let src = resolved.images.get(&e.base.id).cloned().unwrap_or_default();
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Image { src }),
(&e.style).into(),
)
}
TemplateElement::Barcode(e) => {
let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let value = resolved.barcodes.get(&e.id).cloned().unwrap_or_default();
// Barcode leaf'e minimum boyut ver (MeasureFunc yok, Auto=0 olur)
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
let value = resolved.barcodes.get(&e.base.id).cloned().unwrap_or_default();
let is_qr = e.format == "qr";
let default_h = if is_qr { 20.0 } else { 15.0 }; // mm
let default_w = if is_qr { 20.0 } else { 40.0 }; // mm
if matches!(e.size.height, SizeValue::Auto) {
style.min_size.height = Dimension::length(mm_to_pt(default_h));
if matches!(e.base.size.height, SizeValue::Auto) {
style.min_size.height = Dimension::length(mm_to_pt(if is_qr { 20.0 } else { 15.0 }));
}
if matches!(e.size.width, SizeValue::Auto) {
style.min_size.width = Dimension::length(mm_to_pt(default_w));
if matches!(e.base.size.width, SizeValue::Auto) {
style.min_size.width = Dimension::length(mm_to_pt(if is_qr { 20.0 } else { 40.0 }));
}
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "barcode".to_string(),
content: Some(ResolvedContent::Barcode {
format: e.format.clone(),
value,
}),
style: ResolvedStyle {
barcode_color: e.style.color.clone(),
barcode_include_text: e.style.include_text,
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Barcode { format: e.format.clone(), value }),
(&e.style).into(),
)
}
TemplateElement::RepeatingTable(e) => {
// Tabloyu container ağacına expand et (cache ile)
@@ -497,67 +449,37 @@ fn build_element(
)
}
TemplateElement::Shape(e) => {
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "shape".to_string(),
content: Some(ResolvedContent::Shape {
shape_type: e.shape_type.clone(),
}),
style: ResolvedStyle {
background_color: e.style.background_color.clone(),
border_color: e.style.border_color.clone(),
border_width: e.style.border_width,
border_radius: e.style.border_radius,
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Shape { shape_type: e.shape_type.clone() }),
(&e.style).into(),
)
}
TemplateElement::Checkbox(e) => {
let checked_str = resolved
let checked = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("false");
let checked = checked_str == "true";
.get(&e.base.id)
.map(|s| s == "true")
.unwrap_or(false);
let box_size_mm = e.style.size.unwrap_or(4.0);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
// Auto size → square based on style.size
let mut leaf_style = style;
if matches!(e.size.width, SizeValue::Auto) {
leaf_style.size.width = Dimension::length(mm_to_pt(box_size_mm));
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
if matches!(e.base.size.width, SizeValue::Auto) {
style.size.width = Dimension::length(mm_to_pt(box_size_mm));
}
if matches!(e.size.height, SizeValue::Auto) {
leaf_style.size.height = Dimension::length(mm_to_pt(box_size_mm));
if matches!(e.base.size.height, SizeValue::Auto) {
style.size.height = Dimension::length(mm_to_pt(box_size_mm));
}
let node = taffy.new_leaf(leaf_style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "checkbox".to_string(),
content: Some(ResolvedContent::Checkbox { checked }),
style: ResolvedStyle {
color: e.style.check_color.clone(),
border_color: e.style.border_color.clone(),
border_width: e.style.border_width,
..Default::default()
},
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
Some(ResolvedContent::Checkbox { checked }),
(&e.style).into(),
)
}
TemplateElement::RichText(e) => {
let spans = resolved.rich_texts.get(&e.id).cloned().unwrap_or_default();
let spans = resolved.rich_texts.get(&e.base.id).cloned().unwrap_or_default();
let rich_span_measures: Vec<crate::text_measure::RichSpanMeasure> = spans
.iter()
.map(|s| crate::text_measure::RichSpanMeasure {
@@ -573,7 +495,7 @@ fn build_element(
.map(|s| s.font_size_pt)
.fold(11.0f32, f32::max);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
let context = MeasureContext {
text: String::new(),
@@ -600,48 +522,33 @@ fn build_element(
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "rich_text".to_string(),
element_id: e.base.id.clone(),
element_type: e.type_str().to_string(),
content: Some(ResolvedContent::RichText {
spans: resolved_spans,
}),
style: ResolvedStyle {
font_size: e.style.font_size,
font_weight: e.style.font_weight.clone(),
font_family: e.style.font_family.clone(),
color: e.style.color.clone(),
text_align: e.style.align.clone(),
..Default::default()
},
style: (&e.style).into(),
children_ids: vec![],
},
);
Ok(node)
}
TemplateElement::Chart(e) => {
let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction);
// Default minimum boyut — Auto ise chart cok kucuk olmasin
if matches!(e.size.width, SizeValue::Auto) {
let mut style = sizing::leaf_style(&e.base.size, &e.base.position, parent_direction);
if matches!(e.base.size.width, SizeValue::Auto) {
style.min_size.width = Dimension::length(mm_to_pt(80.0));
}
if matches!(e.size.height, SizeValue::Auto) {
if matches!(e.base.size.height, SizeValue::Auto) {
style.min_size.height = Dimension::length(mm_to_pt(60.0));
}
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "chart".to_string(),
content: None, // SVG collect_layout'ta uretilecek
style: ResolvedStyle::default(),
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
None, // SVG collect_layout'ta üretilecek
ResolvedStyle::default(),
)
}
TemplateElement::PageBreak(e) => {
// Küçük yükseklik — editörde görünür olması için (0.5mm ≈ 1.4pt)
let style = Style {
size: Size {
width: Dimension::auto(),
@@ -649,18 +556,12 @@ fn build_element(
},
..Default::default()
};
let node = taffy.new_leaf(style)?;
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "page_break".to_string(),
content: None,
style: ResolvedStyle::default(),
children_ids: vec![],
},
);
Ok(node)
register_leaf(
taffy, node_map, style,
&e.base.id, e.type_str(),
None,
ResolvedStyle::default(),
)
}
}
}
@@ -669,7 +570,7 @@ fn build_element(
fn register_expanded_texts(el: &TemplateElement, resolved: &mut ResolvedData) {
match el {
TemplateElement::StaticText(e) => {
resolved.texts.insert(e.id.clone(), e.content.clone());
resolved.texts.insert(e.base.id.clone(), e.content.clone());
}
TemplateElement::Container(e) => {
for child in &e.children {
@@ -680,6 +581,35 @@ fn register_expanded_texts(el: &TemplateElement, resolved: &mut ResolvedData) {
}
}
/// Generic text leaf builder — HasTextStyle trait ile text-benzeri elementleri tek yerde build eder
fn build_resolved_text_leaf(
el_base: &ElementBase,
el_type_str: &str,
text_style: &TextStyle,
taffy: &mut TaffyTree<MeasureContext>,
node_map: &mut HashMap<NodeId, NodeInfo>,
resolved: &ResolvedData,
parent_direction: Option<&str>,
fallback_text: &str,
) -> Result<NodeId, LayoutError> {
let text = resolved
.texts
.get(&el_base.id)
.map(|s| s.as_str())
.unwrap_or(fallback_text);
build_text_leaf(
taffy,
node_map,
&el_base.id,
el_type_str,
text,
text_style,
&el_base.size,
&el_base.position,
parent_direction,
)
}
/// Text leaf node oluştur (static_text, text, page_number için ortak)
#[allow(clippy::too_many_arguments)]
fn build_text_leaf(
@@ -714,15 +644,7 @@ fn build_text_leaf(
content: Some(ResolvedContent::Text {
value: text.to_string(),
}),
style: ResolvedStyle {
font_size: text_style.font_size,
font_weight: text_style.font_weight.clone(),
font_style: text_style.font_style.clone(),
font_family: text_style.font_family.clone(),
color: text_style.color.clone(),
text_align: text_style.align.clone(),
..Default::default()
},
style: text_style.into(),
children_ids: vec![],
},
);
@@ -799,62 +721,12 @@ fn collect_layout(
let w_mm = pt_to_mm(layout.size.width);
let h_mm = pt_to_mm(layout.size.height);
// Chart elementleri icin SVG uret (boyutlar artik belli)
// Chart elementleri için SVG üret (boyutlar artık belli)
let content = if info.element_type == "chart" {
resolved.charts.get(&info.element_id).map(|cd| {
use crate::chart_layout::DEFAULT_COLORS;
use crate::{ChartRenderData, ChartSeriesData};
// Renk paleti olustur
let n_colors = cd.categories.len().max(cd.series.len()).max(1);
let colors: Vec<String> = (0..n_colors)
.map(|i| {
cd.style
.colors
.as_ref()
.and_then(|c| c.get(i).cloned())
.unwrap_or_else(|| DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string())
})
.collect();
ResolvedContent::Chart {
svg: crate::chart_render::render_svg(cd, w_mm, h_mm),
chart_data: Box::new(ChartRenderData {
chart_type: cd.chart_type.clone(),
categories: cd.categories.clone(),
series: cd
.series
.iter()
.map(|s| ChartSeriesData {
name: s.name.clone(),
values: s.values.clone(),
})
.collect(),
title_text: cd.title.as_ref().map(|t| t.text.clone()),
title_font_size: cd.title.as_ref().and_then(|t| t.font_size),
title_color: cd.title.as_ref().and_then(|t| t.color.clone()),
colors,
show_labels: cd.labels.as_ref().is_some_and(|l| l.show),
label_font_size: cd.labels.as_ref().and_then(|l| l.font_size),
show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true),
grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()),
bar_gap: cd.style.bar_gap,
stacked: matches!(
cd.group_mode,
Some(dreport_core::models::GroupMode::Stacked)
),
inner_radius: cd.style.inner_radius,
show_points: cd.style.show_points,
line_width: cd.style.line_width,
background_color: cd.style.background_color.clone(),
label_color: cd.labels.as_ref().and_then(|l| l.color.clone()),
legend_show: cd.legend.as_ref().is_some_and(|l| l.show),
legend_position: cd.legend.as_ref().and_then(|l| l.position.clone()),
legend_font_size: cd.legend.as_ref().and_then(|l| l.font_size),
x_label: cd.axis.as_ref().and_then(|a| a.x_label.clone()),
y_label: cd.axis.as_ref().and_then(|a| a.y_label.clone()),
title_align: cd.title.as_ref().and_then(|t| t.align.clone()),
}),
chart_data: Box::new(crate::ChartRenderData::from(cd)),
}
})
} else {
@@ -902,17 +774,14 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("root".to_string(), SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
@@ -927,17 +796,14 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("title".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
style: TextStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
@@ -946,34 +812,28 @@ mod tests {
content: "FATURA".to_string(),
}),
TemplateElement::Line(LineElement {
id: "line1".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("line1".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
style: LineStyle {
stroke_color: Some("#000000".to_string()),
stroke_width: Some(0.5),
},
}),
TemplateElement::StaticText(StaticTextElement {
id: "body".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("body".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
style: TextStyle {
font_size: Some(11.0),
..Default::default()
@@ -1051,17 +911,14 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("root".to_string(), SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
@@ -1075,17 +932,14 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Container(ContainerElement {
id: "row".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("row".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
direction: "row".to_string(),
gap: 5.0,
padding: Padding {
@@ -1100,17 +954,14 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "left".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("left".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
style: TextStyle {
font_size: Some(11.0),
..Default::default()
@@ -1118,17 +969,14 @@ mod tests {
content: "Sol".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "right".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("right".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
style: TextStyle {
font_size: Some(11.0),
..Default::default()
@@ -1184,17 +1032,14 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("root".to_string(), SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
}),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
@@ -1208,16 +1053,18 @@ mod tests {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "abs_text".to_string(),
condition: None,
position: PositionMode::Absolute { x: 50.0, y: 80.0 },
size: SizeConstraint {
width: SizeValue::Fixed { value: 100.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
base: ElementBase {
id: "abs_text".to_string(),
condition: None,
position: PositionMode::Absolute { x: 50.0, y: 80.0 },
size: SizeConstraint {
width: SizeValue::Fixed { value: 100.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
},
style: TextStyle {
font_size: Some(14.0),
@@ -1276,10 +1123,7 @@ mod tests {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("root".to_string(), sz_auto.clone()),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
@@ -1295,10 +1139,7 @@ mod tests {
children: vec![
// Header row
TemplateElement::Container(ContainerElement {
id: "c_header".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_fr_auto.clone(),
base: ElementBase::flow("c_header".to_string(), sz_fr_auto.clone()),
direction: "row".to_string(),
gap: 5.0,
padding: p0.clone(),
@@ -1309,10 +1150,7 @@ mod tests {
children: vec![
// Sol: firma bilgileri
TemplateElement::Container(ContainerElement {
id: "c_firma".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_fr_auto.clone(),
base: ElementBase::flow("c_firma".to_string(), sz_fr_auto.clone()),
direction: "column".to_string(),
gap: 1.0,
padding: p0.clone(),
@@ -1322,10 +1160,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_unvan".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_firma_unvan".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(14.0),
font_weight: Some("bold".to_string()),
@@ -1334,10 +1169,7 @@ mod tests {
content: "Teknova Yazılım ve Danışmanlık A.Ş.".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_adres".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_firma_adres".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
@@ -1346,10 +1178,7 @@ mod tests {
.to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_il".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_firma_il".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
@@ -1357,10 +1186,7 @@ mod tests {
content: "Istanbul".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_tel".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_firma_tel".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
@@ -1368,10 +1194,7 @@ mod tests {
content: "Tel: +90 212 555 0042".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_vd".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_firma_vd".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
@@ -1379,10 +1202,7 @@ mod tests {
content: "VD: Levent VD".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_firma_vn".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_firma_vn".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(9.0),
..Default::default()
@@ -1393,10 +1213,7 @@ mod tests {
}),
// Sağ: fatura başlığı
TemplateElement::Container(ContainerElement {
id: "c_fatura_baslik".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("c_fatura_baslik".to_string(), sz_auto.clone()),
direction: "column".to_string(),
gap: 2.0,
padding: p0.clone(),
@@ -1406,10 +1223,7 @@ mod tests {
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_baslik".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_fatura_baslik".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
@@ -1418,10 +1232,7 @@ mod tests {
content: "FATURA".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_no".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_fatura_no".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(10.0),
..Default::default()
@@ -1429,10 +1240,7 @@ mod tests {
content: "No: FTR-2026-001547".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_tarih".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_fatura_tarih".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(10.0),
..Default::default()
@@ -1440,10 +1248,7 @@ mod tests {
content: "Tarih: 2026-03-29".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_vade".to_string(),
condition: None,
position: PositionMode::Flow,
size: sz_auto.clone(),
base: ElementBase::flow("el_fatura_vade".to_string(), sz_auto.clone()),
style: TextStyle {
font_size: Some(10.0),
..Default::default()

View File

@@ -27,12 +27,9 @@ fn base_template() -> Template {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 5.0,
condition: None,
padding: Padding {
top: 15.0,
right: 15.0,
@@ -57,14 +54,11 @@ fn test_1_2_text_wrapping_layout_height() {
// Dar bir container'da uzun metin → yükseklik tek satırdan fazla olmalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "long_text".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("long_text".to_string(), SizeConstraint {
width: SizeValue::Fixed { value: 40.0 }, // 40mm genişlik — kısa
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(12.0),
..Default::default()
@@ -94,14 +88,11 @@ fn test_1_2_text_wrapping_pdf_renders() {
// PDF render sırasında text wrapping çalışmalı — crash olmamalı
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "wrap_pdf".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("wrap_pdf".to_string(), SizeConstraint {
width: SizeValue::Fixed { value: 50.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(11.0),
..Default::default()
@@ -125,14 +116,11 @@ fn test_1_2_text_wrapping_pdf_renders() {
fn test_1_3_image_object_fit_in_layout() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Image(ImageElement {
id: "img_contain".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("img_contain".to_string(), SizeConstraint {
width: SizeValue::Fixed { value: 40.0 },
height: SizeValue::Fixed { value: 30.0 },
..Default::default()
},
}),
src: Some("data:image/png;base64,iVBORw0KGgo=".to_string()),
binding: None,
style: ImageStyle {
@@ -167,14 +155,11 @@ fn test_1_4_italic_font_in_pdf() {
tpl.root
.children
.push(TemplateElement::StaticText(StaticTextElement {
id: "italic_text".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("italic_text".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(12.0),
font_style: Some("italic".to_string()),
@@ -205,14 +190,11 @@ fn test_1_4_bold_italic_font_in_pdf() {
tpl.root
.children
.push(TemplateElement::StaticText(StaticTextElement {
id: "bold_italic".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("bold_italic".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(14.0),
font_weight: Some("bold".to_string()),
@@ -239,14 +221,11 @@ fn test_2_1_repeat_header_false_no_repeat_on_second_page() {
tpl.root
.children
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_no_repeat".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("tbl_no_repeat".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
data_source: ArrayBinding {
path: "items".to_string(),
},
@@ -305,14 +284,11 @@ fn test_2_1_repeat_header_true_repeats_on_second_page() {
tpl.root
.children
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_repeat".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("tbl_repeat".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
data_source: ArrayBinding {
path: "items".to_string(),
},
@@ -389,14 +365,11 @@ fn test_2_2_table_column_format_currency() {
tpl.root
.children
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_fmt".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("tbl_fmt".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
data_source: ArrayBinding {
path: "items".to_string(),
},
@@ -459,14 +432,11 @@ fn test_2_2_table_column_format_currency() {
fn test_2_3_rounded_rectangle_renders() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
id: "rounded_shape".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("rounded_shape".to_string(), SizeConstraint {
width: SizeValue::Fixed { value: 50.0 },
height: SizeValue::Fixed { value: 30.0 },
..Default::default()
},
}),
shape_type: "rounded_rectangle".to_string(),
style: ContainerStyle {
background_color: Some("#3b82f6".to_string()),
@@ -505,14 +475,11 @@ fn test_2_3_container_border_radius_renders() {
tpl.root
.children
.push(TemplateElement::StaticText(StaticTextElement {
id: "text_in_rounded".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("text_in_rounded".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(12.0),
..Default::default()
@@ -604,14 +571,11 @@ fn test_2_7_format_config_in_template() {
fn test_ellipse_shape_renders() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
id: "ellipse".to_string(),
position: PositionMode::Flow,
condition: None,
size: SizeConstraint {
base: ElementBase::flow("ellipse".to_string(), SizeConstraint {
width: SizeValue::Fixed { value: 40.0 },
height: SizeValue::Fixed { value: 20.0 },
..Default::default()
},
}),
shape_type: "ellipse".to_string(),
style: ContainerStyle {
background_color: Some("#ff6600".to_string()),
@@ -635,22 +599,21 @@ fn test_ellipse_shape_renders() {
fn test_7_1_condition_gt_hides_element() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "always_visible".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("always_visible".to_string(), SizeConstraint::default()),
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: "Visible".to_string(),
}));
tpl.root.children.push(TemplateElement::Text(TextElement {
id: "conditional_text".to_string(),
condition: Some(Condition {
path: "toplamlar.iskonto".to_string(),
operator: "gt".to_string(),
value: Some(serde_json::json!(0)),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase {
id: "conditional_text".to_string(),
condition: Some(Condition {
path: "toplamlar.iskonto".to_string(),
operator: "gt".to_string(),
value: Some(serde_json::json!(0)),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
},
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: None,
binding: ScalarBinding { path: "toplamlar.iskonto".to_string() },
@@ -676,14 +639,16 @@ fn test_7_1_condition_gt_hides_element() {
fn test_7_1_condition_gt_shows_element() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Text(TextElement {
id: "conditional_text".to_string(),
condition: Some(Condition {
path: "toplamlar.iskonto".to_string(),
operator: "gt".to_string(),
value: Some(serde_json::json!(0)),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase {
id: "conditional_text".to_string(),
condition: Some(Condition {
path: "toplamlar.iskonto".to_string(),
operator: "gt".to_string(),
value: Some(serde_json::json!(0)),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
},
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: None,
binding: ScalarBinding { path: "toplamlar.iskonto".to_string() },
@@ -705,14 +670,16 @@ fn test_7_1_condition_gt_shows_element() {
fn test_7_1_condition_eq_operator() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "status_text".to_string(),
condition: Some(Condition {
path: "durum".to_string(),
operator: "eq".to_string(),
value: Some(serde_json::json!("aktif")),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase {
id: "status_text".to_string(),
condition: Some(Condition {
path: "durum".to_string(),
operator: "eq".to_string(),
value: Some(serde_json::json!("aktif")),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
},
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: "Aktif".to_string(),
}));
@@ -732,14 +699,16 @@ fn test_7_1_condition_eq_operator() {
fn test_7_1_condition_empty_not_empty() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
id: "show_if_exists".to_string(),
condition: Some(Condition {
path: "note".to_string(),
operator: "not_empty".to_string(),
value: None,
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase {
id: "show_if_exists".to_string(),
condition: Some(Condition {
path: "note".to_string(),
operator: "not_empty".to_string(),
value: None,
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
},
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: "Has note".to_string(),
}));
@@ -763,14 +732,16 @@ fn test_7_1_condition_empty_not_empty() {
fn test_7_1_condition_on_container_hides_children() {
let mut tpl = base_template();
tpl.root.children.push(TemplateElement::Container(ContainerElement {
id: "cond_container".to_string(),
condition: Some(Condition {
path: "show".to_string(),
operator: "eq".to_string(),
value: Some(serde_json::json!(true)),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase {
id: "cond_container".to_string(),
condition: Some(Condition {
path: "show".to_string(),
operator: "eq".to_string(),
value: Some(serde_json::json!(true)),
}),
position: PositionMode::Flow,
size: SizeConstraint::default(),
},
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -779,10 +750,7 @@ fn test_7_1_condition_on_container_hides_children() {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "child_text".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("child_text".to_string(), SizeConstraint::default()),
style: TextStyle { font_size: Some(10.0), ..Default::default() },
content: "Child".to_string(),
})],
@@ -854,10 +822,7 @@ fn test_7_5_effective_format_config_priority() {
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -889,10 +854,7 @@ fn test_7_5_effective_format_config_locale_fallback() {
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding::default(),
@@ -915,14 +877,11 @@ fn test_7_5_locale_affects_table_currency_format() {
let mut tpl = base_template();
tpl.locale = Some("en-US".to_string());
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl_locale".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("tbl_locale".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
data_source: ArrayBinding { path: "items".to_string() },
columns: vec![
TableColumn {

View File

@@ -20,10 +20,7 @@ fn simple_template() -> Template {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
@@ -37,14 +34,14 @@ fn simple_template() -> Template {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
base: ElementBase::flow(
"title".to_string(),
SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
),
style: TextStyle {
font_size: Some(14.0),
font_weight: Some("bold".to_string()),
@@ -166,10 +163,7 @@ fn test_compute_layout_with_data_binding() {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
@@ -183,14 +177,14 @@ fn test_compute_layout_with_data_binding() {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement {
id: "bound_text".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
base: ElementBase::flow(
"bound_text".to_string(),
SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
),
style: TextStyle {
font_size: Some(12.0),
..Default::default()
@@ -235,10 +229,7 @@ fn test_compute_layout_multiple_children_ordering() {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
@@ -253,14 +244,14 @@ fn test_compute_layout_multiple_children_ordering() {
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "first".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
base: ElementBase::flow(
"first".to_string(),
SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
),
style: TextStyle {
font_size: Some(12.0),
..Default::default()
@@ -268,14 +259,14 @@ fn test_compute_layout_multiple_children_ordering() {
content: "First".to_string(),
}),
TemplateElement::StaticText(StaticTextElement {
id: "second".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
base: ElementBase::flow(
"second".to_string(),
SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
),
style: TextStyle {
font_size: Some(12.0),
..Default::default()

View File

@@ -23,10 +23,7 @@ fn simple_template() -> Template {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
@@ -40,14 +37,11 @@ fn simple_template() -> Template {
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("title".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
@@ -94,10 +88,7 @@ fn test_render_pdf_with_multiple_elements() {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
@@ -112,14 +103,11 @@ fn test_render_pdf_with_multiple_elements() {
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "header".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("header".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(16.0),
font_weight: Some("bold".to_string()),
@@ -128,28 +116,22 @@ fn test_render_pdf_with_multiple_elements() {
content: "FATURA".to_string(),
}),
TemplateElement::Line(LineElement {
id: "sep".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("sep".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: LineStyle {
stroke_color: Some("#000000".to_string()),
stroke_width: Some(0.5),
},
}),
TemplateElement::StaticText(StaticTextElement {
id: "body".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("body".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(11.0),
..Default::default()
@@ -192,10 +174,7 @@ fn test_render_pdf_with_container_styles() {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
@@ -214,14 +193,11 @@ fn test_render_pdf_with_container_styles() {
},
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement {
id: "text".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("text".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(12.0),
color: Some("#ff0000".to_string()),
@@ -257,10 +233,7 @@ fn test_page_break_produces_multiple_pages() {
format_config: None,
locale: None,
root: ContainerElement {
id: "root".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint::default(),
base: ElementBase::flow("root".to_string(), SizeConstraint::default()),
direction: "column".to_string(),
gap: 5.0,
padding: Padding {
@@ -275,14 +248,11 @@ fn test_page_break_produces_multiple_pages() {
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "t1".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("t1".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(18.0),
..Default::default()
@@ -290,18 +260,14 @@ fn test_page_break_produces_multiple_pages() {
content: "Page 1 content".to_string(),
}),
TemplateElement::PageBreak(PageBreakElement {
id: "pb1".to_string(),
condition: None,
base: ElementBase::flow("pb1".to_string(), SizeConstraint::default()),
}),
TemplateElement::StaticText(StaticTextElement {
id: "t2".to_string(),
condition: None,
position: PositionMode::Flow,
size: SizeConstraint {
base: ElementBase::flow("t2".to_string(), SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
..Default::default()
},
}),
style: TextStyle {
font_size: Some(18.0),
..Default::default()

File diff suppressed because one or more lines are too long